如前一篇文章所说,commitRoot
方法是commit阶段工作的起点。fiberRootNode会作为传参。
在rootFiber.firstEffect
上保存了一条需要执行副作用的Fiber节点的单向链表effectList
,这些Fiber节点的updateQueue
中保存了变化的props。
这些副作用对应的DOM操作
在commit阶段执行。
除此之外,一些生命周期钩子(比如componentDidXXX)、hooks(比如useEffect)需要在commit阶段执行。
commit阶段的主要工作(即Renderer的工作流程)分为三部分:
-
before mutation
阶段(执行DOM操作前) -
mutation
阶段(执行DOM操作) -
layout
阶段(执行DOM操作后)
1. beforeMutation
before mutation
阶段的代码很短,整个过程就是遍历effectList
并调用commitBeforeMutationEffects
函数。
commitBeforeMutationEffects
大体代码逻辑:
function commitBeforeMutationEffects() {
while (nextEffect !== null) {
const current = nextEffect.alternate;
if (!shouldFireAfterActiveInstanceBlur && focusedInstanceHandle !== null) {
// ...focus blur相关
}
const effectTag = nextEffect.effectTag;
// 调用getSnapshotBeforeUpdate
if ((effectTag & Snapshot) !== NoEffect) {
commitBeforeMutationEffectOnFiber(current, nextEffect);
}
// 调度useEffect
if ((effectTag & Passive) !== NoEffect) {
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
return null;
});
}
}
nextEffect = nextEffect.nextEffect;
}
}
整体可以分为三部分:
-
处理DOM节点渲染/删除后的 autoFocus、blur 逻辑。
-
调用
getSnapshotBeforeUpdate
生命周期钩子。 -
调度useEffect。
这里重点看第二第三点
1.1 调用getSnapshotBeforeUpdate
commitBeforeMutationEffectOnFiber
方法内会调用getSnapshotBeforeUpdate
。
从Reactv16开始,componentWillXXX
钩子前增加了UNSAFE_前缀。
究其原因,是因为Stack Reconciler重构为Fiber Reconciler后,render阶段的任务可能中断/重新开始,对应的组件在render阶段的生命周期钩子(即componentWillXXX
)可能触发多次。
为此,React提供了替代的生命周期钩子getSnapshotBeforeUpdate
。
我们可以看见,getSnapshotBeforeUpdate
是在commit阶段内的before mutation
阶段调用的,由于commit阶段是同步的,所以不会遇到多次调用的问题。
1.2 调度useEffect
在这几行代码内,scheduleCallback
方法由Scheduler模块提供,用于以某个优先级异步调度一个回调函数,也就是触发useEffect的方法flushPassiveEffects
。
// 调度useEffect
if ((effectTag & Passive) !== NoEffect) {
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
scheduleCallback(NormalSchedulerPriority, () => {
// 触发useEffect
flushPassiveEffects();
return null;
});
}
}
那么useEffect如何被异步调度,又为什么要异步(而不是同步)调度?
1.2.1 flushPassiveEffects怎么工作
首先会获取effectList
,在completeWork一节我们讲到,effectList中保存了需要执行副作用的Fiber节点。其中副作用包括:
- 插入(Placement)
- 更新(Update)
- 删除(Deletion)
除此外,当一个FunctionComponent
含有useEffect
或useLayoutEffect
,他对应的Fiber节点也会被赋值effectTag
。
原博客中这块有点复杂,直接看总结:
整个useEffect异步调用分为三步:
before mutation
阶段在scheduleCallback
中调度flushPassiveEffects
layout
阶段之后将effectList
赋值给rootWithPendingPassiveEffects
scheduleCallback
触发flushPassiveEffects
,该函数内部遍历rootWithPendingPassiveEffects
。
1.2.2 为什么要异步调用useEffect
与 componentDidMount
、componentDidUpdate
不同的是,在浏览器完成布局与绘制之后,传给useEffect
的函数会延迟调用。这使得它适用于许多常见的副作用场景,比如设置订阅和事件处理等情况,因此不应在函数中执行阻塞浏览器更新屏幕的操作。
可见,useEffect
异步执行的原因主要是防止同步执行时阻塞浏览器渲染。
2. Mutation
类似before mutation
阶段,mutation
阶段也是遍历effectList
,执行函数。这里执行的是commitMutationEffects
。
commitMutationEffects
会遍历effectList
,对每个Fiber节点执行如下三个操作:
- 根据
ContentReset effectTag
重置文字节点 - 更新
ref
- 根据
effectTag
分别处理,其中effectTag
包括(Placement | Update | Deletion | Hydrating)
这里重点讨论下第三点(不讨论Hydrating
,因为涉及到服务端渲染)。
2.1 Placement
该effectTag
意味着该Fiber节点对应的DOM节点需要插入到页面中。
调用的方法为commitPlacement
。
- 获取父级DOM节点。其中
finishedWork
为传入的Fiber节点。
const parentFiber = getHostParentFiber(finishedWork);
// 父级DOM节点
const parentStateNode = parentFiber.stateNode;
- 获取Fiber节点的DOM兄弟节点
const before = getHostSibling(finishedWork);
- 根据DOM兄弟节点是否存在决定调用
parentNode.insertBefore
或parentNode.appendChild
执行DOM插入操作。
// parentStateNode是否是rootFiber
if (isContainer) {
insertOrAppendPlacementNodeIntoContainer(finishedWork, before, parent);
} else {
insertOrAppendPlacementNode(finishedWork, before, parent);
}
值得注意的是,getHostSibling
(获取兄弟DOM节点)的执行很耗时,当在同一个父Fiber节点下依次执行多个插入操作,getHostSibling
算法的复杂度为指数级。
这是由于Fiber节点不只包括HostComponent
,所以Fiber树和渲染的DOM树节点并不是一一对应的。要从Fiber节点找到DOM节点很可能跨层级遍历。
2.2 Update
当Fiber节点含有Update effectTag
,意味着该Fiber节点需要更新。调用的方法为commitWork
,他会根据Fiber.tag
分别处理。
这里我们主要关注FunctionComponent
和HostComponent
。
2.2.1 FunctionComponent
当fiber.tag
为FunctionComponent
,会调用commitHookEffectListUnmount
。该方法会遍历effectList
,执行所有useLayoutEffect
的销毁函数。
2.2.2 HostComponent
当fiber.tag
为HostComponent
,会调用commitUpdate
。
最终会在updateDOMProperties
中将render阶段completeWork
中为Fiber节点赋值的updateQueue
对应的内容渲染在页面上。
2.3 Deletion
意味着该Fiber节点对应的DOM节点需要从页面中删除。调用的方法为commitDeletion
。
该方法会执行如下操作:
- 递归调用Fiber节点及其子孙Fiber节点中
fiber.tag
为ClassComponent
的componentWillUnmount
生命周期钩子,从页面移除Fiber节点对应DOM节点 - 解绑ref
- 调度
useEffect
的销毁函数
3. layout
该阶段之所以称为layout,因为该阶段的代码都是在DOM修改完成(mutation阶段完成)后执行的。
由于 JS 的同步执行阻塞了主线程,所以此时 JS 已经可以获取到新的DOM,但是浏览器对新的DOM并没有完成渲染。
该阶段触发的生命周期钩子和hook可以直接访问到已经改变后的DOM。
与前两个阶段类似,layout阶段也是遍历effectList
,执行函数。
具体执行的函数是commitLayoutEffects
,这个函数一共做了两件事:
-
commitLayoutEffectOnFiber
(调用生命周期钩子和hook相关操作) -
commitAttachRef
(赋值 ref)
3.1 commitLayoutEffectOnFiber
该方法同样是根据fiber.tag
对不同类型的节点分别处理。
-
ClassComponent
他会通过current === null?区分是mount还是update,调用componentDidMount
或componentDidUpdate
。触发状态更新的
this.setState
如果赋值了第二个参数回调函数,也会在此时调用。 -
FunctionComponent
调用useLayoutEffect
的回调函数,调度useEffect
的销毁与回调函数。在2.2中Update时介绍过,
mutation
阶段会执行useLayoutEffect
的销毁函数。结合这里我们可以发现,
useLayoutEffect
从上一次更新的销毁函数调用到本次更新的回调函数调用是同步执行的。而
useEffect
则需要先调度,在Layout
阶段完成后再异步执行。
这就是useLayoutEffect
与useEffect
的区别。
3.2 commitAttachRef
获取DOM实例,更新ref。
4. Fiber树切换
至此,整个layout阶段就结束了。
在结束前,我们关注下这行代码:
root.current = finishedWork;
我们知道,workInProgress Fiber
树在commit阶段完成渲染后会变为current Fiber
树。这行代码的作用就是切换fiberRootNode
指向的current Fiber
树。
那么这行代码在什么时候执行呢?在mutation阶段结束后,layout阶段开始前。
我们知道componentWillUnmount
会在mutation
阶段执行。此时current Fiber
树还指向前一次更新的Fiber树,在生命周期钩子内获取的DOM还是更新前的。
componentDidMount
和componentDidUpdate
会在layout
阶段执行。此时current Fiber树已经指向更新后的Fiber树,在生命周期钩子内获取的DOM就是更新后的。