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):
sequenceDiagram
participant W as commitRoot(3725)
participant BM as Before Mutation
participant MU as Mutation
participant RC as root.current 切换(4051)
participant LA as Layout
participant PA as Passive(异步)
Note over W: 调度 passive effect 回调(3806)
W->>BM: commitBeforeMutationEffects(3874)
BM->>BM: getSnapshotBeforeUpdate 等价物 / clearContainer / focus-blur 守卫
W->>MU: flushMutationEffects()(3917)
MU->>MU: Placement / Update / ChildDeletion / Ref detach
MU->>MU: resetAfterCommit(root.containerInfo)(4038)
Note over RC: 「必须」在 mutation 之后、layout 之前
MU->>RC: root.current = finishedWork(4051)
RC->>LA: flushLayoutEffects()(3918)
LA->>LA: Ref attach / useLayoutEffect / componentDidMount-Update
LA->>PA: flushSpawnedWork()(3920)
Note over PA: passive(useEffect) 已在 3806 异步排队
下面这张流程图(flowchart)给出 commitRoot → 三个 flush 的内部状态机:
flowchart TD
A[commitRoot 3725] --> B{有 BeforeMutation/Mutation 标记? 3857}
B -- 是 --> C[commitBeforeMutationEffects 3874]
B -- 否 --> D
C --> D{pendingEffectsStatus 状态}
D --> E[flushMutationEffects 4009]
E --> F[commitMutationEffects 4031]
F --> G[resetAfterCommit 4038]
G --> H[root.current = finishedWork 4051]
H --> I[flushLayoutEffects 4055]
I --> J[commitLayoutEffects 3918/3063]
J --> K[flushSpawnedWork 3920]
K --> L[flushPassiveEffects 异步 3815]
记住这两条主线的区别:
- DOM 的「形状」变化(增删改节点)发生在 Mutation 阶段。
- DOM 的「意义」变化(ref 指向、布局副作用、可读到新 DOM 的生命周期)发生在 Layout 阶段,且此时
root.current已是新树。
6.3 核心数据结构与契约
在讲算法之前,先把本章反复出现的「数据」讲清楚。
6.3.1 Flags 与 subtreeFlags(衔接第 3 章)
第 3 章讲过,finishedWork.flags 记录当前 Fiber 自身需要执行的副作用,finishedWork.subtreeFlags 记录其子树里是否有需要执行的副作用。Commit 阶段避免全树遍历,就是靠这两个字段做「短路」:
// 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 末尾:
// 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 不是单纯「做某事」,它先做一堆与副作用执行无关但必须在此刻完成的收尾工作,再驱动三子阶段。我们按出现顺序拆解。
// 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 行给出本章标题所依据的那段权威注释:
// 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):
// 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 后两个阶段:
// 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 切换的精确位置:
// 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 切换等场景清空旧内容)。
入口:
// 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 分派,我们只看与本主题相关的几支:
// 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 后递归:
// 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」的顺序:
// 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)
// 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 行):
// 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:
// 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 的关键设计在这里:
// 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)的骨架:
// 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)
// 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)
// 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 向上回溯)
// 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;
}
// ...
// 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 算法里最巧妙也最贵的一步。我们需要知道「新节点应该插在它后面的那个已有宿主节点之前」。
// 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):
// 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 节点:
// 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。
// 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 这一支:
// 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 都正确的阶段。
// 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 分派,我们看最关键的几支:
// 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)
// 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)
// 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 串起来,得到一次更新的完整副作用流水线:
flowchart LR
subgraph BM[Before Mutation / 读旧值]
A1[getSnapshotBeforeUpdate] --> A2[clearContainer]
end
subgraph MU[Mutation / 改 DOM + detach]
B1[Deletion 先删] --> B2[Placement 插入] --> B3[Update 属性] --> B4[Ref detach] --> B5[resetAfterCommit]
end
SW[root.current = finishedWork 4051]
subgraph LA[Layout / 读新值 + attach]
C1[useLayoutEffect] --> C2[componentDidMount-Update] --> C3[Ref attach] --> C4[commitMount autoFocus-img]
end
subgraph PA[Passive / 异步]
D1[useEffect]
end
BM --> MU --> SW --> LA --> PA
时序铁律(务必记住):
- 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)账本:
// 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)
// 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)
// 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 已引用过;这里强调执行时机的三个语义开关(38643869):ReactSharedInternals.T = null(禁止在 commit 期间读到transition,否则 effect 可能读到「未来的 lane 状态」)、setCurrentUpdatePriority(DiscreteEventPriority)(commit 期间产生的更新按最高优先级的离散事件处理)、executionContext |= CommitContext(进入 Commit 上下文,flushPassiveEffectsImpl 4749 行会靠这个上下文拒绝「在 render/commit 中递归 flush passive」的非法调用)。
⑤ 阶段四:状态机推进与两条执行路径(3894~3921)
// 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 路径(38993914)把五个 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 的完整状态机(每条边都是真实代码行):
stateDiagram-v2
[*] --> NO_PENDING_EFFECTS
NO_PENDING_EFFECTS --> PENDING_MUTATION_PHASE: commitRoot 设置(3894)
PENDING_MUTATION_PHASE --> PENDING_LAYOUT_PHASE: flushMutationEffects 末尾(4052)<br/>root.current = finishedWork(4051)
PENDING_LAYOUT_PHASE --> PENDING_AFTER_MUTATION_PHASE: flushLayoutEffects 末尾(4151)
PENDING_AFTER_MUTATION_PHASE --> PENDING_SPAWNED_WORK: flushAfterMutationEffects(4006) 仅VT路径
PENDING_AFTER_MUTATION_PHASE --> NO_PENDING_EFFECTS: flushSpawnedWork(4182) 非VT路径直接收尾
PENDING_SPAWNED_WORK --> PENDING_PASSIVE_PHASE: flushSpawnedWork(4209) 有passive
PENDING_SPAWNED_WORK --> NO_PENDING_EFFECTS: flushSpawnedWork(4211) 无passive
PENDING_PASSIVE_PHASE --> NO_PENDING_EFFECTS: flushPassiveEffectsImpl(4735)
⑥ 阶段五~六:两个已讲过的 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 的最后收尾段,做了四件对理解渲染时机至关重要的事:
// 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):
// 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)
// 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)
// 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 模式兼容性的关键一环。
把 ①~⑨ 串起来,一次点击触发的完整时间线如下:
sequenceDiagram
participant EV as 事件回调
participant RL as WorkLoop
participant BR as 浏览器
participant SC as Scheduler
EV->>RL: setState → scheduleUpdateOnFiber
RL->>RL: render 阶段(可中断, 第4/5章)
RL->>RL: commitRoot(3725) lane账本+passive排队
RL->>RL: commitBeforeMutationEffects
RL->>RL: flushMutationEffects<br/>Deletion→Placement→Update→Ref detach
RL->>RL: resetAfterCommit + root.current切换(4051)
RL->>RL: flushLayoutEffects<br/>useLayoutEffect/cDM/Ref attach
RL->>RL: flushSpawnedWork<br/>requestPaint(4189)+onCommitRootDevTools(4249)
RL->>SC: scheduleCallback(NormalSchedulerPriority)
RL->>BR: 交还主线程(可 paint)
BR-->>SC: 下一个 macrotask/空闲
SC->>RL: flushPassiveEffects
RL->>RL: commitPassiveUnmount→commitPassiveMount<br/>flushSyncWorkOnAllRoots
6.4.11 MutationMask / LayoutMask / BeforeMutationMask / PassiveMask 的位运算短路
6.3.1 说过「commit 靠 subtreeFlags & Mask 跳过整棵无副作用子树」,本节把四个 Mask 的真实定义、八个真实短路点、以及一次位运算推演全部展开。
① 四个 Mask 的真实定义
// 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 | 0x404 |
MutationMask | Placement | Update | ChildDeletion | ContentReset | Ref | Hydrating | Visibility | FormReset(FormReset 复用 Snapshot 位) | 13878 | 0x3636 |
LayoutMask | Update | Callback | Ref | Visibility | 8772 | 0x2244 |
PassiveMask | Passive | Visibility | ChildDeletion | 10256 | 0x2810 |
PassiveTransitionMask | PassiveMask | Update | Placement | 10262 | 0x2816 |
BeforeAndAfterMutationTransitionMask | Snapshot | Update | Placement | ChildDeletion | Visibility | ContentReset | 9270 | 0x2436 |
几个值得记住的结构性事实:
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 | 整棵树连 before mutation 相位都不进 |
| 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)) | mutation 递归入口裁子树 |
| 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 的比较,而是直接把按位与结果当布尔值用:
// 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 几乎零成本」的位运算原理。
flowchart TD
A[finishedWork.subtreeFlags] --> B{"& (BeforeMutationMask|MutationMask)<br/>3857"}
B -- 0 --> C[跳过 before mutation 相位]
B -- 非0 --> D[commitBeforeMutationEffects]
D --> E{"subtreeFlags & subtreeMask<br/>逐层下钻 449"}
E -- 0 --> F[本层不停留, 不递归子节点]
E -- 非0 --> G[递归 child]
G --> H{"subtreeFlags & MutationMask<br/>453"}
H -- 0 --> I[不递归子节点]
H -- 非0 --> J[commitMutationEffectsOnFiber 每个子节点]
J --> K{"本层 flags & Mask"}
K -- 命中 --> L[执行对应副作用]
K -- 未命中 --> M[仅继续遍历]
L --> N{"subtreeFlags & LayoutMask<br/>3085"}
M --> N
N -- 0 --> O[整棵子树跳过 layout]
N -- 非0 --> P[commitLayoutEffectOnFiber 每个子节点]
④ 这套设计的代价与边界
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 为什么接受这个代价。
① 注释原文
// 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> 宿主叶子):
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 系列的递归终止条件补全。
① 宿主父解析与前置守卫
// 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)。
② 四条宿主父分支
// 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)
// 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
flowchart TD
A[commitPlacement 452] --> B[向上找 isHostParent 宿主父]
B --> C{"hostParentFiber.tag"}
C -- HostSingleton --> D[parent = stateNode<br/>insertOrAppendPlacementNode]
C -- HostComponent --> E["flags & ContentReset?"]
E -- 是 --> F[resetTextContent 清父文本]
F --> G[getHostSibling 找 before]
E -- 否 --> G
G --> D
C -- HostRoot / HostPortal --> H["parent = stateNode.containerInfo<br/>insertOrAppendPlacementNodeIntoContainer"]
C -- 其他 --> I[throw Invalid host parent fiber]
D --> J["node 是 HostComponent/HostText?"]
J -- 是 --> K[insertBefore / appendChild + return]
J -- 否 --> L{"node 是 HostPortal?"}
L -- 是 --> M[return, 子节点各自处理]
L -- 否 --> N{"node 是 Singleton scope?"}
N -- 是 --> O["parent = node.stateNode<br/>(容器版 before = null)"]
N -- 否 --> P{"node.child !== null?"]
O --> P
P -- 是 --> Q[递归 child 与全部 sibling<br/>before 不变]
P -- 否 --> R[自然终止]
6.4.14 commitDeletionEffectsOnFiber 逐 tag 分支、offscreenSubtreeWasHidden 守卫与两段式 detach
6.4.6 讲了删除的主干(找宿主父 → 递归卸载 → 一次 removeChild)。本节把 commitDeletionEffectsOnFiber(1490)的每一个 tag 分支列全,解释三个栈上守卫变量,并回答一个高频疑问:为什么 detachFiberMutation 在 mutation 阶段只切 return,而 detachFiberAfterEffects 要拖到 passive 阶段才清 child/sibling。
① 入口的宿主父解析(1386~1451)
// 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」放在一起看,删除算法的全貌才完整:
// 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 分支被维护:
// 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 分两段
// 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 阶段,调用链非常长,值得完整走一遍:
flowchart TD
A[flushPassiveEffectsImpl 4788<br/>commitPassiveUnmountEffects root.current] --> B[commitPassiveUnmountOnFiber 5118]
B --> C[recursivelyTraversePassiveUnmountEffects 5071]
C --> D{"parentFiber.flags & ChildDeletion<br/>5076"}
D -- 是 --> E["对 deletions 里每个 childToDelete:<br/>commitPassiveUnmountEffectsInsideOfDeletedTree_begin 5083"]
E --> F[commitPassiveUnmountInsideDeletedTreeOnFiber 5398<br/>父→子顺序跑 useEffect destroy]
F --> G["commitPassiveUnmountEffectsInsideOfDeletedTree_complete 5371<br/>nextEffect 链表遍历"]
G --> H[detachFiberAfterEffects 5382 → 1332]
H --> I["fiber.child/deletions/sibling = null<br/>stateNode = null(HostComponent 额外 detachDeletedInstance)<br/>memoizedProps/State、dependencies、updateQueue 全清"]
C --> J{"parentFiber.subtreeFlags & PassiveMask<br/>5109"}
J -- 非0 --> K[递归子节点继续 unmount]
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 这两个容易被忽略的收尾细节。