前言
React版本:16.8以上
如果你是一个react忠实用户,看到这个标题肯定会嗤之以鼻,useEffect还能不懂?这每天都要写好几遍的东西,早已如呼吸般自然~
别急,接下来将设置几道关卡,看一看你的useEffect是否如你想那样。
(看过你真的懂useState吗?useState源码解析这篇文章的应该会有股熟悉感~)
第一关:
function Son({ callback }) {
// ...
useEffect(() => {
console.log('callback改变了');
}, [callback]);
// ...
}
function Father() {
const [state, setState] = useState(0);
const Click = () => {
setState(state + 1);
};
const callback = () => {
console.log('测试');
};
return (
<div>
<div onClick={Click}>点我</div>
<Son callback={callback} />
</div>
);
}
每次点击触发Click后的打印是什么?⬆️
function Son({ callback }) {
// ...
useEffect(() => {
console.log('callback改变了');
}, [callback]);
// ...
}
function Father() {
const [state, setState] = useState(0);
const Click = () => {
setState(state + 1);
};
return (
<div>
<div onClick={Click}>点我</div>
<Son callback={setState} />
</div>
);
}
每次点击触发Click后的打印是什么?⬆️
function Son({ callback }) {
// ...
useEffect(() => {
console.log('callback改变了');
}, [callback]);
// ...
}
function Father() {
const [state, setState] = useState(0);
const Click = () => {
setState(state + 1);
};
const callback = useCallback(() => {
console.log('测试');
}, []);
return (
<div>
<div onClick={Click}>点我</div>
<Son callback={callback} />
</div>
);
}
每次点击触发Click后的打印是什么?⬆️
答案揭晓:
- 打印'callback改变了'(触发setState后,Father组件重新render,callback被重新创建,导致与上次传入的引用地址不同,所以触发useEffect)
- 不打印(setState方法是不会随着组件render而被重新创建的,若有疑问请回顾你真的懂useState吗?useState源码解析)
- 不打印(被useCallback包裹后的方法会被缓存起来,不会随着组件render被重新创建)
如果答对恭喜你已经通过了第一关,让我们继续~
第二关:
function Father() {
const [count, setCount] = useState(0);
useEffect(() => {
setInterval(() => {
setCount(count + 1);
console.log(count);
}, 1000);
}, []);
// ...
}
每秒打印什么?⬆️
function Father() {
const [count, setCount] = useState(0);
useEffect(() => {
setInterval(() => {
setCount(count => count + 1);
console.log(count);
}, 1000);
}, []);
// ...
}
每秒打印什么?⬆️
function Father() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count => count + 1);
setCount(count => count + 1);
setCount(count => count + 1);
console.log(count);
}, []);
// ...
}
打印什么?⬆️
答案揭晓:
- 打印0(count为0的时候便被setInterval使用,形成闭包。)
- 打印0(count为0的时候便被setInterval使用,形成闭包。和setState方法无关。)
- 打印0(当前阶段中count依然为0,count的更新在useEffect之后)
如果答对恭喜你已经通过了第二关,让我们继续~
第三关:
function Father() {
const [isRender, setIsRender] = useState(false);
useEffect(() => {
console.log('render')
});
return <div onClick={() => setIsRender(!isRender)}>点我</div>;
}
每次点击打印什么?⬆️
function Father() {
const [isRender, setIsRender] = useState(false);
useEffect(() => {
console.log('render')
}, undefined);
return <div onClick={() => setIsRender(!isRender)}>点我</div>;
}
每次点击打印什么?⬆️
function Father() {
const [isRender, setIsRender] = useState(false);
useEffect(() => {
console.log('render')
}, null);
return <div onClick={() => setIsRender(!isRender)}>点我</div>;
}
每次点击打印什么?⬆️
答案揭晓:
- 每次点击后都打印'render'(第二个参数不传入,每次render都将执行useEffect内容)
- 每次点击后都打印'render'(第二个参数传入undefined等同于不传入,每次render都将执行useEffect内容)
- 每次点击后都打印'render'(第二个参数传入null等同于不传入,每次render都将执行useEffect内容)
以上关卡答案有没有出乎大家的意料呢?如果不了解的话也别急,接下来让我们一起扒开useEffect的外衣,看看他里面究竟藏着什么玄机~
useEffect
useEffect的基本用法
useEffect(callback, deps);
- callback:副作用函数,你在useEffect中记录的逻辑函数,在你的依赖项变化时,react会执行你的逻辑函数。callback有两种情况-void或 () => (() => void) =>有函数返回值和没有函数返回值。
- deps:副作用函数的依赖项,依赖项列表必须有一个常数项,并且必须像[dep1, dep2, dep3]这样内联编写。传入[]将只在首次渲染执行,不传或传undefined或null则每次render都会执行。
callback的两种情况
- 无函数返回值
useEffect(() => {
console.log('render');
}, []);
组件初次创建时:打印'render'
组件销毁时:无打印
- 有函数返回值
useEffect(() => {
console.log('render');
// 返回一个清除副作用的函数
return () => console.log('end')
}, []);
组件初次创建时:打印'render'
组件销毁时:打印'end'
源码分析
我们都知道,react在不同阶段引用的hooks不是同一个函数,useEffect也不例外。首先我们先看一下react中对于useEffect在不同阶段的处理函数。
// ReactFiberHooks.js
export function renderWithHooks<Props, SecondArg>(
current: Fiber | null,
workInProgress: Fiber,
Component: (p: Props, arg: SecondArg) => any,
props: Props,
secondArg: SecondArg,
nextRenderLanes: Lanes,
): any {
//...省略无关代码
// 我们通过判断当前fiber中的memoizedState是否为空来判断当前的阶段
ReactCurrentDispatcher.current =
current === null || current.memoizedState === null
? HooksDispatcherOnMount
: HooksDispatcherOnUpdate;
//...省略无关代码
}
可以看到hooks函数分为mount(初始化)和update(更新)两种状态。我们从一个简单的栗子来分析下useEffect的原理⬇️
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log('mount');
}, []);
useEffect(() => {
console.log('update');
}, [count]);
return <div onClick={() => setCount(count + 1)}>点我</div>;
}
- 初始化阶段-在首次创建App组件时,mountEffect方法被调用,打印'mount'和'update'。
- 更新阶段-在点击div触发setCount事件后,updateEffect方法被调用,打印'update'。
组件初始化(首次render)
首先我们根据源码定位到mountEffect函数⬇️
// ReactFiberHooks.js
function mountEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
if (
// 本地环境调用
__DEV__ &&
(currentlyRenderingFiber.mode & StrictEffectsMode) !== NoMode
) {
mountEffectImpl(
MountPassiveDevEffect | PassiveEffect | PassiveStaticEffect,
HookPassive,
create,
deps,
);
} else {
mountEffectImpl(
PassiveEffect | PassiveStaticEffect,
HookPassive,
create,
deps,
);
}
}
function mountEffectImpl(
fiberFlags: Flags,
hookFlags: HookFlags,
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
// 初始化hook
const hook = mountWorkInProgressHook();
// 创建一个nextDeps变量,用于对比原依赖项
const nextDeps = deps === undefined ? null : deps;
// 标记一个fiber需要重新渲染,或者是更新操作
currentlyRenderingFiber.flags |= fiberFlags;
// 管理副作用函数信息
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
create,
undefined,
nextDeps,
);
}
可以看到初始化阶段直接调用了mountEffectImpl函数,这个函数简要来说就是进行副作用管理的,函数总要分为4步,我们来细细解读这个函数:
- 函数入参。该函数接收4个参数。
-
- fiberFlags:fiberFlags表示当前fiber的标志位。
- hookFlags:hookFlags 表示当前hook的标志位。
- create:前文介绍的副作用函数,也就是useEffect中依赖变化对应的执行函数。
- deps:当前hook副作用函数的依赖项。
- 初始化hook。看过你真的懂useState吗?useState源码解析这篇文章的同学应该熟悉,可以直接跳过,没看过也没关系,我们再介绍一遍⬇️~
首先我们看一段hook节点初始化创建的代码:
function mountWorkInProgressHook(): Hook {
const hook: Hook = {
memoizedState: null,
baseState: null,
baseQueue: null,
queue: null,
next: null,
};
if (workInProgressHook === null) {
// 这是初始化第一个hook节点时
currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
} else {
// 不是第一个节点直接放到节点后面
workInProgressHook = workInProgressHook.next = hook;
}
return workInProgressHook;
}
我们首先来了解下各个变量的含义:
- memoizedState:Fiber上有一个记录组件当前状态的对象叫做memoizedState,有了memoizedState,我们就能在每次渲染时获取当前组件里的相关数据(state或者副作用函数信息)。hook是一个单项链表的结构。如果workInProgressHook为空,表示这是链表中的第一个hook,将当前hook对象设置为组件的memoizedState和workInProgressHook。否则,将当前hook对象添加到链表的末尾,并将workInProgressHook指向当前hook对象。最后返回当前hook对象。
- currentlyRenderingFiber:当前组件渲染对应的fiber对象。
- workInProgressHook:当前运行到的hook,如上图所示,组件内部可能会存在多个hook。
- 标记fiber需要被渲染或更新。将currentlyRenderingFiber.flags的某些位设置为1,表示当前fiber需要进行更新。具体来说,它将fiberFlags的值按位或上 currentlyRenderingFiber.flags 的值。这个操作会将currentlyRenderingFiber的flags属性的某些标志位设为1,表示这个fiber的状态发生了改变。
- 管理副作用函数信息。将副作用函数信息push到hook.memoizedState中,表示当前hook有一个副作用需要进行管理。同时,它还将create函数和nextDeps数组保存在副作用信息中,以便在清除副作用时使用。
总的来说就是:标记fiber需要渲染,并且将需要对比的nextDeps依赖和副作用函数信息放入hook.memoizedState。也就是正在渲染的Fiber节点的update queue中等待消费。
大家可能对源码里放入hook.memoizedState这一步的pushEffect函数会有疑惑,这个函数内部做了什么呢?让我们一起来看一下:
tips:queue数据结构为一个单项环形链表
function pushEffect(
// effect类型
tag: HookFlags,
// 副作用函数,会在组件第一次渲染时执行,用于创建effect,可以返回一个清除函数,用于在组件卸载时清除effect。如果不需要清除函数,可以返回空
create: () => (() => void) | void,
// 副作用函数的返回值,会在组件卸载时执行,用于清除effect。可以为空。
destroy: (() => void) | void,
// effect所依赖的变量
deps: Array<mixed> | void | null,
): Effect {
const effect: Effect = {
tag,
create,
destroy,
deps,
// Circular
next: (null: any),
};
// 获取当前正在渲染的Fiber节点的update queue
let componentUpdateQueue: null | FunctionComponentUpdateQueue =
(currentlyRenderingFiber.updateQueue: any);
// 如果不存在则创建一个新的update queue
if (componentUpdateQueue === null) {
// 创建新的update queue,并将其赋值给Fiber节点的updateQueue属性
componentUpdateQueue = createFunctionComponentUpdateQueue();
currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
// 将创建的effect对象添加到update queue中,如果update queue中没有effect对象,则将effect对象作为第一个和最后一个effect对象(单项环形链表)
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
const lastEffect = componentUpdateQueue.lastEffect;
if (lastEffect === null) {
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
// 如果update queue中已经存在effect对象,则将新创建的effect对象添加到链表的末尾。
const firstEffect = lastEffect.next;
lastEffect.next = effect;
effect.next = firstEffect;
componentUpdateQueue.lastEffect = effect;
}
}
return effect;
}
可以看出函数做了以下几步:
- 创建一个effect对象。对象包含了tag、create、destroy、deps、next。
- 不存在更新队列的操作。获取当前正在渲染的Fiber节点的update queue,如果不存在则创建一个新的update queue,并将其赋值给Fiber节点的updateQueue属性。
- 没有effect对象的操作。将创建的effect对象添加到update queue中,如果update queue中没有effect对象,则将effect对象作为第一个和最后一个effect对象。形成环形链表。
- 有effect对象的操作。如果update queue中已经存在effect对象,则将新创建的effect对象添加到链表的末尾。然后指向头,形成环形链表。
可以了解到pushEffect这个函数做的事就是把副作用信息放入当前fiber节点的update queue(更新队列)中消费。所以以上就是useEffect初始化的过程了。
组件更新
这一步就是useEffect依赖项发生改变后,执行副作用函数的阶段。也就是销毁旧的effect,执行新effect。让我们一起来看看代码具体是怎么实现的⬇️
function updateEffectImpl(
fiberFlags: Flags,
hookFlags: HookFlags,
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
// 前面提到hook为单向链表结构,这里为链表的更新
const hook = updateWorkInProgressHook();
// 获取最新的副作用依赖项
const nextDeps = deps === undefined ? null : deps;
let destroy = undefined;
if (currentHook !== null) {
// 如果当前hook存在,则获取上一次的Effect和destroy函数
const prevEffect = currentHook.memoizedState;
destroy = prevEffect.destroy;
if (nextDeps !== null) {
const prevDeps = prevEffect.deps;
// 如果依赖没有发生变化,则复用上一次的Effect,直接返回。
if (areHookInputsEqual(nextDeps, prevDeps)) {
// 管理副作用函数信息
hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
return;
}
}
}
// 在当前fiber标志位中添加需要更新的标志位
currentlyRenderingFiber.flags |= fiberFlags;
// 管理副作用函数信息
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
create,
destroy,
nextDeps,
);
}
代码非常简单,总的来说每次更新都会进行以下几步:
- 判断当前hook是否存在。存在则获取上一次的effect和destroy,这里应该注意,const prevEffect = currentHook.memoizedState;这段代码中,因为取的是当前hook的memoizedState,所以无论在useEffect的副作用函数里去执行什么setState操作,都不会影响到当前的state。对useState不太清楚的同学可以去看一下你真的懂useState吗?useState源码解析这篇文章。
function Father() {
const [count, setCount] = useState(0);
const [isRender, setIsRender] = useState(false);
useEffect(() => {
setCount(count + 1)
setCount(count + 1)
setCount(count + 1)
console.log(count)
}, [isRender]);
return <div onClick={() => setIsRender(!isRender)}>点我</div>;
}
- 判断nextDeps是否为空。如果为空则默认将副作用函数信息推送到update queue(更新队列) 。不为空则对比前后依赖,是则推送到update queue(更新队列)。
基本步骤大家应该都清楚了,但有同学可能会好奇,都觉得useEffect要比较前后依赖,那是怎么个比较法呢?那就让我们一起来看看他是怎么比较的⬇️
function areHookInputsEqual(
nextDeps: Array<mixed>,
prevDeps: Array<mixed> | null,
): boolean {
// 省略无用代码
if (prevDeps === null) {
return false;
}
for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
if (is(nextDeps[i], prevDeps[i])) {
continue;
}
return false;
}
return true;
}
function is(x: any, y: any) {
return (
(x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) // eslint-disable-line no-self-compare
);
}
是不是出乎意料的简单,就是遍历新旧依赖分别对比。比较规则为:
- 如果x和y都是数字,且它们的值相等,则返回true。
- 如果x和y都是布尔值,且它们的值相等,则返回true。
- 如果x和y都是undefined,则返回true。
- 如果x和y都是null,则返回true。
- 如果x和y都是字符串,且它们的值相等,则返回true。
- 如果x和y都是对象,并且它们的引用相等,则返回true。
- 如果x和y都是NaN,则返回true。
- 如果x和y都是+0或-0,则它们相等。
- 如果x和y不相等,并且它们都不是NaN,则返回false。
总结
我们用一个简单的栗子来回顾下之前的知识点⬇️
function Father() {
const [count, setCount] = useState(0);
const [isRender, setIsRender] = useState(false);
useEffect(() => {
setCount((count) => count + 1);
setCount((count) => count + 1);
setCount((count) => count + 1);
console.log(count)
return () => console.log('destroy')
}, [isRender]);
return <div onClick={() => setIsRender(!isRender)}>点我</div>;
}
从组件创建到销毁总共会进行三步。
- 初始化。
- 在初次渲染时,React会调用mountEffectImpl函数创建一个新的Effect Hook状态,并将其存储到memoizedState中。加入更新队列。
- 执行副作用函数。执行三次setCount((count) => count + 1),count此时为3,被放入了下一次的hook.memoizedState中,这个时候执行console.log(count),取的是当前hook的memoizedState,所以count为0, 打印0。
- 更新。
- 当用户点击按钮时,会触发组件的更新。在更新过程中,React会调用updateEffectImpl函数更新 Effect Hook状态。这时,传入的 deps为 [!isRender]。React会比较 [isRender] 和 [!isRender] ,发现它们不相等,因此需要重新创建一个新的Effect Hook状态,并将其存储到memoizedState 中。加入更新队列。
- 执行副作用函数。在这个例子中,执行三次setCount((count) => count + 1),count此时为6,被放入了下一次的hook.memoizedState中,这个时候执行console.log(count),取的是当前hook的memoizedState,所以count为3, 打印3。
- 卸载。
- React会调用Effect Hook中存储的destroy函数,用于清理该组件产生的副作用。在这个例子中,destroy函数为console.log('destroy'),所以打印'destroy'。
现在大家再回过头去看看useEffect,是否真的已经“如呼吸般自然”~