useEffect 源码解析
这篇文章会从 useEffect 的使用开始,到 useEffect API 的实现原理,再到 useEffect 的源码实现,最后会从 render 阶段到 commit 阶段介绍 useEffect 是如何被调度的
基本使用
首先我们先看看 useEffect 的使用方法,有助于理解源码里为什么要这么实现,这么做的目的是什么
useEffect 是一个接受两个参数的函数。传递给 useEffect 的参数
- 第一个参数是一个名为
effect的函数 - 第二个参数(是可选的)是一个存储依赖关系的数组
effect 函数会在 componentDidUpdate 的时候被调用,这个函数的返回值也可以是一个函数,这个返回的函数会在组件卸载的时候调用。
useEffect(() => {
fn()
return () => {
console.log('component')
}
},[])调度流程
对于 useEffect 来说,React 做的事情就是
在 render 阶段,函数组件开始渲染,在 beginWork 阶段,会对特定类型的 component 进行差异化处理,对于 FC 会进入 updateComponent 的逻辑,会调用 renderWithHooks 方法来处理 hooks ,初始化时会创建 hook 链表挂载到 workInProgress 的 memoizedState 上,并创建 effect 链表,这个链表会根据依赖项有差异。
如果依赖项没有变化的话, effect 时不会被处理的,也就不会存在于链表中
在 commit 阶段的 before Mutation 阶段,会发起 useEffect 的异步调度,但是不会直接处理 effect,而是要等到 commit 阶段完成,更新已经处理完,才会开始处理 useEffect 产生的 effect 副作用
从整体上看,useEffect 的整个过程涉及到了 render 阶段和 commit 阶段两部分,render 阶段负责创建 effect 链表,commit 阶段去处理
这就是 useEffect 比较完整的调度流程,下面看以下 useEffect 的具体实现
挂载时 -- MountEffect
在组件的 mount 阶段,执行 useEffect 实际上执行的是 mountEffect
具体实现如下
function mountEffect(
create: () => (() => void) | void,
deps: Array<mixed> | void | null,
): void {
if (
__DEV__ &&
enableStrictEffects &&
(currentlyRenderingFiber.mode & StrictEffectsMode) !== NoMode
) {
return mountEffectImpl(
MountPassiveDevEffect | PassiveEffect | PassiveStaticEffect,
HookPassive, // 标识 hook 为 useEffect
create, // 第一个参数
deps, // 第二个参数
);
} else {
return mountEffectImpl(
PassiveEffect | PassiveStaticEffect,
HookPassive,
create,
deps,
);
}
}可以看到 mountEffect 接受 useEffect 用户传入的 create 回调函数,以及依赖项数组 deps,返回的是 mountEffectImpl 的执行结果
同时传入了用于位运算的 Fiber 节点标识,和 hook 对象的标识,以及两个传参
具体再看看 mountEffectImpl 的实现
mountEffectImpl 实现
在 mountEffectImpl 里
- 首先会调用
mountWorkInProgressHook,将当前的 hook 添加到workProgressHook单向链表中,返回新的 hook 链表 - 接着初始化
useEffect的第二个参数,也就是依赖项数组,可以看到,当我们不传的时候会被设为 null - 接着将标识 Fiber 节点的 二进制值添加到 Fiber 的
flags属性上 - 最后第哦啊用
pushEffect初始化 effect 链表,并挂到memoizedState上
这个实现也很简单,哈哈哈
function mountEffectImpl(fiberFlags, hookFlags, create, deps): void {
// 创建 hook
const hook = mountWorkInProgressHook();
// 获取依赖
const nextDeps = deps === undefined ? null : deps;
currentlyRenderingFiber.flags |= fiberFlags;
// 创建effect链表,挂载到hook的memoizedState上和fiber的updateQueue
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
create,
undefined,
nextDeps,
);
}接着再看看 pushEffect 是如何初始化创建的 effect 链表的
pushEffect 实现
首先,会根据传入的参数,创建一个 effect 对象,该对象上存储着,useEffect 的两个参数:create 和 deps, 还有标识 hook 对象的 二进制数值 tag,还有一个 next 指针,形成 effect 链表
这里用二进制也是为了在运算中更快一些,不用二进制也行,我自己实现过
接着就是将 effect 添加到 effect 链表中,需要先判断 effect 链表存不存在,存在就和 next 指针相连,形成环状链表
function pushEffect(tag, create, destroy, deps) {
// 新建 effect 对象
const effect: Effect = {
tag, // useEffect 还是 layoutxxx
create, // 回调
destroy,
deps, // 依赖
// Circular
next: (null: any),
};
// 从当前 Fiber 节点的 updateQueue 上获取当前 Fiber 的更新队列
let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);
if (componentUpdateQueue === null) {
// 如果还没有,那就创建一个,将 effect 链表添加到熬队列上
componentUpdateQueue = createFunctionComponentUpdateQueue();
currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
// 如果已经有更新队列,那就把 effect 加到 effect 链表的末尾,形成环状链表
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;
}
}
// 返回 effect 链表
return effect;
}最后返回的是 effect 给 mountEffectImpl,赋值给 memoizedState 属性
这就是在 mount 时,useEffect 创建 effect 的全部工作
总结以下:
- 在
mountEffect中会调用mountEffectImpl去初始化创建effect链表 - 在
mountEffectImpl中会干 4 件事- 创建 hook 对象
- 初始化依赖项
- 设置
flags - 初始化 effect 链表
- 初始化 effect 链表的逻辑在
pushEffect中,会创建 effect 对象,并维护UpdateQueue上的 effect 环状链表 - 在渲染完成后会,会循环这个环状链表,执行每个对象的 destroy 和 create
update 时 --〉updateEffect
在页面更新时,会执行 updateEffect,调用 updateEffectImpl 完成 effect 链表的构建,这个过程会根据前后依赖的是否变化,来创建不同的 effect 对象,
- 首先会根据
hook单向链表获取对应的更新时的 hook 对象,创建新的 hook 对象,加入 hook 的单向链表 - 如果拿到
effect的deps不为 null,或者 undefined,会从当前 hook 对象拿到上一次effect对象,再从effect对象拿到deps和destroy,用新的deps与之比较- 如果新老
deps相等,push 一个不带HookHasEffect的 tag 给 effect 对象,加入updateQueue环状链表(没有副作用),不更新hook.memoizedState - 如果新老
deps不相等,更新effect对象,在effect的 tag 中加入HookHasEffect和上一次create执行的destroy,更新hook.memoizedState
- 如果新老
function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
// 获取 更新时的 hook 对象
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
let destroy = undefined;
if (currentHook !== null) {
// 从 currentHook 获取上一次的 effect
const prevEffect = currentHook.memoizedState;
destroy = prevEffect.destroy;
if (nextDeps !== null) {
const prevDeps = prevEffect.deps;
// 比较前后的 deps 是否相等,push 一个不带 hasEffect 的 effect
if (areHookInputsEqual(nextDeps, prevDeps)) {
hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
return;
}
}
}
// 如果 deps 有变化,那就 push 一个 有 hookHasEffect 的 effect,并挂到 hook.memoizedState 上
currentlyRenderingFiber.flags |= fiberFlags;
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
create,
destroy,
nextDeps,
);
}和 mountEffectImpl 有一些不同,在挂载时调用的 pushEffect 去创建 effect 对象,并没有传递 destroy 方法,而 update 的时候传了, 这是因为 effect 执行之前,都会先执行前一次的销毁函数,再执行新 effect 的创建函数,而 mount 时,并没有上一个 effect ,因此无需先销毁
再来看看这个 areHookInputsEqual 方法
areHookInputsEqual 比较
这个方法是用来比较两个 deps 是否相等的
function areHookInputsEqual(
nextDeps: Array<mixed>,
prevDeps: Array<mixed> | null,
) {
if (prevDeps === null) {
return false;
}
// 循环遍历
for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
// is 比较函数是浅比较
if (is(nextDeps[i], prevDeps[i])) {
continue;
}
return false;
}
return true;
}首先会遍历 deps ,调用 is 方法来比较依赖项数组中的每个依赖
is 方法是一个浅比较的方法
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
);
}
const objectIs: (x: any, y: any) => boolean =
typeof Object.is === 'function' ? Object.is : is;若当前浏览器支持 Object.is() 方法,则调用该方法来判断两个值是否相同,若不支持,则调用 React 自己实现 is 方法来比较。
这块就是 update 时所做的工作了
如何调度
由于 useEffect 回调延迟调用的设计,在实现上利用 Scheduler 的异步调度函数:scheduleCallback,将执行 useEffect 回调的动作作为一个任务去调度,这个任务会异步调用。
与 componentDidMount、componentDidUpdate 不同的是,在浏览器完成布局与绘制之后,传给 useEffect 的函数会延迟调用。 这使得它适用于许多常见的副作用场景,比如设置订阅和事件处理等情况,因此不应在函数中执行阻塞浏览器更新屏幕的操作。
和 useEffect 调度相关的部分在 React 的 commit 阶段,commit 阶段的具体工作可以看这部分
主要分为 beforeMutation 、mutation、layout 三个阶段
function commitRootImpl(root, renderPriorityLevel) {
// 进入 commit 阶段,先执行一次之前未执行的 useEffect
do {
flushPassiveEffects();
} while (rootWithPendingPassiveEffects !== null);
...
do {
try {
// beforeMutation阶段 异步调度useEffect
commitBeforeMutationEffects();
} catch (error) {
...
}
} while (nextEffect !== null);
...
const rootDidHavePassiveEffects = rootDoesHavePassiveEffects;
if (rootDoesHavePassiveEffects) {
// 记录有副作用的effect
rootWithPendingPassiveEffects = root;
}
}其中和 useEffect 有关的在 commit 阶段开始、beforeMutation 、layout 阶段 具体如下:
- 在
commit阶段开始时,会先将之前还没有处理完的useEffect全部处理完成,这里采用的是do while循环。在这里这么处理的作用是因为 由于useEffect是被以一个低优先级的任务进行调度的,因此在过程中有可能会被其他高优先级的任务打断,高优先级的任务会先进入到commit阶段, 而低优先级的useEffect还没有被执行,所以需要先将之前的effect全部处理掉,保证本次调度的产生的更新是由当前的useEffect产生的
// 进入 commit 阶段,先执行一次之前未执行的 useEffect
do {
flushPassiveEffects();
} while (rootWithPendingPassiveEffects !== null);useEffect在beforeMutation阶段会被交给scheduleCallback,发起一个NormalPriority低优先级的调度,这一点上面也提到了
由于 rootDoesHavePassiveEffects 的限制,只会发起一次 useEffect 调度
提示
引用之前的写的
- before mutation 阶段在 scheduleCallback 中调度 flushPassiveEffects
- layout 阶段之后将 effectList 赋值给 rootWithPendingPassiveEffects
- scheduleCallback 触发 flushPassiveEffects,flushPassiveEffects内部遍历rootWithPendingPassiveEffects
// commitImpl
if (
(finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
(finishedWork.flags & PassiveMask) !== NoFlags
) {
if (!rootDoesHavePassiveEffects) {
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
return null;
});
}
}- 在 layout 阶段 DOM 更新完成之后,会执行
flushPassiveEffectsImpl,会先执行上一次 effect 的销毁(destroy),再执行本次 effect 的创建(create)
可以看到在 flushPassiveEffectsImpl 中调用的 UnmountEffects 和 MountEffects 中调用的 commitHookEffectListMount 和 commitHookEffectListUnMount 这两个方法
commitHookEffectListUnmount 执行 effect 的 destroy
function commitHookEffectListUnmount(
flags: HookFlags,
finishedWork: Fiber,
nearestMountedAncestor: Fiber | null,
) {
const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
if ((effect.tag & flags) === flags) {
// Unmount
const destroy = effect.destroy;
effect.destroy = undefined;
if (destroy !== undefined) {
safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy);
}
}
effect = effect.next;
} while (effect !== firstEffect);
}
}commitHookEffectListMount 先执行 create
function commitHookEffectListMount(flags: HookFlags, finishedWork: Fiber) {
const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
if ((effect.tag & flags) === flags) {
// Mount
const create = effect.create;
effect.destroy = create();
}
effect = effect.next;
} while (effect !== firstEffect);
}
}可以注意到这里采用的是 flags 来遍历 updateQueue,只有 flags 全等才会执行,而这个 flags 就是决定当前 Fiber 是否因为依赖变化而需要执行回调
这也是为什么在依赖不变的时候仍然需要 pushEffect 进入 updateQueue 中,因为在组件销毁的时候需要执行全部 effect 的 destroy 函数,即使依赖不变,也需要 pushEffect
总结
useEffect 的大体流程如下: