react在commit阶段做了什么
首先提出一个问题useffect和uselayouteffect有什么不同?
官网上的介绍
effect 的执行时机 (opens new window):
commitRoot
方法是commit阶段
工作的起点。fiberRootNode
会作为传参。
commitRoot(root);
在rootFiber.firstEffect
上保存了一条需要执行副作用
的Fiber节点
的单向链表effectList
,这些Fiber节点
的updateQueue
中保存了变化的props
。
这些副作用
对应的DOM操作
在commit
阶段执行。
除此之外,一些生命周期钩子(比如componentDidXXX
)、hook
(比如useEffect
)需要在commit
阶段执行。
commit
阶段的主要工作(即Renderer
的工作流程)分为三部分:
- before mutation阶段(执行
DOM
操作前) - mutation阶段(执行
DOM
操作) - layout阶段(执行
DOM
操作后)
在before mutation阶段
之前和layout阶段
之后还有一些额外工作,涉及到比如useEffect
的触发、优先级相关
的重置、ref
的绑定/解绑。
before mutation之前
commitRootImpl
方法中直到第一句if (firstEffect !== null)
之前属于before mutation
之前。
我们大体看下他做的工作
do {
// 触发useEffect回调与其他同步任务。由于这些任务可能触发新的渲染,所以这里要一直遍历执行直到没有任务
flushPassiveEffects();
} while (rootWithPendingPassiveEffects !== null);
// root指 fiberRootNode
// root.finishedWork指当前应用的rootFiber
const finishedWork = root.finishedWork;
// 凡是变量名带lane的都是优先级相关
const lanes = root.finishedLanes;
if (finishedWork === null) {
return null;
}
root.finishedWork = null;
root.finishedLanes = NoLanes;
// 重置Scheduler绑定的回调函数
root.callbackNode = null;
root.callbackId = NoLanes;
let remainingLanes = mergeLanes(finishedWork.lanes, finishedWork.childLanes);
// 重置优先级相关变量
markRootFinished(root, remainingLanes);
// 清除已完成的discrete updates,例如:用户鼠标点击触发的更新。
if (rootsWithPendingDiscreteUpdates !== null) {
if (
!hasDiscreteLanes(remainingLanes) &&
rootsWithPendingDiscreteUpdates.has(root)
) {
rootsWithPendingDiscreteUpdates.delete(root);
}
}
// 重置全局变量
if (root === workInProgressRoot) {
workInProgressRoot = null;
workInProgress = null;
workInProgressRootRenderLanes = NoLanes;
} else {
}
// 将effectList赋值给firstEffect
// 由于每个fiber的effectList只包含他的子孙节点
// 所以根节点如果有effectTag则不会被包含进来
// 所以这里将有effectTag的根节点插入到effectList尾部
// 这样才能保证有effect的fiber都在effectList中
let firstEffect;
if (finishedWork.effectTag > PerformedWork) {
if (finishedWork.lastEffect !== null) {
finishedWork.lastEffect.nextEffect = finishedWork;
firstEffect = finishedWork.firstEffect;
} else {
firstEffect = finishedWork;
}
} else {
// 根节点没有effectTag
firstEffect = finishedWork.firstEffect;
}
可以看到,before mutation
之前主要做一些变量赋值,状态重置的工作。
这一长串代码我们只需要关注最后赋值的firstEffect
,在commit
的三个子阶段都会用到他。
layout之后
接下来让我们简单看下layout
阶段执行完后的代码:
const rootDidHavePassiveEffects = rootDoesHavePassiveEffects;
// useEffect相关
if (rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = false;
rootWithPendingPassiveEffects = root;
pendingPassiveEffectsLanes = lanes;
pendingPassiveEffectsRenderPriority = renderPriorityLevel;
} else {}
// 性能优化相关
if (remainingLanes !== NoLanes) {
if (enableSchedulerTracing) {
// ...
}
} else {
// ...
}
// 性能优化相关
if (enableSchedulerTracing) {
if (!rootDidHavePassiveEffects) {
// ...
}
}
// ...检测无限循环的同步任务
if (remainingLanes === SyncLane) {
// ...
}
// 在离开commitRoot函数前调用,触发一次新的调度,确保任何附加的任务被调度
ensureRootIsScheduled(root, now());
// ...处理未捕获错误及老版本遗留的边界问题
// 执行同步任务,这样同步任务不需要等到下次事件循环再执行
// 比如在 componentDidMount 中执行 setState 创建的更新会在这里被同步执行
// 或useLayoutEffect
flushSyncCallbackQueue();
return null;
主要包括三点内容:
useEffect
相关的处理。- 性能追踪相关。
- 在
commit
阶段会触发一些生命周期钩子(如componentDidXXX
)和hook
(如useLayoutEffect
、useEffect
)。
三个阶段
before mutation阶段(执行DOM
操作前)
before mutation阶段
的代码很短,整个过程就是遍历effectList
并调用commitBeforeMutationEffects
函数处理。
// 保存之前的优先级,以同步优先级执行,执行完毕后恢复之前优先级
const previousLanePriority = getCurrentUpdateLanePriority();
setCurrentUpdateLanePriority(SyncLanePriority);
// 将当前上下文标记为CommitContext,作为commit阶段的标志
const prevExecutionContext = executionContext;
executionContext |= CommitContext;
// 处理focus状态
focusedInstanceHandle = prepareForCommit(root.containerInfo);
shouldFireAfterActiveInstanceBlur = false;
// beforeMutation阶段的主函数
commitBeforeMutationEffects(finishedWork);
focusedInstanceHandle = null;
我们重点关注beforeMutation
阶段的主函数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
。
我们讲解下2、3两点。
调用getSnapshotBeforeUpdate
commitBeforeMutationEffectOnFiber
是commitBeforeMutationLifeCycles
的别名。
在该方法内会调用getSnapshotBeforeUpdate
。
从React
v16开始,componentWillXXX
钩子前增加了UNSAFE_
前缀。
究其原因,是因为Stack Reconciler
重构为Fiber Reconciler
后,render阶段
的任务可能中断/重新开始,对应的组件在render阶段
的生命周期钩子(即componentWillXXX
)可能触发多次。
这种行为和React
v15不一致,所以标记为UNSAFE_
。
为此,React
提供了替代的生命周期钩子getSnapshotBeforeUpdate
。
我们可以看见,getSnapshotBeforeUpdate
是在commit阶段
内的before mutation阶段
调用的,由于commit阶段
是同步的,所以不会遇到多次调用的问题。
调度useEffect
在这几行代码内,scheduleCallback
方法由Scheduler
模块提供,用于以某个优先级异步调度一个回调函数。
// 调度useEffect
if ((effectTag & Passive) !== NoEffect) {
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true;
scheduleCallback(NormalSchedulerPriority, () => {
// 触发useEffect
flushPassiveEffects();
return null;
});
}
}
在此处,被异步调度的回调函数就是触发useEffect
的方法flushPassiveEffects
。
我们接下来讨论useEffect
如何被异步调度,以及为什么要异步(而不是同步)调度。
如何异步调度
在flushPassiveEffects
方法内部会从全局变量rootWithPendingPassiveEffects
获取effectList
。
effectList
中保存了需要执行副作用的Fiber节点
。其中副作用包括
- 插入
DOM节点
(Placement) - 更新
DOM节点
(Update) - 删除
DOM节点
(Deletion)
除此外,当一个FunctionComponent
含有useEffect
或useLayoutEffect
,他对应的Fiber节点
也会被赋值effectTag
。
在flushPassiveEffects
方法内部会遍历rootWithPendingPassiveEffects
(即effectList
)执行effect
回调函数。
如果在此时直接执行,rootWithPendingPassiveEffects === null
。
那么rootWithPendingPassiveEffects
会在何时赋值呢?
在上一节layout之后
的代码片段中会根据rootDoesHavePassiveEffects === true?
决定是否赋值rootWithPendingPassiveEffects
。
const rootDidHavePassiveEffects = rootDoesHavePassiveEffects;
if (rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = false;
rootWithPendingPassiveEffects = root;
pendingPassiveEffectsLanes = lanes;
pendingPassiveEffectsRenderPriority = renderPriorityLevel;
}
所以整个useEffect
异步调用分为三步:
before mutation阶段
在scheduleCallback
中调度flushPassiveEffects
layout阶段
之后将effectList
赋值给rootWithPendingPassiveEffects
scheduleCallback
触发flushPassiveEffects
,flushPassiveEffects
内部遍历rootWithPendingPassiveEffects
为什么需要异步调用
摘录自React
文档effect 的执行时机 (opens new window):
与 componentDidMount、componentDidUpdate 不同的是,在浏览器完成布局与绘制之后,传给 useEffect 的函数会延迟调用。这使得它适用于许多常见的副作用场景,比如设置订阅和事件处理等情况,因此不应在函数中执行阻塞浏览器更新屏幕的操作。
可见,useEffect
异步执行的原因主要是防止同步执行时阻塞浏览器渲染。
总结
在before mutation阶段
,会遍历effectList
,依次执行:
- 处理
DOM节点
渲染/删除后的autoFocus
、blur
逻辑 - 调用
getSnapshotBeforeUpdate
生命周期钩子 - 调度
useEffect
mutation阶段(执行DOM
操作)
类似before mutation阶段
,mutation阶段
也是遍历effectList
,执行函数。这里执行的是commitMutationEffects
。
commitMutationEffects
代码如下:
function commitMutationEffects(root: FiberRoot, renderPriorityLevel) {
// 遍历effectList
while (nextEffect !== null) {
const effectTag = nextEffect.effectTag;
// 根据 ContentReset effectTag重置文字节点
if (effectTag & ContentReset) {
commitResetTextContent(nextEffect);
}
// 更新ref
if (effectTag & Ref) {
const current = nextEffect.alternate;
if (current !== null) {
commitDetachRef(current);
}
}
// 根据 effectTag 分别处理
const primaryEffectTag =
effectTag & (Placement | Update | Deletion | Hydrating);
switch (primaryEffectTag) {
// 插入DOM
case Placement: {
commitPlacement(nextEffect);
nextEffect.effectTag &= ~Placement;
break;
}
// 插入DOM 并 更新DOM
case PlacementAndUpdate: {
// 插入
commitPlacement(nextEffect);
nextEffect.effectTag &= ~Placement;
// 更新
const current = nextEffect.alternate;
commitWork(current, nextEffect);
break;
}
// SSR
case Hydrating: {
nextEffect.effectTag &= ~Hydrating;
break;
}
// SSR
case HydratingAndUpdate: {
nextEffect.effectTag &= ~Hydrating;
const current = nextEffect.alternate;
commitWork(current, nextEffect);
break;
}
// 更新DOM
case Update: {
const current = nextEffect.alternate;
commitWork(current, nextEffect);
break;
}
// 删除DOM
case Deletion: {
commitDeletion(root, nextEffect, renderPriorityLevel);
break;
}
}
nextEffect = nextEffect.nextEffect;
}
}
commitMutationEffects
会遍历effectList
,对每个Fiber节点
执行如下三个操作:
- 根据
ContentReset effectTag
重置文字节点 - 更新
ref
- 根据
effectTag
分别处理,其中effectTag
包括(Placement
|Update
|Deletion
|Hydrating
)
当Fiber节点
含有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节点
很可能跨层级遍历。
考虑如下例子:
function Item() {
return <li><li>;
}
function App() {
return (
<div>
<Item/>
</div>
)
}
ReactDOM.render(<App/>, document.getElementById('root'));
对应的Fiber树
和DOM树
结构为:
// Fiber树
child child child child
rootFiber -----> App -----> div -----> Item -----> li
// DOM树
#root ---> div ---> li
当在div
的子节点Item
前插入一个新节点p
,即App
变为:
function App() {
return (
<div>
<p></p>
<Item/>
</div>
)
}
对应的Fiber树
和DOM树
结构为:
// Fiber树
child child child
rootFiber -----> App -----> div -----> p
| sibling child
| -------> Item -----> li
// DOM树
#root ---> div ---> p
|
---> li
此时DOM节点
p
的兄弟节点为li
,而Fiber节点
p
对应的兄弟DOM节点
为:
fiberP.sibling.child
即fiber p
的兄弟fiber
Item
的子fiber
li
当Fiber节点
含有Update effectTag
,意味着该Fiber节点
需要更新。调用的方法为commitWork
,他会根据Fiber.tag
分别处理。
FunctionComponent mutation
当fiber.tag
为FunctionComponent
,会调用commitHookEffectListUnmount
。该方法会遍历effectList
,执行所有useLayoutEffect hook
的销毁函数。
所谓“销毁函数”,见如下例子:
useLayoutEffect(() => {
// ...一些副作用逻辑
return () => {
// ...这就是销毁函数
}
})
只需要知道在mutation阶段
会执行useLayoutEffect
的销毁函数。
HostComponent mutation
当fiber.tag
为HostComponent
,会调用commitUpdate
。
最终会在updateDOMProperties
中将render阶段 completeWork
中为Fiber节点
赋值的updateQueue
对应的内容渲染在页面上。
for (let i = 0; i < updatePayload.length; i += 2) {
const propKey = updatePayload[i];
const propValue = updatePayload[i + 1];
// 处理 style
if (propKey === STYLE) {
setValueForStyles(domElement, propValue);
// 处理 DANGEROUSLY_SET_INNER_HTML
} else if (propKey === DANGEROUSLY_SET_INNER_HTML) {
setInnerHTML(domElement, propValue);
// 处理 children
} else if (propKey === CHILDREN) {
setTextContent(domElement, propValue);
} else {
// 处理剩余 props
setValueForProperty(domElement, propKey, propValue, isCustomComponentTag);
}
}
当Fiber节点
含有Deletion effectTag
,意味着该Fiber节点
对应的DOM节点
需要从页面中删除。调用的方法为commitDeletion
。
该方法会执行如下操作:
- 递归调用
Fiber节点
及其子孙Fiber节点
中fiber.tag
为ClassComponent
的componentWillUnmount
生命周期钩子,从页面移除Fiber节点
对应DOM节点
- 解绑
ref
- 调度
useEffect
的销毁函数
总结
mutation阶段
会遍历effectList
,依次执行commitMutationEffects
。该方法的主要工作为“根据effectTag
调用不同的处理函数处理Fiber
。
layout阶段(执行DOM
操作后)
该阶段之所以称为layout
,因为该阶段的代码都是在DOM
渲染完成(mutation阶段
完成)后执行的。
该阶段触发的生命周期钩子和hook
可以直接访问到已经改变后的DOM
,即该阶段是可以参与DOM layout
的阶段。
与前两个阶段类似,layout阶段
也是遍历effectList
,执行函数。
具体执行的函数是commitLayoutEffects
。
root.current = finishedWork;
nextEffect = firstEffect;
do {
try {
commitLayoutEffects(root, lanes);
} catch (error) {
invariant(nextEffect !== null, "Should be working on an effect.");
captureCommitPhaseError(nextEffect, error);
nextEffect = nextEffect.nextEffect;
}
} while (nextEffect !== null);
nextEffect = null;
代码如下:
function commitLayoutEffects(root: FiberRoot, committedLanes: Lanes) {
while (nextEffect !== null) {
const effectTag = nextEffect.effectTag;
// 调用生命周期钩子和hook
if (effectTag & (Update | Callback)) {
const current = nextEffect.alternate;
commitLayoutEffectOnFiber(root, current, nextEffect, committedLanes);
}
// 赋值ref
if (effectTag & Ref) {
commitAttachRef(nextEffect);
}
nextEffect = nextEffect.nextEffect;
}
}
commitLayoutEffects
一共做了两件事:
- commitLayoutEffectOnFiber(调用
生命周期钩子
和hook
相关操作) - commitAttachRef(赋值 ref)
commitLayoutEffectOnFiber
方法会根据fiber.tag
对不同类型的节点分别处理。
- 对于
ClassComponent
,他会通过current === null?
区分是mount
还是update
,调用componentDidMount
或componentDidUpdate
触发状态更新
的this.setState
如果赋值了第二个参数回调函数
,也会在此时调用。
this.setState({ xxx: 1 }, () => {
console.log("i am update~");
});
- 对于
FunctionComponent
及相关类型,他会调用useLayoutEffect hook
的回调函数
,调度useEffect
的销毁
与回调
函数
相关类型`指特殊处理后的`FunctionComponent`,比如`ForwardRef`、`React.memo`包裹的`FunctionComponent
switch (finishedWork.tag) {
// 以下都是FunctionComponent及相关类型
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent:
case Block: {
// 执行useLayoutEffect的回调函数
commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);
// 调度useEffect的销毁函数与回调函数
schedulePassiveEffects(finishedWork);
return;
}
在上一节介绍Update effect时介绍过,mutation阶段
会执行useLayoutEffect hook
的销毁函数
。
结合这里我们可以发现,useLayoutEffect hook
从上一次更新的销毁函数
调用到本次更新的回调函数
调用是同步执行的。
而useEffect
则需要先调度,在Layout阶段
完成后再异步执行。
这就是useLayoutEffect
与useEffect
的区别。
- 对于
HostRoot
,即rootFiber
,如果赋值了第三个参数回调函数
,也会在此时调用。
ReactDOM.render(<App />, document.querySelector("#root"), function() {
console.log("i am mount~");
});
commitLayoutEffects
会做的第二件事是commitAttachRef
。
function commitAttachRef(finishedWork: Fiber) {
const ref = finishedWork.ref;
if (ref !== null) {
const instance = finishedWork.stateNode;
// 获取DOM实例
let instanceToUse;
switch (finishedWork.tag) {
case HostComponent:
instanceToUse = getPublicInstance(instance);
break;
default:
instanceToUse = instance;
}
if (typeof ref === "function") {
// 如果ref是函数形式,调用回调函数
ref(instanceToUse);
} else {
// 如果ref是ref实例形式,赋值ref.current
ref.current = instanceToUse;
}
}
}
代码逻辑很简单:获取DOM
实例,更新ref
。
至此,整个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
就是更新后的。
总结
从这节我们学到,layout阶段
会遍历effectList
,依次执行commitLayoutEffects
。该方法的主要工作为“根据effectTag
调用不同的处理函数处理Fiber
并更新ref
。
回到最初的问题:
首先提出一个问题useffect和uselayouteffect有什么不同?
官网上的介绍
effect 的执行时机 (opens new window):
下面是以上几个阶段会调用的生命周期函数