我们已经像这样提起 useEffect 与 useLayoutEffect 多少次了?
How many times have we fought?
记不清了。自从 React Hook 出现之后,这就是我们仅有的回忆。为了终止这纷争的纠缠,除了投身于其中直面那些浩瀚的代码,别无他法。
If you want it, then you have to LEARN it. But you already know that.
让我们结束这一切吧。
Time to finish this! Once and for all!
useLayoutEffect 执行时机
useLayoutEffect 与 useEffect 的差异在所有介绍 react hook 用法的文章里头都已经说的很明白了:
- useLayoutEffect 会在渲染前同步执行,阻塞渲染过程;
- useEffect 会在渲染后异步执行,不阻塞渲染过程。
大致如此,但是这一说法并不完全准确。
要搞清真正的原理,最为准确的方式永远都是直接阅读代码,探查具体的实现逻辑。首先让我们来确定 useLayoutEffect 的时机。
位于 ReactFiberWookLoop.new.js 文件中的 commitRoot 函数(实际上核心的函数功能在 commitRootImpl 中实现)描述了 React 的 commit 阶段(也就是所谓的提交阶段)的执行流程。
commit 阶段将会在 React 执行完 fiber 树的创建/更新之后,真正操作 dom 的更新,并且执行副作用的链表。(关于这一部分的逻辑,推荐阅读详细的代码解析 7km.top/main/fibert…)
拆解开 commitRoot 中各种额外的处理步骤以及分叉内容,commitRoot 中关于执行流程的核心内容可以精简为:
function commitRootImpl(){
// step 1:
commitBeforeMutationEffects(root, finishedWork);
// step 2:
commitMutationEffects(root, finishedWork, lanes);
// step 3:
commitLayoutEffects(finishedWork, root, lanes);
// step 4:
requestPaint();
}
其中的 4 个步骤分别为:
- Step 1:在 DOM 执行变更之前,进行一些 Fiber 处理的准备,例如获取组件更新前的 snapshot;
- Step 2:根据 Fiber 上的标记(Fiber 在协调阶段会打上变更相应的标签,包括 Deletion、Placement 以及 Update 等),执行实际的 DOM 变更操作;
- Step 3:同步 执行 layout 类型的副作用,例如类组件将会根据当前是否已挂载执行 componentDidMount 或者 componentDidUpdate,函数组件将会执行 useLayoutEffect;
- Step 4:标记需要渲染。这样一来当调度器执行完当前帧之后会把执行时间让出来,随后浏览器能够有时间完成渲染过程。
可以看到,在 commit 阶段中 useLayoutEffect 会在 Dom 执行变更字后 & 浏览器真正执行绘制之前同步执行。
useEffect 执行时机
useLayoutEffect 的执行时机清晰明白。那么 useEffect 究竟是同步执行还是异步执行的呢?
让我们扩展一下刚才的 commitRoot 函数内容,填入 useEffect 执行相关的步骤:
function commitRootImpl(){
// step 0:
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
// This render triggered passive effects: release the root cache pool
// *after* passive effects fire to avoid freeing a cache pool that may
// be referenced by a node in the tree (HostRoot, Cache boundary etc)
return null;
});
// step 1:
commitBeforeMutationEffects(root, finishedWork);
// step 2:
commitMutationEffects(root, finishedWork, lanes);
// step 3:
commitLayoutEffects(finishedWork, root, lanes);
// step 4:
requestPaint();
// step 5:
// If the passive effects are the result of a discrete render, flush them
// synchronously at the end of the current task so that the result is
// immediately observable. Otherwise, we assume that they are not
// order-dependent and do not need to be observed by external systems, so we
// can wait until after paint.
if (
includesSomeLane(pendingPassiveEffectsLanes, SyncLane) &&
root.tag !== LegacyRoot
) {
flushPassiveEffects();
}
}
额外增加的两个步骤分别为:
- Step 0:将 flushPassiveEffects 的操作通过 workLoop 进行执行时机的调度。flushPassiveEffects 将会执行带有 Passive 标记的 effect。由于 React 中时间调度的逻辑,如果当前帧的执行还有剩余时间的话,React 将会同步继续执行 flushPassiveEffects 的内容,否则会通过异步 api (setImmediate / MessageChannel / setTimeout)安排在下一个帧里执行;
- Step 5:检查这些带有 Passive 标记的 effect 是否来源于一个离散的事件(例如鼠标点击等),如果是,则同步执行这些 effect。
Step 5 实际上是 React 18 中新增的一个逻辑,为了保证离散事件执行结果的正确性做了这一处理:
In React 18, useEffect fires synchronously when it's the result of a discrete input.
For example, if useEffect attaches an event listener, the listener is guaranteed to be added before the next input.
The same behavior applies to flushSync: the results of the effect are applied immediately, before the flushSync call exits.
The behavior for non-discrete events is unchanged: in most cases, React will defer the effect until after paint (though not always, if there's remaining time in the frame).
为什么 React 18 要额外对离散事件做处理呢?以 React 给的例子来讲,用户设定了这样一段逻辑,在提交表单之后,通过 useEffect 禁用表单的后续提交操作:
useEffect(() => {
if (!disableSubmit) {
const form = formRef.current;
form.addEventListener('submit', onSubmit);
return () => {
form.removeEventListener('submit', onSubmit);
};
}
}, [disableSubmit, onSubmit]);
如果按 React 17 的异步逻辑执行,那么用户尝试多次点击提交表单时,第一次提交并不会立即执行 useEffect,导致表单的提交功能没有第一时间被禁用,因此用户可能会触发多次表单提交操作。
而在 React 18 中,提交表单操作触发的 useEffect 会被同步执行,从而避免了多次的提交。
也就是 React 18 为了保证离散事件之间可能存在的时序关系,强制 passive effect 同步执行了。
总结一下,useEffect 的执行时机 可能是异步的或者同步 的,并且在 React 17 与 React 18 中表现并不一致:
- 如果更新并非由离散事件触发,那么 useEffect 是否同步由当前帧的剩余执行时间决定,如果还有剩余的执行时间,则会继续同步执行,否则会通过异步调度在下一个帧内执行;
- 如果更新由离散事件触发,在 React 17 中,useEffect 的表现仍与非离散事件一致,而 React 18 则会强制 useEffect 同步执行。
写在最后
问题的纠缠也许还会无止境的持续下去,但是在探索的过程中我们做出了自己的努力。让我们迎接下一个问题。
“她不值得你这样,他们谁也不值得”罗林斯说。
约翰·格雷迪半晌没有回答,后来他说:“这事值得。”