从源码剖析React Hooks之useEffect、useLayoutEffect
前言
这一篇算是上一篇从源码剖析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
函数。
转载自:https://juejin.cn/post/7205182602366222373