作者:来对鸡翅谢谢
文章作者授权本账号发布,未经允许请勿转载
在上篇文章中,我们初步探索了 Hooks 系统的实现,对其底层的运行机制有了一定的了解,并遗留了一个TODO,useEffect 的刷新时机。本篇文章主要介绍 React 处理组件的流程,并详细介绍在 commit 阶段中,React每一步的处理逻辑,中间也会具体描述 useEffect 的刷新时机。
React16
引入 Fiber
架构后,将整个调度分为了两个阶段 render & commit
,在 render
阶段,React
会计算 DOM
的更新,并将所有需要更新的 fiber
整理成一个 effect list
,在 commit
阶段中, React
会遍历 effect list
执行所有的副作用,期间会执行更新相关的生命周期、挂载 DOM 等等。
commit 前置工作
以如下代码为例
const Child = () => {
const [childCount, setChildCount] = useState(0);
useLayoutEffect(() => {
setChildCount(1);
}, [])
useEffect(() => {
setChildCount(2);
setChildCount(3);
}, []);
console.count('childRender');
return (
<span>{childCount}</span>
)
}
const App = () => {
const [count, setCount] = useState(0);
useLayoutEffect(() => {
setCount(1);
}, [])
useEffect(() => {
setCount(2);
setCount(3);
}, []);
console.count('render');
return (
<>
<span>{ count }</span>
<Child />
</>
);
}
ReactDOM.render(<App />, document.getElementById('root'))
在进入 commit
阶段前,我们得到的 effect list
如下。
首先 React
会创建一个 FiberRoot
作为整个组件树的顶层节点 , finishedWork
属性指向的是完成的第一个 work
,其实就是传递给 ReactDOM.render
的组件,在示例代码中就是 App
组件,每一个 fiber
都具有几个和 effect
相关的属性,指向具有 side effects
的 fiber
:
firstEffect
:指向effect list
的第一个fiber
nextEffect
:effect list
的指针,指向下一个effect fiber
当进入 commit
阶段时, React
首先会根据根节点拿到 finisedWork
,需要处理的 effect
链表已经在 render
阶段挂载到 firstEffect
属性上,那么遍历的代码就能想象得到。
const effect = fiberRoot.finishedWork.firstEffect;
while (effect) {
// ...
effect = effect.nextEffect
}
和上方思路类似, React
将整个 commit
阶段拆分成了多个小阶段,在每一个小阶段上, React
会执行上述的遍历流程 ,按照官方注释来说,这样可以保证所有的突变 effect
(即 useEffect)可以在 布局 effect
(即 useLayoutEffect) 之前被处理。 commit
流程的主要代码在 ReactFiberWorkLoop.js
文件中
function commitRootImpl() {
// 刷新所有的 PassiveEffect
do {
flushPassiveEffects();
} while (rootWithPendingPassiveEffects !== null);
// Get the list of effects.
// effectList 的第一个节点
let firstEffect;
// ...
// 省略 if 判断,如果 root 有副作用的话,其副作用将会放置在 effectList 的末尾,root 无副作用的话,那么 firstEffect 就是根组件的 firstEffect
firstEffect = finishedWork.firstEffect;
if (firseEffect !== null) {
nextEffect = firstEffect;
// 每一阶段的详细代码后续会进行说明
// 第一阶段,before mutation
do {
commitBeforeMutationEffects();
} while(nextEffect !== null)
// ...
// 将游标重置,指向 effect list 头
nextEffect = firstEffect;
// 第二阶段 mutation
do {
commitMutationEffects(root, renderPriorityLevel);
} while(nextEffect !== null)
// 将当前的 workInProgress树 作为 current 树
root.current = finishedWork;
// ...
// 第三阶段 layout
do {
commitLayoutEffects(root, expirationTime);
} while(nextEffect)
// 让调度器在 帧 的末尾暂停,给浏览器机会执行一次 重绘
requestPaint();
// rootDoesHavePassiveEffects 标志位判断,该标志位是在 commit 第一阶段进行设置,标记当前 commit 是否具有 passiveEffect
if (rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = false;
rootWithPendingPassiveEffects = root;
pendingPassiveEffectsExpirationTime = expirationTime;
pendingPassiveEffectsRenderPriority = renderPriorityLevel;
} else {
// 遍历 effect list 逐个设置为 null 以便 GC
}
// 确保 root 上所有的 work 都被调度完
ensureRootIsScheduled(root);
// 检测在 useLayoutEffect 中是否做了布局修改等,刷新布局,如果在 layoutEffect 中调用了 setState 也会在该函数中检测中并开启新的一轮调度
// 原版注释: If layout work was scheduled, flush it now.
flushSyncCallbackQueue();
} else { ... }
}
从上方代码我们可以了解到,一个完整的 commit
流程被分为了三个子阶段,在三个子阶段中还会穿插一些额外的检测,下面我们依次分析这几个阶段和一些比较重要的标志位。
第一阶段 before Mutation
function commitBeforeMutationEffects() {
while (nextEffect !== null) {
const effectTag = nextEffect.effectTag;
// 对于使用 getSnapShowBeforeUpdate 的组件 fiber.effectTag |= SnapShot
if ((effectTag & Snapshot) !== NoEffect) {
// ...
const current = nextEffect.alternate;
// 执行 getSnapShotBeforeUpdate 生命周期
commitBeforeMutationEffectOnFiber(current, nextEffect);
// ...
}
// 对于使用 useEffect 的组件,其 Fiber.effectTag = UpdateEffect | PassiveEffect
if ((effectTag & Passive) !== NoEffect) {
// If there are passive effects, schedule a callback to flush at
// the earliest opportunity.
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
// 安排一个回调
scheduleCallback(NormalPriority, () => {
flushPassiveEffects();
return null;
});
}
}
nextEffect = nextEffect.nextEffect;
}
}
上述代码代码分为了两个部分,通过遍历 effect list
,判断每一个 effect
的副作用类型。
Class
组件且使用了getSnapShotBeforeUpdate
- 进入第一个判断,在内部会执行
getSnaoShotBeforeUpdate
生命周期,也只有Class
组件会进入这个判断
- 进入第一个判断,在内部会执行
- 函数式组件且使用了
useEffect
- 标记
rootDoesHavePassiveEffects
标志位,在commit
阶段的末尾会判断当前commit
是否具有被动的副作用,有的话会设置一些额外的标志位用于下一轮的调度。 - 安排一个回调,回调内部会触发
flushPassiveEffects
。
- 标记
首先这边可以看到 React
会安排一个回调,这个回调会被异步触发,这边所说的异步并不是指 setState
那样的批处理,而是 eventLoop
中的同步任务、异步任务。我们简单研究一下这个创建回调的函数。
// Scheduler.js
function unstable_scheduleCallback(priorityLevel, callback, options) {
// ...
var newTask = {
id: taskIdCounter++,
callback,
priorityLevel,
startTime,
expirationTime,
sortIndex: -1,
};
// ...
if (startTime > currentTime) {
//... 超时调用
} else {
// 正常调用
push(taskQueue, newTask);
if (!isHostCallbackScheduled && !isPerformingWork) {
isHostCallbackScheduled = true;
// 发送一个 postMessage
requestHostCallback(flushWork);
}
}
}
// ScheduleHostConfig.default.js
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
requestHostCallback = function(callback) {
scheduledHostCallback = callback;
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
port.postMessage(null);
}
};
在上述代码中, React
会创建一个 task
,这个对象上保存着我们传入的回调,并且 task
会被压入到一个任务队列 taskQueue
中,在还有剩余时间时, React
会调用 postMessage
将 performWorkUntilDeadline
函数压入异步任务队列中,等待所有的同步任务都执行完之后执行该函数。
在这里 React
借助浏览器原生的 MessageChannel
来实现异步刷新, MessageChannel
实例化会得到两个端口,一种一个端口用来绑定 onmessage
事件,另一个端口则通过调用 postMessage
异步触发 onmessage
。
上述代码中还有一个比较关键的函数, flushPassiveEffects
,从命名上看,这是用来刷新被动副作用的,也就是刷新 useEffect
,这个方法在很多的地方被调用,比如调度入口、 commit
阶段的入口、 postMessage
异步回调中等。该函数的解析我们放在下面叙述。
从第一阶段来看,我们了解到 useEffect
的其中一个刷新时机,就是异步任务队列中,并且是宏任务队列。
第二阶段 Mutation
function commitMutationEffects(root: FiberRoot, renderPriorityLevel) {
while (nextEffct) {
// ...
const effectTag = nextEffect.effectTag;
let primaryEffectTag = effectTag & (Placement | Update | Deletion | Hydrating);
// ...
switch(primaryEffectTag) {
// 单纯的挂载 DOM
case Placement: {
// 挂载 DOM
commitPlacement(nextEffect);
nextEffect.effectTag &= ~Placement;
return;
}
// 更新组件及DOM
case PlacementAndUpdate: {
// 挂载 DOM
commitPlacement(nextEffect);
nextEffect.effectTag &= ~Placement;
// 刷新 layoutEffect.desotry
const current = nextEffect.alternate;
commitWork(current, nextEffect);
break;
}
// 更新组件
case Update: {
const current = nextEffect.alternate;
commitWork(current, nextEffect);
break;
}
// 卸载
case Deletion: {
commitDeletion(root, nextEffect, renderPriorityLevel);
break;
}
// ...
}
nextEffect = nextEffect.nextEffect;
}
}
上述为第二阶段的大致代码, React
会根据当前组件的 effectTag
与上可能的标志位,得到最终的标志位,这一点和 Hooks
中判断某一个 effect
是否执行是一样的。 React
将产生副作用的原因做了分类,在第二阶段主要用到了如下分类:
Placement
:放置 DOM, 适用于只有 DOM 变化的场景
// 单纯的渲染DOM,没有任何组件更新
const App = () => {
return <span>Placement Effect</span>
}
-
PlacementAndUpdate
:放置 DOM,并更新组件// 当 App 挂载时,或者 count 状态更新时,不仅仅是组件更新,DOM也会发生变化 const App = () => { const [count, setCount] = useState(0); return <span>{ count } PlacementAndUpdate</span> }
-
Update
:组件更新,但是 DOM 无变化// 当 count 变化时,仅仅是 App 组件发生了更新,但是 DOM 没有变化 const App = () => { const [count, setCount] = useState(0); return <span>Update</span> }
-
Deletion
:组件卸载
以上不同的标志位,内部逻辑都比较相似,主要函数为 commitPlacement
和 commitWork
// 挂载 DOM
function commitPlacement(finishedWork: Fiber): void {
// ...
// 当前 Fiber 对应的 stateNode 的父节点
const parentFiber = getHostParentFiber(finishedWork);
let parent;
let isContainer;
// ...省略 对 parentFiber.tag 的Switch 判断,根据不同类型的 fiber 对 parent isContainer 赋值
const before = getHostSibling(finishedWork);
if (isContainer) {
insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent);
} else {
insertOrAppendPlacementNode(finishedWork, before, parent);
}
}
commitPlacement
函数做的事就是将变化的DOM都挂载到页面上,主要操作分为 insertBefore
和 appendChild
,并且 fiber.stateNode
都保留了之前已经挂载好的DOM的引用。因此对于在同一个DOM下交互子节点的顺序,并不是直接卸载之前的DOM,而是直接使用 insertBefore
。
Node.insertBefore()
方法在参考节点之前插入一个拥有指定父节点的子节点。如果给定的子节点是对文档中现有节点的引用,insertBefore()
会将其从当前位置移动到新位置(在将节点附加到其他节点之前,不需要从其父节点删除该节点)。
而另一个主要函数 commitWork
的调用顺序永远在 commitPlacement
之后,其语义是等待布局更新后,需要开启新的一轮 layoutEffect
,但是在此之前需要卸载上一轮的 layoutEffect
,而 commitWork
的作用就是清除上一轮 layoutEffect
的副作用,清除操作也就是调用 useLayoutEffect
第一个参数的返回值, React
使用 destory
来命名。对于函数式组件而言,内部主要函数为 commitHookEffectListUnmount
// tag = HookLayout | HookHasEffect
function commitHookEffectListUnmount(tag: number, finishedWork: Fiber) {
const updateQueue = finishedWork.updateQueue;
// 获取该 Fiber 下的 effect list
let lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
if ((effect.tag & tag) === tag) {
// Unmount
const destroy = effect.destroy;
effect.destroy = undefined;
if (destroy !== undefined) {
destroy();
}
}
effect = effect.next;
} while (effect !== firstEffect);
}
}
在上篇文章中,我们提到组件内调用的 Hooks API
会生成 hook
对象,并且对于具有副作用的 hook
还会额外生成 effect
对象,组件内所有的 effect
相连形成一个 effect
链表,挂载到 fiber.updateQueue.lastEffect
中。
那么这里的逻辑就是从 updateQueue.lastEffect
中取出副作用链表依次遍历调用 effect.desotry
方法。
第三阶段 Layout
在第二阶段中,需要更新的DOM都已挂载到页面上,从生命周期上讲,这个时候需要调用组件的 componentDidMount
生命周期,调用过程就是第三阶段做的事。
function commitLayoutEffects(root, committedExpirationTime) {
while (nextEffect !== null) {
// ...
const effectTag = nextEffect.effectTag;
if (effectTag & (Update | Callback)) {
recordEffect();
const current = nextEffect.alternate;
// didmount
commitLayoutEffectOnFiber(
root,
current,
nextEffect,
committedExpirationTime,
);
}
// ...
}
}
function commitLayoutEffectOnFiber(finishedRoot, current, finishedWork, committedExpirationTime) {
switch(finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent:
case Block: {
// 刷新 layoutEffect
commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);
// ...
return;
}
case ClassComponent: {
// didMount
}
}
}
从上述代码我们可以了解到,对于函数式组件而言,主要调用 commitHookEffectListMount
,和上方 commitHookEffectListUnmount
逻辑类似,都是从 updateQueue.lastEffect
中取出副作用链表,区别在于本次是调用 effect.create()
, 并且将返回值赋值给 effect.desotry
,等待下一轮调度前刷新。
commit 末尾
在上述三个子阶段都完成之后, React
已经将所有的DOM都挂载到屏幕上,并且也执行了 didMount
,但是在上述三个子阶段的执行过程中,可能会发生新的副作用,比如说在 layoutEffect
中调用了 setState
,因此在 commit
的末尾会做一次检测,入口函数就是 flushSyncCallbackQueue
。
function commitRootImpl(root, renderPriorityLevel) {
// ...
// first stage: before Mutation
// second stage: Mutation
// 将 workInProgress树变为 current 树
root.current = finisedWork;
// third stage: Layout
// 在第一阶段中被赋值,赋值依据有需要更新的 useEffect
if (rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = false;
rootWithPendingPassiveEffects = root;
pendingPassiveEffectsExpirationTime = expirationTime;
pendingPassiveEffectsRenderPriority = renderPriorityLevel;
} else {
// while 清空 effect list 便于垃圾回收
}
// ...
flushSyncCallbackQueue();
return null;
}
这边需要提到的一点就是 workInProgress Tree
在第二阶段执行完之后,会被当成 current
树。接下来我们来研究一下 flushSyncCallbackQueue
函数。
function flushSyncCallbackQueue() {
// 在 setState 时被赋值,取消当前 node 的回调,因为会在 flushSyncCallbackQueueImpl 被刷新
if (immediateQueueCallbackNode !== null) {
const node = immediateQueueCallbackNode;
immediateQueueCallbackNode = null;
Scheduler_cancelCallback(node);
}
flushSyncCallbackQueueImpl();
}
function flushSyncCallbackQueueImpl() {
if (!isFlushingSyncQueue && syncQueue !== null) {
isFlushingSyncQueue = true;
let i = 0;
try {
const isSync = true;
const queue = syncQueue;
// 循环 syncQueue 执行同步队列中的每一个回调
runWithPriority(ImmediatePriority, () => {
for (; i < queue.length; i++) {
let callback = queue[i];
do {
callback = callback(isSync);
} while (callback !== null);
}
});
syncQueue = null;
} catch (error) {
// ...
} finally {
// ...
}
}
}
上述代码中出现了一个新的队列 syncQueue
,回想一下,大家都知道 setState
在大多数情况下是批处理的,每次 setState
并不是立即 render
,而是做一个回调,在调度的末尾执行回调,等回调执行时会带上新的状态值并开启新的一轮调度。其实这边的回调就会压入 syncQueue
中,而压入的回调函数则是调度入口函数 performSyncWorkOnRoot
。
function performSyncWorkOnRoot(root) {
// ...
flushPassiveEffects();
// ...
// 开启新的一轮调度
}
在调度入口函数开始,React
首先会执行一次 flushPassiveEffects
,这边可以看到,刷新被动副作用的函数在调度入口也会执行,这个函数已经在整个流程中多次出现了,我们来研究下这个函数。
function flushPassiveEffects() {
// commit 阶段末尾被赋值,依据是 rootDoesHavePassiveEffects
if (pendingPassiveEffectsRenderPriority !== NoPriority) {
// ...
return runWithPriority(priorityLevel, flushPassiveEffectsImpl);
}
}
// 代码有删减
function flushPassiveEffectsImpl() {
// ...
while (effect !== null) {
// ...
commitPassiveHookEffects(effect);
const nextNextEffect = effect.nextEffect;
// Remove nextEffect pointer to assist GC
effect.nextEffect = null;
effect = nextNextEffect;
}
}
从上述代码中,我们可以了解到, flushPassiveEffects
函数调用入口就会做一个判断,而标志位 pendingPassiveEffectsRenderPriority
在 commit
阶段的末尾根据当前 effect list
中是否具有副作用的 fiber
来判断, 实质的刷新函数是 flushPassiveEffectsImpl
内部会提交 commitPassiveHookEffects
和 commitLayoutEffect
类似,都是从 updateQueue.lastEffect
中拿到副作用列表,依次遍历先执行所有的 destory
,之后在进行一次遍历执行所有的 create
并根据返回值设置新的 destory
。
而在这里,我们可以发现,上一轮的 useEffect
会在下一轮 render
前刷新,而不是等到浏览器执行异步队列时刷新。这也正是 React
对 useEffect
执行时机的描述:
Does
useEffect
run after every render? Yes! By default, it runs both after the first render and after every update. (We will later talk about how to customize this.) Instead of thinking in terms of “mounting” and “updating”, you might find it easier to think that effects happen “after render”. React guarantees the DOM has been updated by the time it runs the effects.
总结
默认情况下, useEffect
会在浏览器宏任务队列中刷新,但是如果 commit
阶段中产生了额外的副作用,那么将会在 commit
末尾进行检测并开启新的一轮调度,在调度的开头会被刷新。
我们以一段代码为例,主要通过查看组件 render
的次数,在实际编码过程中,我们应当尽量减少组件 render
的次数,并且知道组件会在什么时候被 render
。
const App = () => {
const [count, setCount] = useState(0);
useLayoutEffect(() => {
setCount(1);
}, []);
useEffect(() => {
setCount(2);
setCount(3);
}, []);
consle.count('render');
return (<span>{ count }</span>);
}
上述组件一种会打印两次 render
,
- 第一次是在初始挂载时,
App
组件被调用,第一次打印,并且创建了一个hook
链表和一个effect
链表 - 当第一次进入
commit
阶段时,commit
的开始会执行一次flushPassiveEffect
但是因为当前没有设置标志位pendingPassiveEffectsRenderPriority
,所以很快的退出了该函数的执行 - 接下来进入
commit
的第一阶段,React
检测到App
组件使用了useEffect
,标记标志位rootDoesHavePassiveEffects
,并将刷新副作用的函数推入宏任务队列, - 进入第二阶段,
React
会挂载DOM,并刷新上一轮的layoutEffect.destory
- 进入第三阶段,
React
刷新当前的layEffect.create
,上述代码中,因为在useLayoutEffect
中调用了setState
,产生了额外的副作用,React
将调度函数当成回调压入syncQueue
,并且setState
的值被缓存下来 commit
阶段末尾,React
依据标志位rootDoesHavePassiveEffects
设置标志位pendingPassiveEffectsRenderPriority
,并执行flushSyncQueue
,刷新syncQueue
队列,依次执行回调,这里的回调就是调度入口函数- 执行
performWorkOnRoot
,开始新的一轮调度,调度入口,执行flushPassiveEffect
,此时因为标志位pendingPassiveEffectsRenderPriority
上一步已经设置过,开始刷新所有的passiveEffect
,上述代码中,useEffect
中调用了两次setState
,新的state
值会立即计算,得到一个新的状态值挂在fiber.updateQueue
上挂载两个update
对象。 - 当第二次调用
App
组件时,React
遍历updateQueue
,计算出最新的状态值,第二次打印 - 第二次进入
commit
阶段,React
执行三个小阶段,在三个阶段中因为hooks.deps
都没有发生变化,所以不执行任何的effect
,commit
的末尾也会检测到新的副作用,调度结束。 - 同步代码执行完成后,执行宏任务队列,执行
performWorkUntilDeadline
回调,内部会刷新flushPassiveEffects
,因为标志位pendingPassiveEffectsRenderPriority
未设置,所以退出。
至此, App
组件调度完成,一共会打印两次。
总结
-
一个完整的
commit
会被拆分为三个子阶段来完成,在commit
末尾会刷新commit
阶段产生的同步回调及setState
-
第一阶段,对于
Class
组件而言,是执行getSnapShotBeforeUpdate
生命周期,对于函数式组件则是安排异步回调 -
第二阶段
React
会挂载或更新 DOM,并清理上一轮的useLayoutEffect
-
第三阶段,对于
Class
组件而言是执行componentDidMount
,对于函数式组件则是执行useLayoutEffect
-
useEffect
的执行时机大多数情况下会在浏览器异步任务队列中被刷新,但如果在commit
阶段调用了setState
,则会在下一轮render
开始前被刷新,在Class
与Hooks
混用的场景尤其要注意,已下方的bad case
为例const Child = ({ getFormIns }) => { // 生成一个 form 实例 const form = Form.useForm(); // 试图向外层传递 form 实例 useEffect(() => { if(getFormIns) { getFormIns(form); } }, []); return <span></span> } class App extends React.Component { constructor(props) { super(props); this.formIns = null; } componentDidMount() { // 拿到 form 实例 做一些操作 formIns.updateModel({}); } return <Child getFormIns={(form) => this.formIns = form} /> }
以上代码中试图在父组件中调用 Form
实例相关的方法, 但是实际上 useEffect
的执行时机并不是真正的 didMount
,导致父组件在 DidMount
的时候出错,将 useEffect
换成 useLayoutEffect
就可以解决上述问题,但不管是使用 useLayoutEffect
还是使用 useEffect
都不符合 Hooks
的语义,在示例代码中场景是父组件期望可以调用子组件的变量或者方法,这种场景更适合使用 useImperativeHandle
来代替。