前言
这一篇算是上一篇从源码剖析React Hooks之useReducer、useState的姊妹篇,因为useReducer
和useState
比较相似,useEffect
和useLayoutEffect
比较相似,所以就分别放在一篇文章里面对比讲解。
useEffect
是React 16.8 新增的 Hook,它的作用是执行副作用操作(side effect),比如数据获取,订阅,手动更改DOM等等。
useEffect
就是一个 Effect Hook,给函数组件增加了操作副作用的能力。它跟 class 组件中的 componentDidMount
、componentDidUpdate
和componentWillUnmount
具有相同的用途,只不过被合并成了一个 API。
本文基于React 18.2
带大家进行深入的了解
基本使用
function FunctionComponent() {
const [number, setNumber] = React.useState(0);
React.useEffect(() => {
console.log('useEffect1');
return () => {
console.log('destroy useEffect1');
};
}, []);
React.useLayoutEffect(() => {
console.log('useLayoutEffect2');
return () => {
console.log('destroy useLayoutEffect2');
};
});
React.useEffect(() => {
console.log('useEffect3');
return () => {
console.log('destroy useEffect3');
};
});
React.useLayoutEffect(() => {
console.log('useLayoutEffect4');
return () => {
console.log('destroy useLayoutEffect4');
};
});
return <button onClick={() => setNumber(number + 1)}>{number}</button>;
}
useEffect
接收两个参数:
- effect: 是一个函数,它就是需要执行的副作用操作;
- deps: 是一个依赖数组,effect 函数执行依赖于 deps 数组中变量的变化。
useEffect
首先会在组件 mount 后执行 effect 函数,然后它会在组件 update 后检查 deps 的变化,如果发生变化,则重新执行 effect 函数。
useEffect
还会在组件 unmount 时返回 effect 函数的返回值(如果有的话),通常我们会在 effect 函数中 return 一个清除副作用的函数。
源码分析
React
中的hooks
基本都是在react-reconciler中实现的。hook
的调用也是分为挂载阶段
和更新阶段
。
// src/react-reconciler/src/ReactFiberHooks.js
const HooksDispatcherOnMount = {
useReducer: mountReducer,
useState: mountState,
useEffect: mountEffect,
useLayoutEffect: mountLayoutEffect,
};
const HooksDispatcherOnUpdate = {
useReducer: updateReducer,
useState: updateState,
useEffect: updateEffect,
useLayoutEffect: updateLayoutEffect,
};
我们可以看到我们的函数useEffect
分别是对应mountEffect
和updateEffect
两个函数,也就是我们在第一次渲染的时候调用的是mountEffect
,当我们第二次渲染时(当调用useState
等其它hook
改变状态时),这时候调用的就是updateEffect
这个函数。
// src/react-reconciler/src/ReactFiberHooks.js
function mountEffect(create, deps) {
return mountEffectImpl(PassiveEffect, HookPassive, create, deps);
}
function updateEffect(create, deps) {
return updateEffectImpl(PassiveEffect, HookPassive, create, deps);
}
function mountLayoutEffect(create, deps) {
return mountEffectImpl(UpdateEffect, HookLayout, create, deps);
}
function updateLayoutEffect(create, deps) {
return updateEffectImpl(UpdateEffect, HookLayout, create, deps);
}
这里我们可以看到mountEffect
和mountLayoutEffect
是通过mountEffectImpl
这个方法实现的,
updateEffect
和updateLayoutEffect
是通过updateEffectImpl
这个方法实现的,不同点就是参数的不同,下面我们介绍一下参数代表的含义:
- PassiveEffect —— 作为一个
Fiber
的flag
,就是一个标志,大家就可以认为是为Fiber
添加一个存在useEffect
的标志,后续在提交进行副作用处理。 - HookPassive —— 作为一个是
effect
的标志,useEffect
和useLayoutEffect
结构本身并没有区别(后面会讲到是什么结构),我们通过这个标志来区分。这个标志是useEffect
的标志。 - UpdateEffect —— 同PassiveEffect,是
useLayoutEffect
的标志。 - HookLayout —— 同HookPassive,是
useLayoutEffect
的标志。 - create —— 这个是在使用时传入的函数。
- deps —— 这个是在使用时传入的依赖项。
下面我们来分别看一下mountEffectImpl
和updateEffectImpl
方法的实现。
挂载阶段
mountEffectImpl
function mountEffectImpl(fiberFlags, hookFlags, create, deps) {
const hook = {
memoizedState: null, // hook的状态
queue: null, // 存放本hook的更新队列,queue.pending = update 的循环链表
next: null, // 指向下一把hook,一个函数里里面可能会有多个hook,它们会组成一个单向链表
};
// 允许不传入依赖项,如果不传就被默认成null
const nextDeps = deps === undefined ? null : deps;
// 给当前的函数组件fiber添加flags
currentlyRenderingFiber.flags |= fiberFlags;
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
create,
undefined,
nextDeps,
);
}
这里我们观察这个函数做了哪些事情:
- 创建了一个新的
hook
,包括memoizedState
表示是hook的状态,queue
是hook的更新链表,next
是指向下一个hook的指针。 - 处理依赖项,把
undefined
重新赋值为null
。 - 给函数组件的
fiber
添加标志,表示使用了useEffect
或者useLayoutEffect
。 - 给
hook.memoizedState
的添加一个值,这个值是pushEffect
返回的值,看起来是一个入栈的操作。
要想彻底搞清楚发生了添加了什么内容,我们应该了解一个pushEffect
这个函数。
pushEffect
/**
* 添加effect链表
* @param {*} tag effect的标签
* @param {*} create 创建方法
* @param {*} destroy 销毁方法
* @param {*} deps 依赖数组
*/
function pushEffect(tag, create, destroy, deps) {
const effect = {
tag,
create,
destroy,
deps,
next: null,
};
let componentUpdateQueue = currentlyRenderingFiber.updateQueue;
if (componentUpdateQueue === null) {
componentUpdateQueue = {
lastEffect: null,
};
currentlyRenderingFiber.updateQueue = componentUpdateQueue;
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
const lastEffect = componentUpdateQueue.lastEffect;
if (lastEffect === null) {
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
const firstEffect = lastEffect.next;
lastEffect.next = effect;
effect.next = firstEffect;
componentUpdateQueue.lastEffect = effect;
}
}
return effect;
}
我们看一下这个像是入栈的函数做了哪些事情:
- 创建了一个effect对象,这里包括tag用来标志
useEffect
或者useLayoutEffect
,create是你传入的函数,destroy是create函数的返回值,可能是undefined,deps是依赖项,next是下一个effect。 - 在函数组件上添加了一个
updateQueue
的属性,这个用来指向effect链表。 - 操作这个effect链表,让他们组成一个首尾相连的
环形链表
。 - 返回这个effect对象。
通过这两个函数我想我们应该对这个结构比较好奇,下面根据示意图表示一下。
如图所示,我们通过如上的操作就是得到了这样的结果。其中在函数组件的fiber上的memoizedState
挂上我们hook
链表,每个useEffect
的memoizedState
指向一个effect
对象,所有effect
对象组成一个环形链表。 函数组件fiber上的updateQueue.lastEffect
指向最后一个effect
对象。
思考一下:为什么我们已经拥有hook链表了,为什么还要维护一个effect链表?
是因为我们的hook链表不止包括useEffect
还有其它hook,如果不引入这个链表的话,我们可能会多很多无用的遍历,故而再次又维护了一个effect对象的链表。
更新阶段
updateEffectImpl
点我们进入更新阶段,即第二次进入到useEffect
或者useLayoutEffect
,此时它们是通过updateEffectImpl
实现的,下面我们看看这个函数做了哪些事情。
function updateEffectImpl(fiberFlags, hookFlags, create, deps) {
const hook = {
memoizedState: currentHook.memoizedState,
queue: currentHook.queue,
next: null,
};
const nextDeps = deps === undefined ? null : deps;
let destroy;
//上一个老hook
if (currentHook !== null) {
//获取此useEffect这个Hook上老的effect对象 create deps destroy
const prevEffect = currentHook.memoizedState;
destroy = prevEffect.destroy;
if (nextDeps !== null) {
const prevDeps = prevEffect.deps;
// 用新数组和老数组进行对比,如果一样的话
if (areHookInputsEqual(nextDeps, prevDeps)) {
//不管要不要重新执行,都需要把新的effect组成完整的循环链表放到fiber.updateQueue中
hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
return;
}
}
}
//如果要执行的话需要修改fiber的flags
currentlyRenderingFiber.flags |= fiberFlags;
//如果要执行的话 添加HookHasEffect flag
//刚才有同学问 Passive还需HookHasEffect,因为不是每个Passive都会执行的
hook.memoizedState = pushEffect(HookHasEffect | hookFlags, create, destroy, nextDeps);
}
仔细看代码,发现跟mountEffectImpl
相似,这里我们可以对比着看一下:
- 创建了一个新的
hook
,包括memoizedState
表示是hook的状态此时指向当前hook的memoizedState
,queue
是hook的更新链表此时指向当前hook的queue
,next
是指向下一个hook的指针。 - 处理依赖项,把
undefined
重新赋值为null
。 - 创建
destroy
,就是create
函数返回的函数。 - 通过
areHookInputsEqual
方法逐个对比依赖项,只有当依赖项变化时,继续往下走执行更新逻辑;否则就return,停止更新。 - 给函数组件的
fiber
添加标志,表示使用了useEffect
或者useLayoutEffect
。 - 给
hook.memoizedState
的添加一个值,这个值是pushEffect
的返回值,不同的是我们在第三个参数加入了destroy
。
我们着重指出了更新阶段的不同点,我们创造了destroy
,同时在pushEffect
时添加了该函数,相比挂载阶段,我们开始关注destroy
,也就是说我们只有在更新时才会调用destroy
。
areHookInputsEqual
对比依赖项,只有依赖项变化时,我们才会继续我们更新逻辑,这里我们可以看看areHookInputsEqual
是如何实现的,
function areHookInputsEqual(nextDeps, prevDeps) {
if (prevDeps === null) {
return null;
}
for (let i = 0; i < prevDeps, length && i < nextDeps.lenth; i++) {
if (Object.is(nextDeps[i], prevDeps[i])) {
continue;
}
return false;
}
return true;
}
其实实现是比较简单的,主要就是通过Object.is()
方法来逐项对比依赖项,来看看是否有变化,至于Object.is()的使用,大家可以点击查看。
提交阶段
commitRoot
当我们完成更新阶段,开始进入到提交阶段,来重新渲染我们视图,主要是在commitRoot
函数中进行的,因为这里涉及的关于Fiber
的知识比较多,也为了该篇文章尽量解耦,对一些关于Fiber
操作进行了简化,这里主要是为了体现useEffect
和useLayoutEffect
的执行时机的不同。
// src/react-reconciler/src/ReactFiberWorkLoop.js
function commitRoot(root) {
// 已经完成构建的fiber,上面会包括hook信息
const { finishedWork } = root;
// 如果存在useEffect或者useLayoutEffect
if ((finishedWork.flags & Passive) !== NoFlags) {
if (!rootDoesHavePassiveEffect) {
rootDoesHavePassiveEffect = true;
// 开启下一个宏任务
requestIdleCallback(flushPassiveEffect);
}
}
console.log('开始commit~~~~~~~~~~~~~~~~~~~~~~~');
// 判断自己身上有没有副作用
const rootHasEffect = (finishedWork.flags & MutationMask) !== NoFlags;
// 如果自己的副作用或者子节点有副作用就进行DOM操作
if (rootHasEffect) {
// 当DOM执行变更之后
console.log('DDOM执行变更commitMutationEffectsOnFiber~~~~~~~~~~~~~~');
commitMutationEffectsOnFiber(finishedWork, root);
// 执行layout Effect
console.log(
'DOM执行变更后commitLayoutEffects~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~',
);
commitLayoutEffects(finishedWork, root);
if (rootDoesHavePassiveEffect) {
rootDoesHavePassiveEffect = false;
rootWithPendingPassiveEffects = root;
}
}
// 等DOM变更之后,更改root中current的指向
root.current = finishedWork;
}
我们在开始分析每一步做了哪些事情,我在代码中也给出了一个界限提示:
- 取出已经构建好的
finishedWork
,这是一个完成所有标记的完成态fiber, - 根据
flags
来判断是否在代码中使用过useEffect
,注意是这里只是判断了Passive
,所以只是看有没有使用useEffect
。如果存在就在requestIdleCallback
开启下一个宏任务
,这里要将要执行的就是我们useEffect
中的逻辑。 - 检测是否有副作用操作
3.1 进行DOM变更操作commitMutationEffectsOnFiber
;
3.2 进行useLayoutEffect
中的逻辑
3.3 清空rootDoesHavePassiveEffect
这里我们就可以看出来,对于useEffect
和useLayoutEffect
执行时机的不同,其中useLayoutEffect
属于是同步操作,在DOM变更之前就会同步进行,同时他也会阻塞代码的进行,在UI变更前就会完成,而useEffect
则是需要开启下一个宏任务
需要在本次UI渲染之后进行,我们可以简单的理解为useLayoutEffect
在useEffect
之前进行。
flushPassiveEffect
这里我们在好好观察一下flushPassiveEffect
,是怎么工作的,即useEffect
的工作顺序。
function flushPassiveEffect() {
console.log('下一个宏任务中flushPassiveEffect~~~~~~~~~~');
if (rootWithPendingPassiveEffects !== null) {
const root = rootWithPendingPassiveEffects;
// 执行卸载副作用, destroy
commitPassiveUnmountEffects(root.current);
// 执行挂载副作用 create
commitPassiveMountEffects(root, root.current);
}
}
其实这个函数是比较简单的,这里我们关注点是commitPassiveUnmountEffects
要比函数commitPassiveMountEffects
先执行,即我们的destroy
函数要在比create
函数先执行,这里需要注意的是destroy
是上一次create
的返回值,即我们在执行本次的create
函数之前要先执行上一次的destroy
函数。
总结
本文从挂载阶段、更新阶段和提交阶段分别介绍了useEffect
和useLayoutEffect
的不同表现,其实对于useEffect
和useLayoutEffect
在使用上并无二致,只是在执行时机的不同,主要是以下几点:
useLayoutEffect
要比useEffect
先执行,相比而言前者是在UI变更调用的,而后者是在UI变更之后调用的,所以要慢一步。- 我们的
destroy
函数要在比create
函数先执行,这里需要注意的是destroy
是上一次create
的返回值,即我们在执行本次的create
函数之前要先执行上一次的destroy
函数。