React 19.3.1 源码深度剖析-第 6 章 React DOM 渲染器与 Commit 阶段(把 Fiber 副作用落到真实 DOM)(上)
本章定位:在第 3 章(Fiber 与 Flags 副作用模型)、第 4 章(Reconciliation 协调算法与 markUpdate)之后,我们终于走到了「副作用真正落地」的那一刻------Commit 阶段。本章要把 React 渲染器(Renderer)与协调器(Reconciler)之间的边界、Commit 三子阶段为什么要这样划分、Placement 定位算法的巧妙与代价、删除为什么不逐个
removeChild、React 19 去掉updatePayload的设计取舍、以及 ref 的 attach/detach 时机与 cleanup 函数,全部讲透。所有结论均落到react-19.3.1的真实源码行号上。
第 6 章 阅读导航(上篇:6.1 ~ 6.4)
| 维度 | 在本章的位置 |
|---|---|
| 动机与背景 | 6.1 |
| 整体心智模型 + mermaid | 6.2 |
| 核心数据结构(Flags/Masks/三阶段状态机/HostConfig 契约) | 6.3 |
| 关键算法/流程(commitRoot → 三子阶段 → Placement/Update/Deletion/Ref) | 6.4 |
下篇 (《第06章_ReactDOM渲染器与Commit阶段_B》)接续 6.5 起:与其他模块的交互(6.5)、边界场景与坑(6.6)、性能影响(6.7)、用法与最佳实践(6.8)、源码索引表(6.9)、自测 FAQ(6.10)、两段式 detach(6.11)、commitMount(6.12)、本章要点小结(6.13)。
本篇只覆盖 6.1~6.4,即"Commit 阶段是怎么跑的";"跑完之后还要注意什么"在下篇。
6.1 动机与背景:为什么需要 Commit 阶段
要理解 Commit 阶段,必须先理解 React 为什么把「计算」和「执行」两个阶段分开。
6.1.1 Stack Reconciler 的教训
在 React 15 及更早的 Stack Reconciler (栈式协调器)中,递归地进行 render 与 commit 是同步、不可中断 的。一旦开始渲染,浏览器主线程就被完全占住,直到整棵树的 DOM 都被更新完。对于大型应用,一次 setState 可能卡死几百毫秒,用户完全无法交互------这就是著名的「掉帧 / 卡顿」根源。
Fiber 架构(第 1~5 章)把工作拆成一个个可中断、可恢复 的纤程(Fiber)单元,使 Reconciler 阶段可以「时间切片」。但副作用(改 DOM、调生命周期、挂 ref)必须一次性、原子地完成------你不能改了一半 DOM 然后让出主线程,否则用户会看到一个「半新半旧」的破碎页面。
于是 React 把整个过程切成两块:
- Reconciler 阶段(render / 第 5 章):可中断、可恢复、可丢弃(因为没碰 DOM)。产出一棵带 Flags 的 finishedWork 树。
- Commit 阶段(本章) :不可中断、同步执行,把 finishedWork 上的 Flags 翻译成真实 DOM 操作、生命周期调用、ref 挂载。
这是 Commit 存在的根本理由:副作用的「可见性」要求原子性,而原子性要求同步,同步要求把所有 DOM 操作集中到最后一个不可中断的窗口里完成。
6.1.2 渲染器与协调器的解耦
另一个关键动机是多平台复用 。React 的核心算法(Diff、复用、Flags 标记)写在一个与平台无关的包 react-reconciler 里,而「怎么创建 DOM 节点、怎么 append、怎么设属性」这种平台相关的操作,被抽象成一族接口 HostConfig,由具体渲染器(这里是 react-dom-bindings 中的 ReactFiberConfigDOM.js)实现。
这意味着 react-reconciler 里完全不出现 document、appendChild、style 这些字眼 ------它只知道 Instance、Container 这种类型别名,调用的全是 createInstance、appendChild、commitUpdate 这种抽象方法。这是本章反复会回到的主题:HostConfig 解耦。我们会看到 precacheFiberNode + updateFiberProps 这两个调用,正好架起了「Fiber ↔ DOM」双向索引的桥梁,也是第 10 章事件委托能「从 DOM 一路找回 Fiber」的基础。
6.1.3 三子阶段的由来
副作用不止一种,而且它们的时序约束彼此冲突:
- 某些操作必须在「改 DOM 之前」读旧 DOM 状态(如
getSnapshotBeforeUpdate读滚动位置)。 - 某些操作必须「先删旧的、再挂新的、最后调生命周期」(否则
componentWillUnmount还能看到新 DOM)。 - 某些操作必须「root.current 已切换成新树」之后才能跑(否则
componentDidMount里读到的this的 DOM 还是旧的)。
这些约束无法在一个线性遍历里同时满足,因此 React 把 Commit 又切成三个独立的、各扫一遍整棵树的子阶段:
- Before Mutation(变更前):读旧状态、清容器。
- Mutation(变更中):真正改 DOM------插入、更新、删除、detach ref。
- Layout(布局/变更后) :root.current 已切换,挂 ref、跑
useLayoutEffect/componentDidMount/componentDidUpdate。
本章 6.4 会用源码逐行证明这个顺序的必要性。
6.2 整体心智模型
一句话心智模型:
Commit 是 Reconciler 把「带 Flags 的 Fiber 树」翻译成「真实副作用」的、不可中断的、分三次遍历的同步执行器;三次遍历分别解决「读旧值 / 改 DOM / 跑布局副作用」三类时序互不兼容的操作。
下面这张时序图(sequence diagram)给出 Commit 三子阶段与 root.current 切换点的宏观关系(来源:ReactFiberWorkLoop.js 的 commitRoot/flushMutationEffects/flushLayoutEffects):
下面这张流程图(flowchart)给出 commitRoot → 三个 flush 的内部状态机:
记住这两条主线的区别:
- DOM 的「形状」变化(增删改节点)发生在 Mutation 阶段。
- DOM 的「意义」变化(ref 指向、布局副作用、可读到新 DOM 的生命周期)发生在 Layout 阶段,且此时
root.current已是新树。
6.3 核心数据结构与契约
在讲算法之前,先把本章反复出现的「数据」讲清楚。
6.3.1 Flags 与 subtreeFlags(衔接第 3 章)
第 3 章讲过,finishedWork.flags 记录当前 Fiber 自身 需要执行的副作用,finishedWork.subtreeFlags 记录其子树里是否有需要执行的副作用。Commit 阶段避免全树遍历,就是靠这两个字段做「短路」:
js
// packages/react-reconciler/src/ReactFiberWorkLoop.js:3857
const subtreeHasBeforeMutationEffects =
(finishedWork.subtreeFlags & (BeforeMutationMask | MutationMask)) !==
NoFlags;
const rootHasBeforeMutationEffect =
(finishedWork.flags & (BeforeMutationMask | MutationMask)) !== NoFlags;
if (subtreeHasBeforeMutationEffects || rootHasBeforeMutationEffect) {
// ... 才进入 commitBeforeMutationEffects
}
这里有个细节:Before Mutation 阶段判断条件里同时或上了 MutationMask 。为什么?因为 BeforeMutationMask 和 MutationMask 的命中往往同时出现(一次更新既可能触发 snapshot 又可能触发 mutation),React 选择用一个合并判断减少分支。注意只对 BeforeMutation/Mutation 这两个「紧贴 DOM」的相位做短路;Layout/Passive 也有对应 LayoutMask/PassiveMask(见 6.4.3、6.4.4)。
6.3.2 三阶段 Mask 一览
这些 Mask 是「某相位需要处理的 Flags 集合」,定义在 ReactFiberFlags.js(本章不逐行展开其定义,但会用到)。关键事实(与用户确认清单一致):
| 子阶段 | 处理的 Mask | 主要 Flags | 是否改 DOM | root.current 状态 |
|---|---|---|---|---|
| Before Mutation | BeforeMutationMask |
Snapshot、clearContainer、beforeActiveInstanceBlur |
否(只读/清容器) | 仍是旧树 |
| Mutation | MutationMask |
Placement、Update、ChildDeletion、Ref(detach) |
是 | 仍是旧树(直到 4051) |
| Layout | LayoutMask |
Update(useLayoutEffect/生命周期)、Callback、Ref(attach) |
否(仅读新 DOM) | 已切换为新树 |
注意 Ref 这个 Flag 在 Mutation 和 Layout 两个阶段都有出现 ,但语义相反:Mutation 阶段是 detach(先拆旧的 ref) ,Layout 阶段是 attach(再挂新的 ref)。这正是 6.4.5 要讲透的「ref 时序」。
6.3.3 nextEffect 与「递归遍历」而非 effect list
React 16/17 时代有一个显式的 effect list(单链表 firstEffect/nextEffect/lastEffect),把有副作用的 Fiber 串起来,Commit 时只遍历这条链表。但到 React 19,effect list 已被废弃 ,改为直接递归整棵树 child → sibling → return ,靠 subtreeFlags 做短路裁剪(见 recursivelyTraverseMutationEffects、recursivelyTraverseLayoutEffects)。源码里 nextEffect 这个全局变量在 Before Mutation 阶段还保留着(见 commitBeforeMutationEffects 里 nextEffect = firstChild),但 Mutation/Layout 已是完全递归。
源码印证:
ReactFiberCommitWork.js中commitBeforeMutationEffects(352 行)仍使用nextEffect全局变量做遍历;而commitMutationEffects(2027 行)直接调用commitMutationEffectsOnFiber(2040 行)递归,不再依赖链表。
6.3.4 HostConfig 契约(渲染器解耦的核心)
react-reconciler 看到的不是 DOM,而是一组类型别名与函数。下面是 ReactFiberConfigDOM.js 必须实现、且本章会调用的契约方法清单:
| HostConfig 方法 | 所在文件:行 | 作用 |
|---|---|---|
createInstance |
ReactFiberConfigDOM.js:534 |
创建 DOM 节点(不插入) |
createTextInstance |
(同文件) | 创建文本节点 |
appendInitialChild |
ReactFiberConfigDOM.js:695 |
初始挂载时 append 子节点 |
finalizeInitialChildren |
ReactFiberConfigDOM.js:703 |
设置初始属性,返回是否需 commitMount |
appendChild |
ReactFiberConfigDOM.js:1032 |
把节点 append 到父(优先 moveBefore) |
insertBefore |
ReactFiberConfigDOM.js:1135 |
把节点插到参照节点前(优先 moveBefore) |
appendChildToContainer / insertInContainerBefore |
ReactFiberConfigDOM.js:1082/1148 |
针对根容器/Portal 的插入 |
commitUpdate |
ReactFiberConfigDOM.js:999 |
更新属性(React 19 内联 diff) |
commitTextUpdate |
ReactFiberConfigDOM.js:1018 |
更新文本 |
removeChild / removeChildFromContainer |
(同文件) | 删除节点 |
commitMount |
ReactFiberConfigDOM.js:891 |
初始挂载后的特殊处理(autoFocus/img) |
supportsMutation |
ReactFiberConfigDOM.js:889 |
声明本渲染器是「变更式」 |
supportsMoveBefore |
ReactFiberConfigDOM.js:1026 |
是否可用 Element.moveBefore |
react-reconciler 调用这些函数时,根本不知道背后是 DOM 还是 Native 视图。本章所有「改 DOM」的讲解,其实都是透过这层契约发生的。
6.3.5 Fiber ↔ DOM 的双向索引
这是 6.5 节(与第 10 章事件委托衔接)的伏笔,先在此点明两个关键调用,位于 createInstance 末尾:
js
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:661
precacheFiberNode(internalInstanceHandle, domElement);
updateFiberProps(domElement, props);
return domElement;
precacheFiberNode:把fiber.stateNode指向 DOM,同时通过内部 Map 把 DOM → fiber 建立反向索引(getInstanceFromNode)。updateFiberProps:把当前 props 句柄(尤其是事件处理器)塞到 DOM 上一份(getFiberCurrentPropsFromNode可读出)。
事件属性在 commitUpdate 里直接 return(见 6.4.4 与 6.5.4),正是因为事件处理器不写进 DOM 属性,而是走 updateFiberProps 这个「句柄」------事件委托发生时,第 10 章正是用 getInstanceFromNode 找到 fiber,再用 getFiberCurrentPropsFromNode 拿出最新 props 里的 handler。
6.4 关键算法与流程(逐函数讲解)
这是本章最核心的部分。我们从 commitRoot 出发,依次拆开三子阶段,再深入 Placement / Update / Deletion / Ref 四个子机制。
6.4.1 入口:commitRoot(3725)
commitRoot 不是单纯「做某事」,它先做一堆与副作用执行无关但必须在此刻完成的收尾工作,再驱动三子阶段。我们按出现顺序拆解。
js
// packages/react-reconciler/src/ReactFiberWorkLoop.js:3725
function commitRoot(
root, finishedWork, lanes, spawnedLane, updatedLanes,
suspendedRetryLanes, suspendedState, suspendedCommitReason,
completedRenderEndTime,
) {
// 1) 计算还有哪些 lane 没干完,标记 root 完成(markRootFinished)
let remainingLanes = mergeLanes(finishedWork.lanes, finishedWork.childLanes);
pendingEffectsRemainingLanes = remainingLanes;
// ... 处理手势/Gesture lane ...
markRootFinished(root, lanes, remainingLanes, /* ... */);
// 2) 把 didIncludeCommitPhaseUpdate 复位,用于检测「commit 期更新」递归
didIncludeCommitPhaseUpdate = false;
// 3) 如果有 passive effect,尽早排一个异步回调去 flush(3815 行的 scheduleCallback)
// ------注意:useEffect 不在这里同步跑,只是「排队」。
// ...
// 4) 三子阶段的驱动(3863 起)
// ...
}
第 (3) 点的设计意图极其重要 :scheduleCallback(NormalSchedulerPriority, () => { ... flushPassiveEffects(); })。React 把 useEffect(passive effect)的刷新故意推迟到本次 commit 之后的下一个宏任务/空闲时段。这保证:
- 浏览器能先 paint 出 Mutation 阶段改好的 DOM(用户看到更新)。
useLayoutEffect(layout 阶段,同步)永远比useEffect(passive,异步)先跑------这是 React 官方反复强调的时序保证,源码层面就是「layout 同步在 commit 内跑完,passive 异步在外部跑」。
继续往下,commitRoot 在 3848 行给出本章标题所依据的那段权威注释:
js
// packages/react-reconciler/src/ReactFiberWorkLoop.js:3848
// The commit phase is broken into several sub-phases. We do a separate pass
// of the effect list for each phase: all mutation effects come before all
// layout effects, and so on.
这里原文写的是 "effect list",是历史遗留注释(React 19 已无显式 effect list,改用递归),但「分多趟遍历、mutation 全在 layout 之前」的核心语义不变。按本系列规范「若发现规范与源码冲突以源码为准」,我们保留注释原貌并注明。
紧接着是 Before Mutation 阶段的触发(3871~3881),留意它把 executionContext |= CommitContext、把更新优先级临时提到 DiscreteEventPriority,并 T = null(禁止在 commit 中读 transition):
js
// packages/react-reconciler/src/ReactFiberWorkLoop.js:3863
if (subtreeHasBeforeMutationEffects || rootHasBeforeMutationEffect) {
const prevTransition = ReactSharedInternals.T;
ReactSharedInternals.T = null;
const previousPriority = getCurrentUpdatePriority();
setCurrentUpdatePriority(DiscreteEventPriority);
const prevExecutionContext = executionContext;
executionContext |= CommitContext;
try {
// The first phase a "before mutation" phase. We use this phase to read the
// state of the host tree right before we mutate it. This is where
// getSnapshotBeforeUpdate is called.
commitBeforeMutationEffects(root, finishedWork, lanes);
} finally {
executionContext = prevExecutionContext;
setCurrentUpdatePriority(previousPriority);
ReactSharedInternals.T = prevTransition;
}
}
最后(3915~3921),若没有 View Transition,commitRoot 同步 flush 后两个阶段:
js
// packages/react-reconciler/src/ReactFiberWorkLoop.js:3915
} else {
// Flush synchronously.
flushMutationEffects();
flushLayoutEffects();
// Skip flushAfterMutationEffects
flushSpawnedWork();
}
注意它跳过了 flushAfterMutationEffects,因为那是 View Transition 专用相位(6.4.4 末尾提及)。
6.4.2 Flush 函数与 root.current 切换时机(4009 / 4055 / 4051)
三个 flush 函数 flushMutationEffects(4009)、flushLayoutEffects(4055)、flushAfterMutationEffects(3997)是真正的执行闸门。它们的签名几乎一致,都是「检查 pendingEffectsStatus 状态位 → 取 pendingEffectsRoot/finishedWork/lanes → 调对应的 commitXxxEffects → 推进状态位」。
我们重点看 flushMutationEffects 的尾巴 ------也就是 root.current 切换的精确位置:
js
// packages/react-reconciler/src/ReactFiberWorkLoop.js:4009
function flushMutationEffects(): void {
if (pendingEffectsStatus !== PENDING_MUTATION_PHASE) {
return;
}
pendingEffectsStatus = NO_PENDING_EFFECTS;
const root = pendingEffectsRoot;
const finishedWork = pendingFinishedWork;
const lanes = pendingEffectsLanes;
const subtreeMutationHasEffects =
(finishedWork.subtreeFlags & MutationMask) !== NoFlags;
const rootMutationHasEffect = (finishedWork.flags & MutationMask) !== NoFlags;
if (subtreeMutationHasEffects || rootMutationHasEffect) {
// ... 设置 executionContext / 优先级 ...
try {
// The next phase is the mutation phase, where we mutate the host tree.
commitMutationEffects(root, finishedWork, lanes); // 4031
if (enableCreateEventHandleAPI) {
if (shouldFireAfterActiveInstanceBlur) {
afterActiveInstanceBlur();
}
}
resetAfterCommit(root.containerInfo); // 4038
} finally {
// 复位
}
}
// 4047 --------- 本章最关键的几行注释 ---------
// The work-in-progress tree is now the current tree. This must come after
// the mutation phase, so that the previous tree is still current during
// componentWillUnmount, but before the layout phase, so that the finished
// work is current during componentDidMount/Update.
root.current = finishedWork; // 4051
pendingEffectsStatus = PENDING_LAYOUT_PHASE; // 4052
}
这一段是「三阶段为什么这样划分」的源码级铁证,逐字拆解:
commitMutationEffects在 4031 行先跑------此时 DOM 正在被增删改,旧树仍是root.current。resetAfterCommit(4038)是渲染器在「所有 mutation 完成后、layout 前」的钩子(比如 React DOM 在这里重置一些容器级状态)。- 4047~4051 的注释明确说 :
root.current = finishedWork必须放在 mutation 之后 (否则componentWillUnmount会看到新 DOM 而非旧 DOM),且必须放在 layout 之前 (否则componentDidMount/Update里读到的仍是旧树)。 - 4052 把状态推进到
PENDING_LAYOUT_PHASE,下一个 flush 才会跑 layout。
这就是为什么 componentWillUnmount 永远看不到新 DOM,而 componentDidMount 永远看得到新 DOM ------全靠 4047~4051 这一行赋值的位置。这是 React 生命周期时序正确性的基石。root.current 切换时机错误,会直接破坏成千上万依赖此约定的业务代码。
6.4.3 Before Mutation 阶段:commitBeforeMutationEffects(352)
这个相位做三件事:
prepareForCommit取当前聚焦的实例句柄(focusedInstanceHandle),供 blur 守卫用。- 遍历整棵树,处理
Snapshot类 Flag(本质是getSnapshotBeforeUpdate的等价物)。 - 处理根容器的
clearContainer(在 hydration/SSR 切换等场景清空旧内容)。
入口:
js
// packages/react-reconciler/src/ReactFiberCommitWork.js:352
export function commitBeforeMutationEffects(
root, firstChild, committedLanes,
): void {
focusedInstanceHandle = prepareForCommit(root.containerInfo);
shouldFireAfterActiveInstanceBlur = false;
const isViewTransitionEligible =
enableViewTransition &&
includesOnlyViewTransitionEligibleLanes(committedLanes);
nextEffect = firstChild;
commitBeforeMutationEffects_begin(isViewTransitionEligible);
focusedInstanceHandle = null;
resetAppearingViewTransitions();
}
然后 commitBeforeMutationEffectsOnFiber(483)按 tag 分派,我们只看与本主题相关的几支:
js
// packages/react-reconciler/src/ReactFiberCommitWork.js:483
function commitBeforeMutationEffectsOnFiber(finishedWork, isViewTransitionEligible) {
const current = finishedWork.alternate;
const flags = finishedWork.flags;
// ... enableCreateEventHandleAPI 的 focus/blur 守卫(490~505)...
switch (finishedWork.tag) {
// ... FunctionComponent 的 event payload 处理(511)...
case ClassComponent: {
if ((flags & Snapshot) !== NoFlags) {
if (current !== null) {
commitClassSnapshot(finishedWork, current); // 527:getSnapshotBeforeUpdate 等价物
}
}
break;
}
case HostRoot: {
if ((flags & Snapshot) !== NoFlags) {
if (supportsMutation) {
const root = finishedWork.stateNode;
clearContainer(root.containerInfo); // 537:清容器
}
}
break;
}
// HostComponent/HostText 等:nothing to do
}
}
要点:
commitClassSnapshot(527) 内部调用getSnapshotBeforeUpdate,把「变更前」的 DOM 状态(如滚动偏移)存下来,供稍后componentDidUpdate对比。这必须在 Mutation 之前读,因为一旦 DOM 被改,旧状态就没了。clearContainer(537) :仅当HostRoot带了Snapshot且supportsMutation时清空容器。这是 SSR hydration 转客户端渲染、或某些根级更新的清理动作,必须在插入新节点前把旧内容清掉。
Before Mutation 阶段完全不碰 DOM 的「形状」,只「读」和「清」。这就是为什么它能和 Mutation 严格分离。
6.4.4 Mutation 阶段:commitMutationEffects 与 commitMutationEffectsOnFiber(2027 / 2072)
这是真正改 DOM 的阶段。入口很短,只是设好全局 inProgressRoot/inProgressLanes 后递归:
js
// packages/react-reconciler/src/ReactFiberCommitWork.js:2027
export function commitMutationEffects(root, finishedWork, committedLanes) {
inProgressLanes = committedLanes;
inProgressRoot = root;
rootViewTransitionAffected = false;
inUpdateViewTransition = false;
resetComponentEffectTimers();
commitMutationEffectsOnFiber(finishedWork, root, committedLanes);
inProgressLanes = null;
inProgressRoot = null;
}
真正的核心是 commitMutationEffectsOnFiber(2072)。它先处理事件 effect 的提前改写 (2096~2109,把 eventPayloads 的 ref.impl 换成 nextImpl,确保「父事件先于子事件」被改写),然后 recursivelyTraverseMutationEffects 向下,最后 commitReconciliationEffects(2111)处理自身的 Placement。
我们重点看 recursivelyTraverseMutationEffects(2046),它揭示了「删除优先于子节点 mutation」的顺序:
js
// packages/react-reconciler/src/ReactFiberCommitWork.js:2046
function recursivelyTraverseMutationEffects(root, parentFiber, lanes) {
// Deletions effects can be scheduled on any fiber type. They need to happen
// before the children effects have fired.
const deletions = parentFiber.deletions;
if (deletions !== null) {
for (let i = 0; i < deletions.length; i++) {
const childToDelete = deletions[i];
commitDeletionEffects(root, parentFiber, childToDelete); // 先删
}
}
if (parentFiber.subtreeFlags & (MutationMask | Cloned)) {
let child = parentFiber.child;
while (child !== null) {
commitMutationEffectsOnFiber(child, root, lanes); // 再处理子节点
child = child.sibling;
}
}
}
顺序的语义 :先对 deletions 调用 commitDeletionEffects,再递归子节点。为什么要先删?因为如果先递归子节点(可能触发 update)再删,被删子树里可能已经跑了不该跑的副作用。删除(unmount)是「不可逆、要尽快完成」的动作,所以排在最前。
(a) Placement:commitReconciliationEffects(2845)
js
// packages/react-reconciler/src/ReactFiberCommitWork.js:2845
function commitReconciliationEffects(finishedWork, committedLanes) {
const flags = finishedWork.flags;
if (flags & Placement) {
commitHostPlacement(finishedWork); // 调 commitPlacement
// Clear the "placement" from effect tag so that we know that this is
// inserted, before any life-cycles like componentDidMount gets called.
finishedWork.flags &= ~Placement; // 清掉 Placement 位
}
if (flags & Hydrating) {
finishedWork.flags &= ~Hydrating;
}
}
注意 2859 行 finishedWork.flags &= ~Placement:Placement 位在 mutation 阶段就被清掉 。原因在注释里:当 componentDidMount 在 layout 阶段触发时,它需要知道自己「已经被插入了」,不能再误判。清掉 Placement 是一种「状态机推进」------防止后续阶段重复处理。
commitHostPlacement 仅是 commitPlacement 的薄包装(ReactFiberCommitHostEffects.js 中),下一节 6.4.5 单独深挖。
(b) Update(属性更新):commitHostUpdate → commitUpdate → updateProperties(3013 / 114 / 999)
在 commitMutationEffectsOnFiber 的 HostComponent 分支(约 2302 行):
js
// packages/react-reconciler/src/ReactFiberCommitWork.js:2302
if (flags & Update) {
const instance = finishedWork.stateNode;
if (instance != null) {
const newProps = finishedWork.memoizedProps;
const oldProps = current !== null ? current.memoizedProps : newProps;
commitHostUpdate(finishedWork, newProps, oldProps); // 2312
}
}
commitHostUpdate(ReactFiberCommitHostEffects.js:114)只是错误捕获 + 调 commitUpdate:
js
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js... 实为 ReactFiberCommitHostEffects.js:114
export function commitHostUpdate(finishedWork, newProps, oldProps): void {
try {
// DEV 包用 runWithFiberInDEV 包裹,便于 DevTools 定位出错 fiber
commitUpdate(
finishedWork.stateNode, finishedWork.type,
oldProps, newProps, finishedWork,
);
} catch (error) {
captureCommitPhaseError(finishedWork, finishedWork.return, error);
}
}
commitUpdate(ReactFiberConfigDOM.js:999)------React 19 的关键设计在这里:
js
// packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js:999
export function commitUpdate(
domElement, type, oldProps, newProps, internalInstanceHandle,
): void {
// Diff and update the properties.
updateProperties(domElement, type, oldProps, newProps);
// Update the props handle so that we know which props are the ones with
// with current event handlers.
updateFiberProps(domElement, newProps); // 1011:刷新事件句柄
}
设计取舍(关键考点) :在更早的 React 版本里,commitUpdate 之前要先算一个 updatePayload 数组(由 diffProperties 产出,形如 [key1, value1, key2, value2, ...]),再遍历这个 payload 去设属性。React 19 去掉了 updatePayload ,改为在 updateProperties 里内联 diff lastProps/nextProps (即 oldProps/newProps)。
为什么?源码注释(1011 附近)点明:updateFiberProps 必须在更新后刷新,因为事件处理器依赖这个句柄。把 diff 逻辑从「render 期预计算 payload」挪到「commit 期直接内联 diff」,省掉了 render 期构造/传递 payload 数组的开销,也让「最新 props 句柄」的维护更内聚。代价是:render 阶段不再提前知道「哪些属性变了」,因此像 markUpdate(第 4 章)这类「提前标记属性更新」的逻辑在 React 19 里被弱化/合并------我们会在 6.5.3 衔接第 4 章。
继续看 updateProperties(ReactDOMComponent.js:1518)的骨架:
js
// packages/react-dom-bindings/src/client/ReactDOMComponent.js:1518
export function updateProperties(domElement, tag, lastProps, nextProps): void {
if (__DEV__) validatePropertiesInDevelopment(tag, nextProps);
switch (tag) {
// div/span/svg... 常见标签走 fast track,break(1529~1539)
case 'input': {
// input 特殊:value/checked 由 updateWrapper 处理,这里只 diff 其余属性
// ...
}
default: {
// 1) 先遍历 lastProps,若某 key 不在 nextProps 里 → setProp(..., null, ...)
// 2) 再遍历 nextProps,若 nextProp !== lastProp → setProp(...)
}
}
}
两段遍历(删旧 + 设新)正是「内联 diff」的具体实现:
- 对
lastProps里存在、nextProps里缺失的属性,用setProp(domElement, tag, key, null, nextProps, lastProp)置空/删除。 - 对
nextProps里与lastProp不同的属性,用setProp(...)真正写入。
setProp(ReactDOMComponent.js:397)是按 key 分派的巨型 switch。我们抓两个与本章强相关、且用户清单点名的细节:
细节一:事件属性直接 return(976~991)
js
// packages/react-dom-bindings/src/client/ReactDOMComponent.js:976
default: {
if (
key.length > 2 &&
(key[0] === 'o' || key[0] === 'O') &&
(key[1] === 'n' || key[1] === 'N')
) {
if (__DEV__ && registrationNameDependencies.hasOwnProperty(key) && value != null && typeof value !== 'function') {
warnForInvalidEventListener(key, value);
}
// Updating events doesn't affect the visuals.
return; // 991:事件属性直接返回
} else {
const attributeName = getAttributeAlias(key);
setValueForAttribute(domElement, attributeName, value);
}
}
为什么事件属性在 setProp 里直接 return?两个原因:
- 事件不经过 DOM 属性 :React 的事件是委托在根容器上的(第 10 章)。handler 的最新版本存在
updateFiberProps维护的 props 句柄里,而不是挂在 DOM 的onclick属性上。所以 diff 到一个onClick变了,不需要改任何 DOM 属性 ,只需commitUpdate末尾的updateFiberProps(domElement, newProps)刷新句柄即可(见上面 1011 行)。这也就是为什么setProp在事件分支直接return,注释明确写道 "Updating events doesn't affect the visuals." - 因此
commitUpdate末尾那句updateFiberProps是事件系统能拿到最新 handler 的唯一刷新点,这也是第 10 章能正确分发「更新后回调」的根本。
细节二:style 走 setValueForStyles(453~455)
js
// packages/react-dom-bindings/src/client/ReactDOMComponent.js:453
case 'style': {
setValueForStyles(domElement, value, prevValue);
return; // 455:早返回,避免标记 host mutation
}
React 对 style 做的是对象级 diff (比较每个 CSS 属性),而不是整段字符串赋值,这样能精确保留没变的样式、只改变的。注意 return 前不调用 trackHostMutation------因为 setValueForStyles 内部自己决定要不要标记(见 6.7 节性能讨论)。
细节三:自定义元素走 setPropOnCustomElement(~1051 的 default 之外)
当 type 含连字符(自定义元素)时,updateProperties/setInitialProperties 会把属性交给 setPropOnCustomElement(ReactDOMComponent.js:1002)。它和 setProp 的区别:对自定义元素,所有属性(包括 onXxx)都尝试直接写到 DOM 上 (因为自定义元素没有 React 事件委托),并且 onScroll/onClick 等需要 listenToNonDelegatedEvent 单独监听。这是对 Web Components 的兼容处理,值得记住它与普通 HTML 元素的「事件直接 return」形成对照。
(c) ChildDeletion:commitDeletionEffects(1386)
删除是 Mutation 阶段最微妙的部分,单独放 6.4.6 讲。
6.4.5 Placement 算法:commitPlacement(452)------ 巧妙与代价
Placement 解决一个看似简单实则棘手的问题:新 Fiber 应该插入到真实 DOM 的哪个位置?
React 在 render 阶段只给 Fiber 打了 Placement 标,但没有记录「插到哪个兄弟前面」 。这个信息要在 commit 时现场计算,因为兄弟节点的 DOM 位置是动态的。算法分三步:①找宿主父节点 ②找插入参照(before)③递归插入。
① 找宿主父节点(isHostParent 向上回溯)
js
// packages/react-reconciler/src/ReactFiberCommitHostEffects.js:452
function commitPlacement(finishedWork: Fiber): void {
let hostParentFiber;
let parentFiber = finishedWork.return;
while (parentFiber !== null) {
if (isHostParent(parentFiber)) {
hostParentFiber = parentFiber;
break;
}
parentFiber = parentFiber.return;
}
// ...
js
// packages/react-reconciler/src/ReactFiberCommitHostEffects.js:257
function isHostParent(fiber: Fiber): boolean {
return (
fiber.tag === HostComponent ||
fiber.tag === HostRoot ||
(supportsResources ? fiber.tag === HostHoistable : false) ||
(supportsSingletons ? fiber.tag === HostSingleton && isSingletonScope(fiber.type) : false) ||
fiber.tag === HostPortal
);
}
注意「宿主父节点」不一定是 finishedWork.return 的直接父------因为中间可能隔着 Fragment、Context.Provider、FunctionComponent 这些非宿主节点 。所以必须从 return 一路向上,直到遇到第一个 isHostParent(HostComponent / HostRoot / HostPortal 等)才停。比如 <div><Frag><span/></Frag></div> 里,span 的宿主父是 div,要跳过 Frag。
② 找插入参照(before):getHostSibling(271)
这是 Placement 算法里最巧妙也最贵的一步。我们需要知道「新节点应该插在它后面的那个已有宿主节点之前」。
js
// packages/react-reconciler/src/ReactFiberCommitHostEffects.js:271
function getHostSibling(fiber: Fiber): ?Instance {
// We're going to search forward into the tree until we find a sibling host
// node. Unfortunately, if multiple insertions are done in a row we have to
// search past them. This leads to exponential search for the next sibling.
// TODO: Find a more efficient way to do this.
let node: Fiber = fiber;
siblings: while (true) {
// If we didn't find anything, let's try the next sibling.
while (node.sibling === null) {
if (node.return === null || isHostParent(node.return)) {
return null; // 没有更后的兄弟 → 插到末尾(append)
}
node = node.return;
}
node.sibling.return = node.return;
node = node.sibling;
while (
node.tag !== HostComponent &&
node.tag !== HostText &&
node.tag !== DehydratedFragment
) {
// 跳过 singleton scope...
if (node.flags & Placement) {
continue siblings; // 兄弟本身也是新插入的,跳过(它还没 DOM 位置)
}
if (node.child === null || node.tag === HostPortal) {
continue siblings; // 没有宿主子节点,看它下一个兄弟
} else {
node.child.return = node;
node = node.child;
}
}
// Check if this host node is stable or about to be placed.
if (!(node.flags & Placement)) {
return node.stateNode; // 找到了稳定的宿主节点作 before 参照
}
}
}
逐段拆解:
- 从「待插入节点自身」开始,先横向找
sibling。 - 若
sibling为null,则向上回退到return(只要没到宿主父/根),继续找更外层的兄弟。 - 找到了一个兄弟 fiber,若它不是宿主节点(HostComponent/HostText/DehydratedFragment),就向下钻进它的子节点去「找第一个宿主叶子」。
- 关键判据 (323 行):
if (!(node.flags & Placement))------ 只有当这个宿主兄弟本身不是 新插入(没有 Placement 标)时,它才「已经在 DOM 里有稳定位置」,可以作before参照。 - 如果兄弟也是 Placement(还没插入),
continue siblings跳过它,继续往后找------因为插入是按顺序进行的,前面的新节点会先被插好。
为什么返回 null 就是「append 到末尾」? 因为外层 while(node.sibling === null) 一直回退到宿主父/根还没找到更后的稳定宿主兄弟时,说明当前节点应该排在所有已存在节点之后 → insertOrAppendPlacementNode 里 before 为 null → 走 appendChild。
指数级回退(exponential search):注释 273~275 自曝其短------「if multiple insertions are done in a row we have to search past them. This leads to exponential search for the next sibling.」
为什么会指数级?考虑一个列表 [A, B, C, D] 全部被打上 Placement(比如整个列表被重新排序/替换)。插入 A 时,getHostSibling(A) 要跳过 B、C、D(它们都是 Placement,逐个 continue siblings);插入 B 时,要跳过 C、D;......每次插入都要重新向后扫描所有尚未插入的兄弟。最坏情况下,N 个连续 Placement 节点的插入是 O(N²) 的------而因为每个节点都要从自己位置向后一路 continue,叠加回退,呈现「指数级」恶化趋势(严格说是平方,但结合向上的 return 回退与向下的 child 钻取,复杂度在深层树里会更高,源码用 "exponential" 强调其代价严重)。React 官方留了 TODO: Find a more efficient way to do this. 但尚未实现更优算法。这是 6.7 节性能分析的主角。
③ 递归插入:insertOrAppendPlacementNode(394 / 330)
找到 before 后,commitPlacement 根据宿主父类型分别调用 insertOrAppendPlacementNode(父是普通 Instance)或 insertOrAppendPlacementNodeIntoContainer(父是容器/Portal):
js
// packages/react-reconciler/src/ReactFiberCommitHostEffects.js:505
case HostComponent: {
const parent = hostParentFiber.stateNode;
if (hostParentFiber.flags & ContentReset) {
resetTextContent(parent); // 先清空父的文本(ContentReset)
hostParentFiber.flags &= ~ContentReset;
}
const before = getHostSibling(finishedWork);
insertOrAppendPlacementNode(finishedWork, before, parent, parentFragmentInstances);
break;
}
插入函数本身递归下降到宿主叶子 ,因为 finishedWork 可能是一个「组件 Fiber」,其下挂着真正的 DOM 节点:
js
// packages/react-reconciler/src/ReactFiberCommitHostEffects.js:394
function insertOrAppendPlacementNode(node, before, parent, parentFragmentInstances) {
const {tag} = node;
const isHost = tag === HostComponent || tag === HostText;
if (isHost) {
const stateNode = node.stateNode;
if (before) {
insertBefore(parent, stateNode, before); // 插到 before 前
} else {
appendChild(parent, stateNode); // 否则 append 到末尾
}
// ... Fragment refs / trackHostMutation ...
return;
} else if (tag === HostPortal) {
return; // Portal 自身不插入,它的子节点会各自处理
}
// Singleton scope 处理(略)...
const child = node.child;
if (child !== null) {
insertOrAppendPlacementNode(child, before, parent, parentFragmentInstances);
let sibling = child.sibling;
while (sibling !== null) {
insertOrAppendPlacementNode(sibling, before, parent, parentFragmentInstances);
sibling = sibling.sibling;
}
}
}
核心逻辑:
- 若
node本身就是宿主节点(HostComponent/HostText),直接insertBefore或appendChild。 - 否则(比如
FunctionComponent/Fragment外壳),递归它的所有 child 与 sibling ,把它们各自的宿主叶子插到同一before之后。
为什么「before」在递归里不变? 因为同一棵被插入的子树,它的所有宿主节点在 DOM 里应当连续出现;用一个固定的 before 作参照,能保证整棵子树被「挤」到正确位置。appendChildToContainer/insertInContainerBefore(1082/1148)是容器版,逻辑对称。
moveBefore 优化(见 6.7) :insertBefore/appendChild 现代浏览器下会优先用 Element.moveBefore(若 supportsMoveBefore 为真),保留被移动节点的内部状态(如 <video> 播放进度、<input> 焦点、iframe 内容),而不是「删了再建」。
6.4.6 Deletion 算法:commitDeletionEffects(1386)------ 为什么不逐个 removeChild
删除比插入更讲究,因为要先卸载所有子树的 effect/ref/生命周期,最后才动 DOM。
js
// packages/react-reconciler/src/ReactFiberCommitWork.js:1386
function commitDeletionEffects(root, returnFiber, deletedFiber) {
// ... supportsMutation 分支 ...
// Before starting, find the nearest host parent on the stack
let parent = returnFiber;
findParent: while (parent !== null) {
switch (parent.tag) {
case HostSingleton: /* supportsSingletons 处理 */ break findParent;
case HostComponent:
hostParent = parent.stateNode; hostParentIsContainer = false; break findParent;
case HostRoot:
case HostPortal:
hostParent = parent.stateNode.containerInfo; hostParentIsContainer = true; break findParent;
}
parent = parent.return;
}
// ...
commitDeletionEffectsOnFiber(root, returnFiber, deletedFiber);
hostParent = null;
hostParentIsContainer = false;
// ...
detachFiberMutation(deletedFiber); // 1474:切断 return 防内存泄漏
}
先找「宿主父」(与 Placement 同理,从 returnFiber 向上找第一个 isHostParent),记下 hostParent 与 hostParentIsContainer。然后递归卸载。
进入 commitDeletionEffectsOnFiber(1490),按 tag 分派。看 HostComponent 这一支:
js
// packages/react-reconciler/src/ReactFiberCommitWork.js:1569
case HostComponent: {
if (!offscreenSubtreeWasHidden) {
safelyDetachRef(deletedFiber, nearestMountedAncestor); // 先拆 ref
}
if (enableFragmentRefs) {
commitFragmentInstanceDeletionEffects(deletedFiber);
}
// Intentional fallthrough to next branch
}
case HostText: {
if (enableFragmentRefs && enableFragmentRefsTextNodes && deletedFiber.tag === HostText) {
commitFragmentInstanceDeletionEffects(deletedFiber);
}
// We only need to remove the nearest host child. Set the host parent
// to `null` on the stack to indicate that nested children don't
// need to be removed.
if (supportsMutation) {
const prevHostParent = hostParent;
const prevHostParentIsContainer = hostParentIsContainer;
hostParent = null; // 关键:子树内部不再 removeChild
recursivelyTraverseDeletionEffects(finishedRoot, nearestMountedAncestor, deletedFiber);
hostParent = prevHostParent;
hostParentIsContainer = prevHostParentIsContainer;
if (hostParent !== null) {
if (hostParentIsContainer) {
commitHostRemoveChildFromContainer(deletedFiber, /* ... */, hostParent, deletedFiber.stateNode);
} else {
commitHostRemoveChild(deletedFiber, /* ... */, hostParent, deletedFiber.stateNode);
}
}
}
}
为什么不逐个 removeChild? 这是 React 删除算法最聪明的地方:
-
hostParent = null是关键技巧。在递归卸载子树(recursivelyTraverseDeletionEffects)之前,把栈上的hostParent临时置为null。这样,子树里无论有多少层嵌套的 HostComponent,都不会再触发任何removeChild------因为commitHostRemoveChild只有在hostParent !== null时才执行(见 1603 的if (hostParent !== null))。 -
递归完成后,只对外层那个被删的
deletedFiber.stateNode调用一次commitHostRemoveChild。由于浏览器 DOM 是树形结构 ,删掉一个父节点,它下面的所有子孙节点会由浏览器级联回收(cascade回收)------根本不需要手动逐个删孩子。 -
这样做的好处:
- 性能 :删 N 层子树只调 1 次
removeChild,而非 N 次。 - 正确性 :unmount 副作用(ref detach、componentWillUnmount、layout effect cleanup)已经在
recursivelyTraverseDeletionEffects里从外到内递归执行完毕;等到真正删 DOM 时,只剩纯结构删除,没有任何副作用会再被错误触发。
- 性能 :删 N 层子树只调 1 次
-
最后
detachFiberMutation(deletedFiber)(1474)把deletedFiber.return = null,切断被删节点与父树的return指针------否则被删子树仍通过return引用父树,父树又通过child/deletions引用它,形成循环引用导致 GC 无法回收(内存泄漏)。这是 React 长期踩坑后加的防御。
commitDeletionEffectsOnFiber 在 ClassComponent 分支还会跑 componentWillUnmount、safelyDetachRef;在 FunctionComponent 分支跑 useLayoutEffect/useEffect 的 cleanup(unmount)。这些都在「唯一一次 removeChild 之前」完成------保证 componentWillUnmount 里还能访问到真实 DOM(因为 DOM 还没被删)。
6.4.7 Layout 阶段:commitLayoutEffects(3063)与 commitLayoutEffectOnFiber(600)
Mutation 结束后,root.current 已在 4051 切好,进入 Layout 阶段------这是唯一能同步读到新 DOM、且生命周期/useLayoutEffect/ref attach 都正确的阶段。
js
// packages/react-reconciler/src/ReactFiberCommitWork.js:3063
export function commitLayoutEffects(finishedWork, root, committedLanes): void {
inProgressLanes = committedLanes;
inProgressRoot = root;
resetComponentEffectTimers();
const current = finishedWork.alternate;
commitLayoutEffectOnFiber(root, current, finishedWork, committedLanes); // 3074
inProgressLanes = null;
inProgressRoot = null;
}
commitLayoutEffectOnFiber(600)按 tag 分派,我们看最关键的几支:
js
// packages/react-reconciler/src/ReactFiberCommitWork.js:614
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent: {
recursivelyTraverseLayoutEffects(finishedRoot, finishedWork, committedLanes);
if (flags & Update) {
commitHookLayoutEffects(finishedWork, HookLayout | HookHasEffect); // useLayoutEffect
}
break;
}
case ClassComponent: {
recursivelyTraverseLayoutEffects(finishedRoot, finishedWork, committedLanes);
if (flags & Update) {
commitClassLayoutLifecycles(finishedWork, current); // componentDidMount/Update
}
if (flags & Callback) {
commitClassCallbacks(finishedWork); // setState 回调
}
if (flags & Ref) {
safelyAttachRef(finishedWork, finishedWork.return); // ref attach
}
break;
}
case HostComponent: {
recursivelyTraverseLayoutEffects(finishedRoot, finishedWork, committedLanes);
if (current === null) {
if (flags & Update) {
commitHostMount(finishedWork); // autoFocus/img 等
} else if (flags & Hydrate) {
commitHostHydratedInstance(finishedWork);
}
}
if (flags & Ref) {
safelyAttachRef(finishedWork, finishedWork.return); // 宿主 ref attach
}
break;
}
要点:
recursivelyTraverseLayoutEffects(3080) 先向下递归子节点(保证子组件的componentDidMount先于父组件------经典的「子先父后」挂载顺序)。commitHookLayoutEffects:触发useLayoutEffect的回调(同步、阻塞 paint)。commitClassLayoutLifecycles:触发componentDidMount(首次)/componentDidUpdate(更新)。safelyAttachRef(642/702) :在 layout 阶段挂 ref------这是 ref 在「新 DOM 已就位」之后才被赋值的根本原因。
注意 Layout 阶段不再改 DOM 形状 (mutation 已经做完了),它只「读新 DOM、跑同步副作用、挂 ref」。这就是为什么 useLayoutEffect 里读到的 DOM 是更新后的,且它执行在浏览器 paint 之前(会阻塞渲染,故应极轻量)。
6.4.8 Ref 的 attach/detach 时机与 cleanup(ReactFiberCommitEffects.js)
ref 的时序是本章最容易被误解的点之一。用户清单明确:attach 在 layout 阶段,detach 在 mutation 阶段(先于 update)与删除阶段;函数 ref 可返回 cleanup。
attach(layout 阶段):safelyAttachRef(827)→ commitAttachRef(757)
js
// packages/react-reconciler/src/ReactFiberCommitEffects.js:757
function commitAttachRef(finishedWork: Fiber) {
const ref = finishedWork.ref;
if (ref !== null) {
let instanceToUse;
switch (finishedWork.tag) {
case HostHoistable:
case HostSingleton:
case HostComponent:
instanceToUse = getPublicInstance(finishedWork.stateNode); // 734:DOM 节点(经特殊包装)
break;
// ViewTransition / Fragment / default:取 stateNode
default:
instanceToUse = finishedWork.stateNode;
}
if (typeof ref === 'function') {
// 函数式 ref:调用并缓存返回值作为 cleanup
finishedWork.refCleanup = ref(instanceToUse); // 803
} else {
// 对象式 ref:ref.current = instanceToUse
ref.current = instanceToUse; // 821
}
}
}
关键设计:
- 函数式 ref 的返回值即 cleanup :
finishedWork.refCleanup = ref(instanceToUse)(803)。之后当这个 ref 被 detach 时,safelyDetachRef会调用这个refCleanup(见下)。这是 React 19 新增的 ref cleanup 函数机制,等价于useEffect返回 cleanup。 - 对象式 ref 直接赋
ref.current(821)。注意 DEV 下对字符串 ref 报错("String refs are no longer supported.")和对无current属性的对象 ref 报错。 getPublicInstance(HostComponent 分支)返回的是stateNode经过getPublicInstance包装后的对象------对绝大多数 DOM 元素就是 DOM 节点本身,但某些特例(如iframe/video的 wrapper)会返回更有意义的实例。
detach(mutation 阶段 / 删除阶段):safelyDetachRef(842)
js
// packages/react-reconciler/src/ReactFiberCommitEffects.js:842
export function safelyDetachRef(current, nearestMountedAncestor) {
const ref = current.ref;
const refCleanup = current.refCleanup;
if (ref !== null) {
if (typeof refCleanup === 'function') {
try {
// 调用之前 attach 时返回的 cleanup
refCleanup();
} catch (error) {
captureCommitPhaseError(current, nearestMountedAncestor, error);
} finally {
current.refCleanup = null; // 防止重复调用
}
} else if (typeof ref === 'function') {
// 老式函数 ref(无 cleanup):调用 ref(null)
ref(null);
} else if (ref !== null) {
ref.current = null; // 对象式 ref 置空
}
}
}
detach 的触发点有两处,都早于对应节点的新 DOM 建立:
- 更新场景 :在
commitMutationEffectsOnFiber的ClassComponent分支(2133~2137),当flags & Ref且current !== null且!offscreenSubtreeWasHidden时,safelyDetachRef(current, current.return)------先把旧实例的 ref 拆掉,再在 layout 阶段把新实例 attach 上。这保证了「更新过程中的某一刻,ref 不会同时指向新旧两个实例」。 - 删除场景 :在
commitDeletionEffectsOnFiber的HostComponent/ClassComponent等分支,safelyDetachRef在「唯一一次 removeChild」之前被调用(见 6.4.6)。
Offscreen 守卫 :用户清单提到的 offscreenSubtreeWasHidden 在 detach 时反复出现(如 2134、1570、1510)。它的语义是:当一个子树被 Offscreen/Activity 隐藏(hidden)时,它的「隐藏」不应触发 ref detach / unmount------因为子树只是被「藏起来」,随时可能「重新出现(reappear)」,如果 detach 了 ref 再重新 attach,会丢失聚焦、滚动等状态。所以 React 用 offscreenSubtreeWasHidden 守卫:在隐藏路径上跳过 detach,把子树的 effect 推迟到真正删除或重新显示时处理。这是并发特性(transition / Activity)带来的复杂度,6.6 节展开。
6.4.9 三阶段时序完整串联(一张图收束)
把 6.4.1~6.4.8 串起来,得到一次更新的完整副作用流水线:
时序铁律(务必记住):
- Deletion < Placement < Update < Ref-detach(均在 Mutation 内,root.current 仍是旧树)。
root.current = finishedWork(4051)夹在 Mutation 与 Layout 之间。- Ref-attach 在 Layout 内(root.current 已是新树)。
useEffect(passive)在 commit 之后的宏任务里异步跑,晚于useLayoutEffect。
6.4.10 commitRoot 完整流程逐段讲解(3725):从 render 完成到浏览器绘制
6.4.1 给出了 commitRoot 的骨架,但那只到 3921 行的 flushSpawnedWork() 为止。一次 commit 的真实生命周期其实一直延续到** passive 阶段跑完、root 被重新调度、Scheduler 被显式告知「可以 paint 了」**。本节把 3725 行到 passive 的每一段都走一遍,重点补上 6.4.1 未覆盖的部分:lane 账本结算、passive 调度条件的三个分支、pendingEffectsStatus 状态机、flushSpawnedWork 的尾巴(requestPaint / onCommitRootDevTools / ensureRootIsScheduled / sync 离散更新的同步 flush)、以及 flushPassiveEffectsImpl 的内部结构。
① 阶段零:lane 账本结算(3738~3766)
commitRoot 的第一件事不是碰 DOM,而是结清本次 render 占用的车道(Lane)账本:
js
// packages/react-reconciler/src/ReactFiberWorkLoop.js:3738
let remainingLanes = mergeLanes(finishedWork.lanes, finishedWork.childLanes);
pendingEffectsRemainingLanes = remainingLanes;
// Make sure to account for lanes that were updated by a concurrent event
// during the render phase; don't mark them as finished.
const concurrentlyUpdatedLanes = getConcurrentlyUpdatedLanes();
remainingLanes = mergeLanes(remainingLanes, concurrentlyUpdatedLanes);
finishedWork.lanes/finishedWork.childLanes是本次 render 覆盖的车道集合;取并集得到remainingLanes,意思是「这些车道的工作刚刚做完了」。pendingEffectsRemainingLanes = remainingLanes(3740)把这个值存起来给 passive 阶段用 ------后面flushPassiveEffects会在finally里releaseRootPooledCache(root, remainingLanes)(4724),决定 root 的 cache 池能不能回收。这就是为什么 passive 跑完之后 cache 才释放:useEffect里可能还在用池子里的 cache 实例(3816~3819 的注释原话:"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")。concurrentlyUpdatedLanes(3744)处理一个竞态:render 进行中用户又触发了同车道的新更新,这些车道不能标记为完成,否则新更新会被漏掉。- 最后
markRootFinished(root, lanes, remainingLanes, spawnedLane, updatedLanes, suspendedRetryLanes)(3759)通知 Scheduler「这些车道没活了」,ensureRootIsScheduled才不会继续为它们排任务。
② 阶段一:尽早排队 passive effect(3769~3827)
js
// packages/react-reconciler/src/ReactFiberWorkLoop.js:3776
let passiveSubtreeMask;
if (enableViewTransition) {
pendingViewTransitionEvents = null;
if (includesOnlyViewTransitionEligibleLanes(lanes)) {
// Claim any pending Transition Types for this commit.
pendingTransitionTypes = claimQueuedTransitionTypes(root);
passiveSubtreeMask = PassiveTransitionMask;
} else {
pendingTransitionTypes = null;
passiveSubtreeMask = PassiveMask;
}
} else {
passiveSubtreeMask = PassiveMask;
}
if (
// If this subtree rendered with profiling this commit, we need to visit it to log it.
(enableProfilerTimer &&
enableComponentPerformanceTrack &&
finishedWork.actualDuration !== 0) ||
(finishedWork.subtreeFlags & passiveSubtreeMask) !== NoFlags ||
(finishedWork.flags & passiveSubtreeMask) !== NoFlags
) {
if (enableYieldingBeforePassive) {
// We don't schedule a separate task for flushing passive effects.
// Instead, we just rely on ensureRootIsScheduled below to schedule
// a callback for us to flush the passive effects.
} else {
// So we can clear these now to allow a new callback to be scheduled.
root.callbackNode = null;
root.callbackPriority = NoLane;
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;
});
}
} else {
// If we don't have passive effects, we're not going to need to perform more work
// so we can clear the callback now.
root.callbackNode = null;
root.callbackPriority = NoLane;
}
三个要点:
passiveSubtreeMask在 View Transition 场景下换成PassiveTransitionMask(3782)。PassiveTransitionMask = PassiveMask | Update | Placement(ReactFiberFlags.js:136)------View Transition 需要把「仅仅更新过」或「新插入」的节点也纳入 passive 遍历,因为要在动画结束后恢复这些节点的view-transition-name。这是同一个 Mask 随相位语义扩张的典型例子。- 调度条件是三路 OR (3790~3797):Profiler 渲染过(
actualDuration !== 0,即使没有 passive flag 也要进去记性能数据)、subtreeFlags命中、flags命中。 root.callbackNode = null必须在scheduleCallback之前 (3804)。这是为了清掉上一个 Scheduler 任务句柄,让新任务能排进去(注释 "So we can clear these now to allow a new callback to be scheduled")。3771~3773 的注释还解释了为什么要「as early as possible」排队------引用 issue #16714:passive 回调必须在 commit 期间可能产生的其他任务之前入队,保证useEffect的相对顺序稳定。
注意 3798~3801 的 enableYieldingBeforePassive 分支:在这个实验开关下 React 不单独排任务 ,而是靠后面的 ensureRootIsScheduled 顺带调度 passive flush(见 flushPassiveEffectsImpl 末尾 4863)。
③ 阶段二:profiler 记账与 View Transition 复位(3829~3846)
js
// packages/react-reconciler/src/ReactFiberWorkLoop.js:3829
if (enableProfilerTimer) {
// Mark the current commit time to be shared by all Profilers in this
// batch. This enables them to be grouped later.
resetCommitErrors();
recordCommitTime();
// ...
}
resetShouldStartViewTransition();
resetCommitErrors 清空本次 commit 的错误收集数组(供 4261~4288 统一上报),recordCommitTime 记录 commit 起始时刻供 Profiler 分组。这两步没有任何副作用执行,纯记账。
④ 阶段三:Before Mutation 的短路与执行(3848~3881)
38573861 的短路判断在 6.3.1 已引用过;这里强调执行时机的三个语义开关(3864 3869):ReactSharedInternals.T = null(禁止在 commit 期间读到transition,否则 effect 可能读到「未来的 lane 状态」)、setCurrentUpdatePriority(DiscreteEventPriority)(commit 期间产生的更新按最高优先级的离散事件处理)、executionContext |= CommitContext(进入 Commit 上下文,flushPassiveEffectsImpl 4749 行会靠这个上下文拒绝「在 render/commit 中递归 flush passive」的非法调用)。
⑤ 阶段四:状态机推进与两条执行路径(3894~3921)
js
// packages/react-reconciler/src/ReactFiberWorkLoop.js:3894
pendingEffectsStatus = PENDING_MUTATION_PHASE;
if (enableViewTransition && shouldStartViewTransition) {
// ...
pendingViewTransition = startViewTransition(
suspendedState,
root.containerInfo,
pendingTransitionTypes,
flushMutationEffects,
flushLayoutEffects,
flushAfterMutationEffects,
flushSpawnedWork,
flushPassiveEffects,
reportViewTransitionError,
// ...
);
} else {
// Flush synchronously.
flushMutationEffects();
flushLayoutEffects();
// Skip flushAfterMutationEffects
flushSpawnedWork();
}
这是整个 commit 的分水岭 。默认路径(39153921)是「一条道跑到黑」:三个 flush 顺序调用。而 View Transition 路径(3899 3914)把五个 flush 函数当作回调交给浏览器 ------startViewTransition 拿到 document.startViewTransition() 返回的 ViewTransition 对象,由浏览器在「旧状态截图完成 → 新状态应用」的时机分别回调 flushMutationEffects / flushLayoutEffects / flushAfterMutationEffects / flushSpawnedWork。也就是说 View Transition 下 commit 是被浏览器动画帧拆成两段执行的 ,root.current 的切换点依然在 mutation 之后、layout 之前,但两段之间可能隔着一个动画帧。
注意 3919 的注释 // Skip flushAfterMutationEffects:非 VT 路径跳过的是 flushAfterMutationEffects(3997)这个相位(它负责 commitAfterMutationEffects,ReactFiberCommitWork.js:2884,为 View Transition 恢复 view-transition-name),但 flushLayoutEffects 结尾仍会把状态置为 PENDING_AFTER_MUTATION_PHASE(4151) ,由紧随其后的 flushSpawnedWork 兜底收尾(4155~4162 允许两种状态进入)。这个细节很容易看漏,导致误以为状态机跳过了某个相位。
下面这张图给出 pendingEffectsStatus 的完整状态机(每条边都是真实代码行):
⑥ 阶段五~六:两个已讲过的 flush(4009 / 4055)中容易漏掉的细节
flushMutationEffects 的 root.current 切换 6.4.2 已逐字分析。flushLayoutEffects(4055)里补两个 6.4.7 未覆盖的点:
enableDefaultTransitionIndicator的 cleanup 在 layout 阶段跑 (40804105):当 root 挂过「默认 transition 指示器」(4086 解释了这个选择的理由:「we do this in the layout phase so it has the same semantics as if you did it with aroot.pendingIndicator)且所有相关车道都已提交完(root.indicatorLanes === NoLanes), cleanup 函数在 layout 阶段以DiscreteEventPriority同步执行。注释 4084useLayoutEffector if it was reset automatically withuseOptimistic」。- LayoutMask 短路判断在
commitLayoutEffects之外 (4107~4111):和 mutation 一样,先看subtreeFlags/flags再决定要不要设CommitContext、跑commitLayoutEffects(4125)。
⑦ 阶段七:flushSpawnedWork 的尾巴------commit 真正「交还控制权」的地方(4154~4400)
flushSpawnedWork 名字听起来只处理「commit 期间产生的新工作」,实际上它是 整个同步 commit 的最后收尾段,做了四件对理解渲染时机至关重要的事:
js
// packages/react-reconciler/src/ReactFiberWorkLoop.js:4187
// Tell Scheduler to yield at the end of the frame, so the browser has an
// opportunity to paint.
requestPaint();
requestPaint()(4189):显式告诉 Scheduler「这一帧到此为止,让浏览器去 paint」。这是 React 把主线程交还给浏览器的确切位置------Mutation 阶段改好的 DOM 直到这里之后才可能被绘制。想看「React 什么时候让出主线程」,答案就是这一行。- passive 相位决策 (4197~4221):用和 ② 相同的
passiveSubtreeMask再判一次(注意要重算,因为 effect 里可能改过状态),有 passive 就PENDING_PASSIVE_PHASE,没有就NO_PENDING_EFFECTS并立刻releaseRootPooledCache(4216)。 - DevTools 通知 (4249):
onCommitRootDevTools(finishedWork.stateNode, renderPriority)------把 FiberRoot 和本次 render 的优先级递给 DevTools 后端,React DevTools 的 commit 高亮、组件更新计数都从这里拿数据。4257 的onCommitRootTestSelector()是测试选择器的孪生钩子。4261~4288 则把 render 期间累计的recoverableErrors(如 hydration mismatch)统一回调给root.onRecoverableError。 - sync 离散更新的 passive 同步 flush + 重新调度 + 死循环检测(4324~4385):
js
// packages/react-reconciler/src/ReactFiberWorkLoop.js:4316
// 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 (
includesSyncLane(pendingEffectsLanes) &&
(disableLegacyMode || root.tag !== LegacyRoot)
) {
flushPendingEffects();
}
// Always call this before exiting `completeRoot`, to ensure that any
// additional work on this root is scheduled.
ensureRootIsScheduled(root);
43244329 解释了一个常见现象:同样写 4385 是无限更新循环检测:如果本次 render/commit 又排了 sync 更新(useEffect,在离散事件(click)里触发时它几乎同步执行,而在 transition 里触发时要等 paint 之后 ------差别就在这个 includesSyncLane 分支。4342didIncludeRenderPhaseUpdate || didIncludeCommitPhaseUpdate),就累加 nestedUpdateCount,超过阈值会报 "Maximum update depth exceeded"(5249)。
⑧ 阶段八:flushPassiveEffects(4691)与 flushPassiveEffectsImpl(4728)
js
// packages/react-reconciler/src/ReactFiberWorkLoop.js:4728
function flushPassiveEffectsImpl() {
// ...
const prevExecutionContext = executionContext;
executionContext |= CommitContext;
commitPassiveUnmountEffects(root.current);
commitPassiveMountEffects(
root,
root.current,
lanes,
transitions,
pendingEffectsRenderEndTime,
);
if (__DEV__) {
commitDoubleInvokeEffectsInDEV(root, true);
}
executionContext = prevExecutionContext;
// ...
flushSyncWorkOnAllRoots();
// ...
// TODO: Move to commitPassiveMountEffects
onPostCommitRootDevTools(root);
// ...
return true;
}
逐点:
- 优先级降档 (4708~4709):
priority = lowerEventPriority(DefaultEventPriority, renderPriority)------passive effect 跑在比触发它的更新更低的优先级上,ReactSharedInternals.T = null(4715)同理。 - 「先全部 unmount、再全部 mount」 (4788→4789):
commitPassiveUnmountEffects(root.current)遍历整棵树跑完所有useEffect的 destroy,然后commitPassiveMountEffects再跑所有 create。这个顺序和 layout 阶段commitHookLayoutUnmountEffects的注释(ReactFiberCommitEffects.js:119~123)是同一套理由:destroy 之间、以及与兄弟组件 create 之间不能交叉,否则一个组件的 cleanup 可能覆盖另一个组件刚设好的 ref。 flushSyncWorkOnAllRoots()(4818):passive effect 里触发的同步更新在这里立刻 flush 掉,不等下一个 Scheduler 任务。commitDoubleInvokeEffectsInDEV(root, true)(4802):DEV + StrictEffectsMode 下的双调用(详见 6.4.17)。onPostCommitRootDevTools(root)(4867) :passive 阶段结束后再通知一次 DevTools,与 ④ 阶段七的onCommitRootDevTools分工不同------前者在同步 commit 后、后者在整个 passive 完成后。
⑨ 收尾:legacy root 的同步 flush(ReactFiberRootScheduler.js:177)
js
// packages/react-reconciler/src/ReactFiberRootScheduler.js:177
export function flushSyncWorkOnLegacyRootsOnly() {
// This is allowed to be called synchronously, but the caller should check
// the execution context first.
if (!disableLegacyMode) {
flushSyncWorkAcrossRoots_impl(NoLanes, true);
}
}
它的两个调用点解释了 legacy ReactDOM.render 的同步语义:scheduleUpdateOnFiber 里 lane 为 SyncLane 且 fiber 不是 concurrent mode 时立即 flush(ReactFiberWorkLoop.js:1109),以及 batchedUpdates 退出最外层时 flush(1874)。也就是说:legacy root 的 commit 是「卡」在事件处理函数里同步完成的,而 createRoot 的默认更新走 microtask/Scheduler 异步调度。这个差异是 React 18/19 保留 legacy 模式兼容性的关键一环。
把 ①~⑨ 串起来,一次点击触发的完整时间线如下:
6.4.11 MutationMask / LayoutMask / BeforeMutationMask / PassiveMask 的位运算短路
6.3.1 说过「commit 靠 subtreeFlags & Mask 跳过整棵无副作用子树」,本节把四个 Mask 的真实定义、八个真实短路点、以及一次位运算推演全部展开。
① 四个 Mask 的真实定义
js
// packages/react-reconciler/src/ReactFiberFlags.js:101
export const BeforeMutationMask: number =
Snapshot |
(enableCreateEventHandleAPI
? // createEventHandle needs to visit deleted and hidden trees to
// fire beforeblur
// TODO: Only need to visit Deletions during BeforeMutation phase if an
// element is focused.
Update | ChildDeletion | Visibility
: // useEffectEvent uses the snapshot phase,
// but we're moving it to the mutation phase.
enableEffectEventMutationPhase
? 0
: Update);
// For View Transition support we use the snapshot phase to scan the tree for potentially
// affected ViewTransition components.
export const BeforeAndAfterMutationTransitionMask: number =
Snapshot | Update | Placement | ChildDeletion | Visibility | ContentReset;
export const MutationMask =
Placement |
Update |
ChildDeletion |
ContentReset |
Ref |
Hydrating |
Visibility |
FormReset;
export const LayoutMask = Update | Callback | Ref | Visibility;
// TODO: Split into PassiveMountMask and PassiveUnmountMask
export const PassiveMask = Passive | Visibility | ChildDeletion;
// For View Transitions we need to visit anything we visited in the snapshot phase to
// restore the view-transition-name after committing the transition.
export const PassiveTransitionMask: number = PassiveMask | Update | Placement;
按本仓库默认 feature flag(enableCreateEventHandleAPI / enableEffectEventMutationPhase 均关闭)取各 Flag 的位权(ReactFiberFlags.js:18~40),可算出具体数值,方便后面推演:
| Mask | 组成(源码符号) | 十进制 | 十六进制 |
|---|---|---|---|
BeforeMutationMask |
`Snapshot | Update` | 1028 |
MutationMask |
`Placement | Update | ChildDeletion |
LayoutMask |
`Update | Callback | Ref |
PassiveMask |
`Passive | Visibility | ChildDeletion` |
PassiveTransitionMask |
`PassiveMask | Update | Placement` |
BeforeAndAfterMutationTransitionMask |
`Snapshot | Update | Placement |
几个值得记住的结构性事实:
Visibility出现在所有四个 Mask 里。Offscreen/Activity 的显隐切换在 before/mutation/layout/passive 四个相位都要被处理(disappear/reappear、hideInstance、passive 的显隐),所以它无法被任何相位裁掉。Update在所有 Mask 里都出现,但语义完全不同 :before 相位是「getSnapshotBeforeUpdate需要读旧值」,mutation 相位是「属性要 diff 到 DOM」,layout 相位是「跑useLayoutEffect/componentDidUpdate」,passive 相位(PassiveTransitionMask里)是「View Transition 恢复名字」。Snapshot位被FormReset复用 (ReactFiberFlags.js:50),所以MutationMask含FormReset实际上就是含Snapshot------<form>的重置标记因此天然落在 mutation 相位。BeforeMutationMask是 flag 依赖的 :开enableCreateEventHandleAPI时它扩到Update | ChildDeletion | Visibility,因为beforeblur需要访问被删除和被隐藏的树(注释 104~107)。
② 八个真实短路点
| # | 位置 | 代码 | 短路效果 |
|---|---|---|---|
| 1 | commitRoot:3857 |
`(finishedWork.subtreeFlags & (BeforeMutationMask | MutationMask)) !== NoFlags` |
| 2 | commitBeforeMutationEffects_begin:376,449 |
subtreeMask = isViewTransitionEligible ? BeforeAndAfterMutationTransitionMask : BeforeMutationMask;if ((fiber.subtreeFlags & subtreeMask) !== NoFlags && child !== null) |
before 相位内逐层裁子树;VT 场景换用更宽的 Mask |
| 3 | recursivelyTraverseMutationEffects:453 |
`if (parentFiber.subtreeFlags & (MutationMask | Cloned))` |
| 4 | flushMutationEffects:4018 |
(finishedWork.subtreeFlags & MutationMask) !== NoFlags |
整个 mutation 相位可跳过 |
| 5 | recursivelyTraverseLayoutEffects:3085 |
if (parentFiber.subtreeFlags & LayoutMask) |
一行代码裁掉整棵无布局副作用的子树 |
| 6 | flushLayoutEffects:4107 |
(finishedWork.subtreeFlags & LayoutMask) !== NoFlags |
整个 layout 相位可跳过 |
| 7 | commitRoot:3795 / flushSpawnedWork:4205 |
(finishedWork.subtreeFlags & passiveSubtreeMask) !== NoFlags |
决定要不要排 passive 回调 / 进 passive 相位 |
| 8 | recursivelyTraversePassiveUnmountEffects:5109 |
if (parentFiber.subtreeFlags & PassiveMask) |
passive unmount 遍历裁子树 |
第 5 个短路点是最「暴利」的一个,值得贴出原文------注意它甚至不是 !== NoFlags 的比较,而是直接把按位与结果当布尔值用:
js
// packages/react-reconciler/src/ReactFiberCommitWork.js:3080
function recursivelyTraverseLayoutEffects(
root: FiberRoot,
parentFiber: Fiber,
lanes: Lanes,
) {
if (parentFiber.subtreeFlags & LayoutMask) {
let child = parentFiber.child;
while (child !== null) {
const current = child.alternate;
commitLayoutEffectOnFiber(root, current, child, lanes);
child = child.sibling;
}
}
}
注意 3085 行甚至不是 !== NoFlags 的比较,而是直接把按位与的结果当布尔值 用------0 进不了 if,>0 全是真。一个 & 加一个分支预测,就是 React 跳过整棵子树的全部成本。
第 3 个短路点的 | Cloned 也常被忽略:Cloned 位(ReactFiberFlags.js:26)标记「这个 fiber 是 render 阶段 bailout 时克隆出来的」,它的 subtreeFlags 是从旧树拷贝 来的,不能仅凭 Mask 判断子树干净,所以显式把 Cloned 或进去强制遍历。这与 StaticMask(ReactFiberFlags.js:141~151)的设计是一套的:LayoutStatic/PassiveStatic 等静态位在克隆时保留,保证「bailout 掉的子树在真正卸载时仍能被 passive 遍历到」(注释 66~70: "since we can defer traversing the tree during layout to look for Passive effects, and instead rely on the static flag as a signal that there may be cleanup work")。
③ 一次完整的位运算推演
假设某次更新后,根节点的 subtreeFlags 只有一个深层组件打了 useEffect、一个元素换了属性、一个元素换了 ref:即 subtreeFlags = Passive | Update | Ref。
- 数值:
Passive(2048) | Update(4) | Ref(512) = 2564(0xA04)。 - before mutation 短路(3857) :
BeforeMutationMask | MutationMask。注意一个有趣的事实:BeforeMutationMask的两个位(Snapshot、Update)本来就是MutationMask的子集,所以这个|运算在默认 flag 下是恒等的 (0x404 | 0x3636 === 0x3636)。真正让纯 mutation 更新也进入 before 相位的是条件里的MutationMask本身:2564 & 0x3636 = 516(0x204,命中Update与Ref两位)≠ 0 → 进入 before mutation 相位 。before 相位成本极低(只有带Snapshot的 fiber 会真调getSnapshotBeforeUpdate),React 用「多跑一个便宜相位」换更简单的判断。 - mutation 短路(4018) :
2564 & 0x3636 = 516≠ 0 → 进入。 - layout 短路(4107) :
2564 & 0x2244 = 516(Update、Ref命中,Passive、Callback不命中)≠ 0 → 进入。 - passive 调度(3795) :
2564 & 0x2810 = 2048(0x800,只有Passive命中)≠ 0 → 排 passive 回调。
再推演一个「点击一个 memo 住的纯展示组件」的场景:整棵树只有目标的 fiber 上没有任何 commit flag(flags = NoFlags、subtreeFlags = NoFlags)。此时四个判断全部得 0:before/mutation/layout 三个相位一次都不进 ,passive 回调也不排------commitRoot 在 3738~3846 做完 lane 记账后,从 3863 到 3921 一路空转,最后在 4189 requestPaint() 交还主线程。这就是「没有副作用的更新,commit 几乎零成本」的位运算原理。
④ 这套设计的代价与边界
subtreeFlags的维护成本在 render 阶段 :completeWork每返回一层都要把子树的 flags 或上来(第 3 章),这是「用 render 期的一点 OR 运算换 commit 期的整树跳过」的交易。- Mask 是「保守并集」 :3858 的条件写的是
BeforeMutationMask | MutationMask,而BeforeMutationMask的位(Snapshot | Update)本就是MutationMask的子集------这个|在默认 flag 下是恒等的,真正让纯 mutation 更新也进 before 相位的是条件里的MutationMask本身。PassiveTransitionMask并入Update | Placement则真实扩大了 VT 场景的 passive 遍历范围。这些扩张都是「宁愿多走一遍便宜遍历,不可漏掉一个副作用」的保守策略。 - 位只有 31 个 :
ReactFiberFlags.js:42~44的注释直言 "we're about to run out of bits",并用「Mutually exclusive 位复用」(Hydrate = Callback、FormReset = Snapshot等,45~51 行)续命。新增副作用类型时第一位竞争会直接影响 Mask 的设计。
6.4.12 getHostSibling 的指数级回退:原文注释、最坏树形与 React 的取舍
6.4.5 已经贴过 getHostSibling 的源码并解释了「为什么返回 null 就是 append」。本节聚焦三件事:注释原文的完整语义、一个能触发最坏情况的真实树形推演、以及 React 为什么接受这个代价。
① 注释原文
js
// packages/react-reconciler/src/ReactFiberCommitHostEffects.js:271
function getHostSibling(fiber: Fiber): ?Instance {
// We're going to search forward into the tree until we find a sibling host
// node. Unfortunately, if multiple insertions are done in a row we have to
// search past them. This leads to exponential search for the next sibling.
// TODO: Find a more efficient way to do this.
let node: Fiber = fiber;
"if multiple insertions are done in a row we have to search past them"------连续插入 是关键词。getHostSibling 的开销不取决于树有多大,而取决于「待插入节点后面还挨着多少同样待插入的兄弟」。
② 一个最坏情况的树形推演
构造如下 Fiber 树(宿主父 P 下有 5 个全新 的子节点,即常见「列表 key 全变、整体重建」场景;每个子节点是一个最简单的外壳组件,内部只有一个 <span> 宿主叶子):
text
P (HostComponent)
├── A (FunctionComponent, Placement) ── child: spanA
├── B (FunctionComponent, Placement) ── child: spanB
├── C (FunctionComponent, Placement) ── child: spanC
├── D (FunctionComponent, Placement) ── child: spanD
└── E (FunctionComponent, Placement) ── child: spanE
mutation 阶段的遍历顺序(6.4.4 已证明:commitMutationEffectsOnFiber 先递归子节点、再对自己 调 commitReconciliationEffects,ReactFiberCommitWork.js:2110~2111)决定了插入次序是 A → B → C → D → E (自左向右)。逐个推演 getHostSibling:
- 插入 A :
node = A。A.sibling = B ≠ null→node = B。B 不是宿主节点(是 FunctionComponent),进入while (node.tag !== HostComponent && ...)循环:B 带Placement标 →continue siblings。再看B.sibling = C→node = C,同样带 Placement → 跳过。D、E 同理。到 E 时E.sibling === null→ 向上回退:E.return = P,isHostParent(P)为真 → 返回 null (append 到末尾)。此程扫描了 B、C、D、E 共 4 个兄弟。 - 插入 B :对称地扫描 C、D、E 共 3 个。
- 插入 C :扫描 D、E 共 2 个。
- 插入 D :扫描 E 共 1 个。
- 插入 E :
sibling === null、回退到宿主父 → 立即返回 null,扫描 0 个。
总计 4+3+2+1+0 = 10 = N(N-1)/2------平方级 。而且上面还是「理想情况」:每个兄弟都有宿主子节点可钻。如果每个列表项的外壳更深(Item → Wrapper → div),continue siblings 之前还要先 node.child 向下钻一层再判断 Placement;如果兄弟是 HostPortal,走 315 行的 node.tag === HostPortal → continue siblings(Portal 永远不能作参照节点,因为它属于另一棵宿主树)。层层叠加,就是源码用 "exponential" 一词强调的恶化趋势------严格说主体部分是 O(N²),但每跳的常数会随树深、Portal、Singleton 分支增长。
还有两个容易忽略的副作用:
getHostSibling不是只读的 :288 行node.sibling.return = node.return;、318 行node.child.return = node;会顺手修复沿途 fiber 的return指针 。因为 WIP 树上部分节点的return在 commit 时可能是过期的,搜索路径必须边走边修,否则后续遍历会读到错误的父节点。这意味着每次「无功而返」的搜索也留下了指针修复的副产物------搜索本身不完全是无用功。- Placement 位是边插边清的 :
commitReconciliationEffects每插入一个节点就finishedWork.flags &= ~Placement(ReactFiberCommitWork.js:2859)。但这救不了后续兄弟------因为插入顺序自左向右,后面的兄弟向前看时,前面的节点还没轮到插入(顺序上它在后面),清除的只是「已插入」的位。真正能让这个算法退化的,只有 render 阶段少打 Placement 标。
③ 为什么 React 接受这个代价
- TODO 挂了十年量级的优先级 :275 行的
// TODO: Find a more efficient way to do this.至今没有实现。官方态度很明确:在真实工作负载下这个问题几乎不出现。 - key 稳定时算法根本不退化 :render 阶段复用节点(第 4/5 章)意味着
deletions为空、Placement只落在真正的新节点上。此时每个新节点向后扫描遇到的第一个兄弟就是稳定节点,getHostSiblingO(1) 返回。平方级只在「连续整段插入」时出现。 - 修它需要额外的数据结构 :要 O(1) 找到「下一个稳定宿主兄弟」,render 阶段就得为每个 fiber 维护「宿主子节点索引」,或者 commit 阶段维护一个「已插入宿主节点游标」------这两种方案都要在热路径上加内存或加写,代价比这个罕见的最坏情况更高。React 选择了「常见路径零成本、罕见路径平方」。
- 绝对成本仍然有限 :N=1000 的全量 Placement 约 50 万次指针跳转,每次不过是几次属性读取和 tag 比较,实测在毫秒级;它会和 commit 本身的 DOM 操作叠加,但单独看不构成卡顿主因。真正的危险是「N 大 且 每次更新都全量 Placement」的长期模式------那通常意味着 key 用法有 bug。
规避手段(按有效性排序) :① 给列表稳定且语义唯一的 key(数据 id,禁用数组下标);② 避免「整个列表换 key」式重建,做原地更新;③ 超大列表上虚拟化,从根上压小 N;④ 如果必须整体重排,优先依赖 moveBefore 路径(6.7.2)------条目 fiber 被复用时不产生 Placement,走的是已存在节点的移动而非插入。
6.4.13 commitPlacement 的完整分支树(452):宿主父类型 × 插入目标
6.4.5 讲了算法三步,本节把 switch (hostParentFiber.tag) 的每一条分支 和 insertOrAppendPlacementNode 系列的递归终止条件补全。
① 宿主父解析与前置守卫
js
// packages/react-reconciler/src/ReactFiberCommitHostEffects.js:452
function commitPlacement(finishedWork: Fiber): void {
// Recursively insert all host nodes into the parent.
let hostParentFiber;
let parentFiber = finishedWork.return;
while (parentFiber !== null) {
if (isHostParent(parentFiber)) {
hostParentFiber = parentFiber;
break;
}
parentFiber = parentFiber.return;
}
// Fragment ancestry is collected separately so portals remain placement
// parents while fragment bookkeeping still walks past them to ancestors.
const parentFragmentInstances = enableFragmentRefs
? getParentFragmentInstances(finishedWork)
: null;
// $FlowFixMe[constant-condition]
if (!supportsMutation) {
if (enableFragmentRefs) {
commitImmutablePlacementNodeToFragmentInstances(
finishedWork,
parentFragmentInstances,
);
}
return;
}
if (hostParentFiber == null) {
throw new Error(
'Expected to find a host parent. This error is likely caused by a bug ' +
'in React. Please file an issue.',
);
}
三个点:
!supportsMutation早退(470~478) :非变更式渲染器(如 React Native 的 persistence 模式)不做真实插入,只维护 Fragment 实例簿记(commitImmutablePlacementNodeToFragmentInstances,545)。这是 HostConfig 解耦在 Placement 上的直接体现。hostParentFiber == null是 React 内部不变量(480~485):commit 阶段任何被标 Placement 的 fiber,其 return 链上必然存在宿主父。抛错即代表 Fiber 树构造有 bug。parentFragmentInstances(465) :为<Fragment ref>特性收集祖先 Fragment 实例列表,Portal 仍是 placement 父节点,但 fragment 簿记会「穿过」Portal 继续向上找(注释 463~464)。
② 四条宿主父分支
js
// packages/react-reconciler/src/ReactFiberCommitHostEffects.js:487
switch (hostParentFiber.tag) {
case HostSingleton: {
// $FlowFixMe[constant-condition]
if (supportsSingletons) {
const parent: Instance = hostParentFiber.stateNode;
const before = getHostSibling(finishedWork);
// We only have the top Fiber that was inserted but we need to recurse down its
// children to find all the terminal nodes.
insertOrAppendPlacementNode(
finishedWork,
before,
parent,
parentFragmentInstances,
);
break;
}
// Fall through
}
case HostComponent: {
const parent: Instance = hostParentFiber.stateNode;
if (hostParentFiber.flags & ContentReset) {
// Reset the text content of the parent before doing any insertions
resetTextContent(parent);
// Clear ContentReset from the effect tag
hostParentFiber.flags &= ~ContentReset;
}
const before = getHostSibling(finishedWork);
// We only have the top Fiber that was inserted but we need to recurse down its
// children to find all the terminal nodes.
insertOrAppendPlacementNode(
finishedWork,
before,
parent,
parentFragmentInstances,
);
break;
}
case HostRoot:
case HostPortal: {
const parent: Container = hostParentFiber.stateNode.containerInfo;
const before = getHostSibling(finishedWork);
insertOrAppendPlacementNodeIntoContainer(
finishedWork,
before,
parent,
parentFragmentInstances,
);
break;
}
default:
throw new Error(
'Invalid host parent fiber. This error is likely caused by a bug ' +
'in React. Please file an issue.',
);
}
| 宿主父 tag | 插入目标 | 插入函数 | 特殊处理 |
|---|---|---|---|
HostSingleton |
hostParentFiber.stateNode(如 <head>) |
insertOrAppendPlacementNode |
需 supportsSingletons,否则穿透到下一分支 |
HostComponent |
hostParentFiber.stateNode |
insertOrAppendPlacementNode |
ContentReset 前置清理(507~512) |
HostRoot / HostPortal |
hostParentFiber.stateNode.containerInfo |
insertOrAppendPlacementNodeIntoContainer |
容器版 API,内部会再解析 document.body 等 |
| 其他 | --- | --- | 抛 Invalid host parent fiber(537~541) |
ContentReset 分支值得单独说 :当父元素之前只有文本子节点(如 <div>{text}</div> 后来要插入元素子节点),render 阶段会给父打 ContentReset 标。commit 插入前必须 resetTextContent(parent) 把文本清掉,否则 DOM 里会同时残留旧文本和新插入的元素------文本节点会成为「第一个孩子」挡住插入位置。清完立刻 flags &= ~ContentReset(状态机推进,防止同一父被多次插入时重复清理)。
③ insertOrAppendPlacementNode 的递归终止条件(394~450)
js
// packages/react-reconciler/src/ReactFiberCommitHostEffects.js:394
function insertOrAppendPlacementNode(
node: Fiber,
before: ?Instance,
parent: Instance,
parentFragmentInstances: null | Array<FragmentInstanceType>,
): void {
const {tag} = node;
const isHost = tag === HostComponent || tag === HostText;
if (isHost) {
const stateNode = node.stateNode;
if (before) {
insertBefore(parent, stateNode, before);
} else {
appendChild(parent, stateNode);
}
if (enableFragmentRefs) {
commitNewChildToFragmentInstances(node, parentFragmentInstances);
}
trackHostMutation();
return; // 终止条件 ①
} else if (tag === HostPortal) {
// If the insertion itself is a portal, then we don't want to traverse
// down its children. Instead, we'll get insertions from each child in
// the portal directly.
return; // 终止条件 ②
}
// $FlowFixMe[constant-condition]
if (supportsSingletons ? tag === HostSingleton : false) {
if (enableFragmentRefs) {
// The singleton is the fragment child. Its own children are not
// attributed to the fragment instances above it.
commitNewChildToFragmentInstances(node, parentFragmentInstances);
parentFragmentInstances = null;
}
if (isSingletonScope(node.type)) {
// This singleton is the parent of deeper nodes and needs to become
// the parent for child insertions and appends
parent = node.stateNode; // 终止条件 ③:切换 parent
}
}
const child = node.child;
if (child !== null) {
insertOrAppendPlacementNode(child, before, parent, parentFragmentInstances);
let sibling = child.sibling;
while (sibling !== null) {
insertOrAppendPlacementNode(
sibling,
before,
parent,
parentFragmentInstances,
);
sibling = sibling.sibling;
}
}
}
四条路径决定递归走向:
- 命中宿主叶子(HostComponent/HostText) :真正执行
insertBefore/appendChild,trackHostMutation()后return。这是唯一的「干活」出口。 - 命中 HostPortal :直接
return------Portal 的内容不属于当前宿主树 ,Portal 内每个子节点的 Placement 由它们自己的commitPlacement处理(那时它们的宿主父是这个 Portal)。 - 命中 Singleton scope(如
<head>) :parent被切换为该 Singleton 的 stateNode,后续深层节点都插到它里面。 child === null:递归自然终止------该 fiber 下没有任何宿主后代(例如一个 render 出null的组件)。
注意 before 在整棵被插入子树里始终不变 :insertOrAppendPlacementNode(child, before, ...) 和兄弟循环都传同一个 before。原因是同一棵新子树的所有宿主节点在最终 DOM 里必须连续排列 在同一个参照节点之前;用固定 before 反复 insertBefore 即可把它们一个个「挤」到正确位置。
insertOrAppendPlacementNodeIntoContainer(330392)是容器版的孪生函数,结构对称,但有一处细微差别 :进入 Singleton scope 时它除了切 369)------因为容器场景下没有「更后的稳定兄弟」可参照,Singleton 内容只能 append。这种「两个函数 95% 相同、5% 语义分叉」的结构是阅读 React 源码时的常见陷阱,对照阅读时容易看串行。parent,还显式 before = null(368
6.4.14 commitDeletionEffectsOnFiber 逐 tag 分支、offscreenSubtreeWasHidden 守卫与两段式 detach
6.4.6 讲了删除的主干(找宿主父 → 递归卸载 → 一次 removeChild)。本节把 commitDeletionEffectsOnFiber(1490)的每一个 tag 分支 列全,解释三个栈上守卫变量,并回答一个高频疑问:为什么 detachFiberMutation 在 mutation 阶段只切 return,而 detachFiberAfterEffects 要拖到 passive 阶段才清 child/sibling。
① 入口的宿主父解析(1386~1451)
js
// packages/react-reconciler/src/ReactFiberCommitWork.js:1408
// Before starting, find the nearest host parent on the stack so we know
// which instance/container to remove the children from.
// TODO: Instead of searching up the fiber return path on every deletion, we
// can track the nearest host component on the JS stack as we traverse the
// tree during the commit phase. This would make insertions faster, too.
let parent: null | Fiber = returnFiber;
findParent: while (parent !== null) {
switch (parent.tag) {
case HostSingleton: {
// $FlowFixMe[constant-condition]
if (supportsSingletons) {
if (isSingletonScope(parent.type)) {
hostParent = parent.stateNode;
hostParentIsContainer = false;
break findParent;
}
break;
}
// Expected fallthrough when supportsSingletons is false
}
case HostComponent: {
hostParent = parent.stateNode;
hostParentIsContainer = false;
break findParent;
}
case HostRoot:
case HostPortal: {
hostParent = parent.stateNode.containerInfo;
hostParentIsContainer = true;
break findParent;
}
}
parent = parent.return;
}
if (hostParent === null) {
throw new Error(
'Expected to find a host parent. This error is likely caused by ' +
'a bug in React. Please file an issue.',
);
}
commitDeletionEffectsOnFiber(root, returnFiber, deletedFiber);
hostParent = null;
hostParentIsContainer = false;
与 Placement 的 commitPlacement 完全同构:从 returnFiber 向上找第一个宿主父,记下 hostParent 与 hostParentIsContainer(决定最后调 commitHostRemoveChild 还是 commitHostRemoveChildFromContainer)。1410~1412 的 TODO 聊了一个优化设想:与其每次删除都向上搜 return 链,不如在 commit 遍历时把「最近的宿主组件」放在 JS 调用栈上带着走------注释还补一句 "This would make insertions faster, too"( Placement 也要做同样的向上搜索)。
② 逐 tag 分支全表
commitDeletionEffectsOnFiber 的外层 switch(1506)遵循一条设计原则(注释 1503~1505):「修改栈上变量的 case 在前面;不碰栈的简单 case 在里面」。完整清单:
| tag | 行号 | 卸载副作用 | 是否动宿主树 | 备注 |
|---|---|---|---|---|
HostHoistable |
1507 | safelyDetachRef(守卫 !offscreenSubtreeWasHidden);releaseResource / unmountHoistable |
递归子节点后由 HostText 分支收尾 |
资源(memoizedState !== null)走引用计数;实例从 document.head 摘除 |
HostSingleton |
1533 | safelyDetachRef(守卫);commitFragmentInstanceDeletionEffects |
同左 | singleton scope 会临时切换 hostParent(1545commitHostSingletonRelease 提前到 mutation(1555 |
HostComponent |
1569 | safelyDetachRef(守卫) |
是本分支执行 removeChild | 1576 行 // Intentional fallthrough to next branch ------ 故意不掉 break,直接进 HostText |
HostText |
1578 | (Fragment refs 簿记) | 真正的 removeChild 发生地 | hostParent = null 技巧(1591commitHostRemoveChild(FromContainer)(1603 |
DehydratedFragment |
1631 | hydrationCallbacks.onDeleted |
clearSuspenseBoundary(FromContainer) |
1652 注释 "Dehydrated fragments don't have any children"------SSR 脱水边界没有 React 子节点 |
HostPortal |
1673 | 无 | 切换 hostParent 为 portal 的 containerInfo(1679~1680) |
子树的删除发生在 portal 容器里,不是外层父节点 |
FunctionComponent / ForwardRef / MemoComponent / SimpleMemoComponent |
1706 | commitHookEffectListUnmount(HookInsertion) 无条件 ;commitHookLayoutUnmountEffects(HookLayout) 受 !offscreenSubtreeWasHidden 守卫 |
无 | insertion effect 的 destroy 即使在隐藏子树里也要跑(它可能在 DOM 之外有副作用);layout cleanup 在隐藏路径上跳过 |
ClassComponent |
1730 | safelyDetachRef + componentWillUnmount(均受守卫) |
无 | 1730~1741:if (!offscreenSubtreeWasHidden) { safelyDetachRef(...); if (typeof instance.componentWillUnmount === 'function') safelyCallComponentWillUnmount(...); } |
ScopeComponent |
1749 | safelyDetachRef(守卫) |
无 | enableScopeAPI 实验特性 |
OffscreenComponent |
1762 | 无直接副作用 | 无 | 核心是栈变量维护(见 ③) |
ViewTransitionComponent |
1792 | safelyDetachRef(无守卫 )+ DEV 下 untrackNamedViewTransition |
无 | 注意这一支的 detach 不看 offscreenSubtreeWasHidden |
Fragment |
1809 | safelyDetachRef(守卫,enableFragmentRefs) |
无 | Fragment 本身没有 DOM 节点,删除它等于删除它的孩子们 |
default |
1823 | 无 | 无 | 只递归(SuspenseComponent、Profiler、LazyComponent 等都落这里) |
把 HostComponent 的「故意穿透」和 HostText 的「唯一 removeChild」放在一起看,删除算法的全貌才完整:
js
// packages/react-reconciler/src/ReactFiberCommitWork.js:1578
case HostText: {
// ...
// We only need to remove the nearest host child. Set the host parent
// to `null` on the stack to indicate that nested children don't
// need to be removed.
// $FlowFixMe[constant-condition]
if (supportsMutation) {
const prevHostParent = hostParent;
const prevHostParentIsContainer = hostParentIsContainer;
hostParent = null;
recursivelyTraverseDeletionEffects(
finishedRoot,
nearestMountedAncestor,
deletedFiber,
);
hostParent = prevHostParent;
hostParentIsContainer = prevHostParentIsContainer;
if (hostParent !== null) {
// Now that all the child effects have unmounted, we can remove the
// node from the tree.
if (hostParentIsContainer) {
commitHostRemoveChildFromContainer(
deletedFiber,
nearestMountedAncestor,
hostParent as any as Container,
deletedFiber.stateNode as Instance | TextInstance,
);
} else {
commitHostRemoveChild(
deletedFiber,
nearestMountedAncestor,
hostParent as any as Instance,
deletedFiber.stateNode as Instance | TextInstance,
);
}
}
} else {
1604 的注释 "Now that all the child effects have unmounted, we can remove the node from the tree." 是整个删除算法的题眼:先卸完所有副作用,最后才动一刀 DOM 。hostParent = null 让子树里无论嵌套多少层 HostComponent 都不会触发 removeChild------因为每个嵌套分支的 commitHostRemoveChild 都被 if (hostParent !== null) 挡着(HostComponent 分支爬到 HostText 也是同理,只有最外层持有真实 hostParent)。
③ offscreenSubtreeWasHidden 与两个兄弟变量
删除路径上有三个栈上布尔量在 OffscreenComponent 分支被维护:
js
// packages/react-reconciler/src/ReactFiberCommitWork.js:1762
case OffscreenComponent: {
if (disableLegacyMode || deletedFiber.mode & ConcurrentMode) {
// If this offscreen component is hidden, we already unmounted it. Before
// deleting the children, track that it's already unmounted so that we
// don't attempt to unmount the effects again.
// TODO: If the tree is hidden, in most cases we should be able to skip
// over the nested children entirely. An exception is we haven't yet found
// the topmost host node to delete, which we already track on the stack.
// But the other case is portals, which need to be detached no matter how
// deeply they are nested. We should use a subtree flag to track whether a
// subtree includes a nested portal.
const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
offscreenSubtreeWasHidden =
prevOffscreenSubtreeWasHidden || deletedFiber.memoizedState !== null;
recursivelyTraverseDeletionEffects(
finishedRoot,
nearestMountedAncestor,
deletedFiber,
);
offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden;
} else {
三者的语义必须分清(mutation 相位 2576~2586 用同样的模式维护它们):
| 变量 | 含义 | 设置处 |
|---|---|---|
offscreenSubtreeIsHidden |
当前子树在新树里是隐藏的 | commitMutationEffectsOnFiber Offscreen 分支 2579 |
offscreenSubtreeWasHidden |
当前子树在本次 commit 之前就是隐藏的 | 2582(mutation)/ 1774(deletion)/ 798(layout) |
offscreenDirectParentIsHidden |
直接父级处于隐藏状态 | 2580 |
WasHidden 的语义是「这批副作用已经卸载过了,别再卸一遍」 。一棵树被 Offscreen 隐藏时,它的 layout effect 已经被 disappearLayoutEffects 卸载(见 6.4.19);如果这棵树随后被删除 ,删除遍历会把 offscreenSubtreeWasHidden 置真,于是 safelyDetachRef、componentWillUnmount、commitHookLayoutUnmountEffects 全部被守卫挡下------卸载一次,绝不两次。这就是 6.4.8 说「隐藏路径上跳过 detach」的完整机制:不是「不忍心拆 ref」,而是「这些 ref/生命周期属于已经被 disappear 处理过的子树,重复调用会造成 user effect 双跑」。
HostPortal 分支(16731705)则是这条规则的必要补充:Portal 后面永远不设 1772 TODO 抱怨的场景:"portals... need to be detached no matter how deeply they are nested"。offscreenSubtreeWasHidden,且无论如何都要把 hostParent 切成 portal 的 containerInfo------因为 Portal 属于另一棵宿主树,外层怎么隐藏都掩盖不到它,它的内容必须显式从自己的容器里摘掉。这正是 1770
④ detachFiberMutation vs detachFiberAfterEffects:为什么 cleanup 分两段
js
// packages/react-reconciler/src/ReactFiberCommitWork.js:1308
function detachFiberMutation(fiber: Fiber) {
// Cut off the return pointer to disconnect it from the tree.
// This enables us to detect and warn against state updates on an unmounted component.
// It also prevents events from bubbling from within disconnected components.
//
// Ideally, we should also clear the child pointer of the parent alternate to let this
// get GC:ed but we don't know which for sure which parent is the current
// one so we'll settle for GC:ing the subtree of this child.
// This child itself will be GC:ed when the parent updates the next time.
//
// Note that we can't clear child or sibling pointers yet.
// They're needed for passive effects and for findDOMNode.
// We defer those fields, and all other cleanup, to the passive phase (see detachFiberAfterEffects).
//
// Don't reset the alternate yet, either. We need that so we can detach the
// alternate's fields in the passive phase. Clearing the return pointer is
// sufficient for findDOMNode semantics.
const alternate = fiber.alternate;
if (alternate !== null) {
alternate.return = null;
}
fiber.return = null;
}
detachFiberMutation(mutation 阶段末尾 1474 调用)只切 return 指针(fiber 自己和 alternate 各一次)。注释给出三条不能多清的理由:
child/sibling还要用 :"They're needed for passive effects and for findDOMNode."------passive 阶段(见下)要遍历被删子树跑useEffect的 destroy,没有child/sibling就下不去。alternate不能动:"We need that so we can detach the alternate's fields in the passive phase."------双缓冲的两个 fiber 都要在 passive 阶段被清理,mutually 引用着。- 切
return的三重收益 :检测「给已卸载组件 setState」并告警;阻止已断开组件里的事件继续冒泡;切断被删子树与父树的循环引用,让子树整体可被 GC(父树下次更新时连deletions数组一起释放)。
真正的深度清理在 passive 阶段,调用链非常长,值得完整走一遍:
detachFiberAfterEffects(13321377)清掉的字段清单: 1337)、alternate 链接(并递归清理 alternate 自己,1335child、deletions、sibling、stateNode(HostComponent 会先 detachDeletedInstance(hostInstance) 砍断 DOM↔fiber 的循环,13531359)、 1344 还解释了设计哲学:未来 Fiber 重构后 _debugOwner(DEV)、return、dependencies、memoizedProps、memoizedState、pendingProps、updateQueue。注释 1339setState 会绑定到独立的「实例对象」而不是 Fiber,这些循环字段天然不存在------「如果这一层清理就够解决内存问题,那对未来的计划是个好兆头」。
为什么必须拖到 passive? 三个硬约束:① useEffect 的 destroy 属于用户代码,只能在 passive 相位跑(第 7 章);② 遍历被删子树需要 child/sibling;③ commitPassiveUnmountEffectsInsideOfDeletedTree_begin(5347)用的是 nextEffect 链表而不是递归(5351~5368),并且注释 5354 明确 "Deletion effects fire in parent -> child order"------父先子后,与 unmount 语义一致。注意 5076 的 parentFiber.flags & ChildDeletion 判断和 5105 的 detachAlternateSiblings:前者决定要不要走被删树遍历,后者把父 fiber alternate 上的 deletions 数组本身拆掉,防止 alternate 继续持有一整棵已死子树的引用。
本文为本章上篇,下篇见《第06章_ReactDOM渲染器与Commit阶段(下)》。
上篇源码位置速查(覆盖 6.1 ~ 6.4.14)
| 主题 | 文件 | 关键位置 |
|---|---|---|
| commit 入口 | react-reconciler/src/ReactFiberWorkLoop.js |
commitRoot(:3725);Flush 与 root.current 切换(:4009 / :4055 / :4051) |
| Before Mutation | react-reconciler/src/ReactFiberCommitEffects.js |
commitBeforeMutationEffects(:352);全文 1 045 行 |
| Mutation | react-reconciler/src/ReactFiberCommitWork.js |
commitMutationEffects(:2027)、commitMutationEffectsOnFiber(:2072);全文 5 618 行 |
| Placement 插入 | 同上 | commitPlacement(:452) |
| Deletion | 同上 | commitDeletionEffects(:1386) |
| Layout | react-reconciler/src/ReactFiberCommitEffects.js |
commitLayoutEffects(:3063)、commitLayoutEffectOnFiber(:600) |
| Ref attach/detach | 同上 | ref 的 commit 时机与 cleanup |
| Host 副作用原语 | react-reconciler/src/ReactFiberCommitHostEffects.js |
commitHostMount / commitHostUpdate / commitHostHide |
| Flags 与 Mask 定义 | react-reconciler/src/ReactFiberFlags.js |
MutationMask / LayoutMask / BeforeMutationMask / PassiveMask |
| HostConfig 实现(DOM) | react-dom-bindings/src/client/ReactFiberConfigDOM.js |
全文 7 027 行,渲染器契约的具体落地 |
| DOM 属性 diff | react-dom-bindings/src/client/ReactDOMComponent.js |
updateProperties、setProp |
上篇小结
-
Commit 之所以必须分成三个子阶段,是因为有些 DOM 操作必须在"读"之前、有些必须在"写"之后 :Before Mutation 用来在 DOM 变更前 快照(如
getSnapshotBeforeUpdate);Mutation 做真实增删改;Layout 在 DOM 已更新但浏览器尚未绘制时同步运行(useLayoutEffect、ref attach)。漏掉任何一阶段,某些浏览器 API 就取不到正确的值。 -
遍历方式从 effect list 改成了递归 +
subtreeFlags短路 :nextEffect不再是一条全局链表,而是靠每个 fiber 的subtreeFlags判断"这棵子树有没有我要的 flag"。这让并发渲染下的中断/恢复变得安全------不再有一条需要跨渲染维护的链表状态。 -
Placement 不总是"插到末尾" :
commitPlacement要区分宿主父节点类型、是否有ContentReset、是否需要moveBefore,最坏情况下getHostSibling会指数级回退(本篇 6.4.12 已给出最坏树形与 React 的取舍)。 -
Deletion 不逐个
removeChild:它一次性找到子树最外层的宿主节点批量摘除,但必须逐个向下遍历以执行 unmount 与 ref detach,这是"删得快"与"语义正确"的折中。 -
Mask 是"保守并集" :
BeforeMutationMask | MutationMask这类写法意味着一个阶段会顺带处理相邻阶段的 flag,代价是多遍历一点,收益是少写一套分支。
下篇 接着讲 6.5 起的内容:与其他模块的交互、边界场景与坑、性能影响、最佳实践,以及两段式 detach 与 commitMount 这两个容易被忽略的收尾细节。