React 19.3.1 源码深度剖析-第 6 章 React DOM 渲染器与 Commit 阶段(把 Fiber 副作用落到真实 DOM)(上)

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 把整个过程切成两块:

  1. Reconciler 阶段(render / 第 5 章):可中断、可恢复、可丢弃(因为没碰 DOM)。产出一棵带 Flags 的 finishedWork 树。
  2. 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 阶段避免全树遍历,就是靠这两个字段做「短路」:

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
}

这一段是「三阶段为什么这样划分」的源码级铁证,逐字拆解:

  1. commitMutationEffects 在 4031 行先跑------此时 DOM 正在被增删改,旧树仍是 root.current。
  2. resetAfterCommit(4038)是渲染器在「所有 mutation 完成后、layout 前」的钩子(比如 React DOM 在这里重置一些容器级状态)。
  3. 4047~4051 的注释明确说 :root.current = finishedWork 必须放在 mutation 之后 (否则 componentWillUnmount 会看到新 DOM 而非旧 DOM),且必须放在 layout 之前 (否则 componentDidMount/Update 里读到的仍是旧树)。
  4. 4052 把状态推进到 PENDING_LAYOUT_PHASE,下一个 flush 才会跑 layout。

这就是为什么 componentWillUnmount 永远看不到新 DOM,而 componentDidMount 永远看得到新 DOM ------全靠 4047~4051 这一行赋值的位置。这是 React 生命周期时序正确性的基石。root.current 切换时机错误,会直接破坏成千上万依赖此约定的业务代码。

6.4.3 Before Mutation 阶段:commitBeforeMutationEffects(352)

这个相位做三件事:

  1. prepareForCommit 取当前聚焦的实例句柄(focusedInstanceHandle),供 blur 守卫用。
  2. 遍历整棵树,处理 Snapshot 类 Flag(本质是 getSnapshotBeforeUpdate 的等价物)。
  3. 处理根容器的 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?两个原因:

  1. 事件不经过 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."
  2. 因此 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 参照
    }
  }
}

逐段拆解:

  1. 从「待插入节点自身」开始,先横向找 sibling。
  2. 若 sibling 为 null,则向上回退到 return(只要没到宿主父/根),继续找更外层的兄弟。
  3. 找到了一个兄弟 fiber,若它不是宿主节点(HostComponent/HostText/DehydratedFragment),就向下钻进它的子节点去「找第一个宿主叶子」。
  4. 关键判据 (323 行):if (!(node.flags & Placement)) ------ 只有当这个宿主兄弟本身不是 新插入(没有 Placement 标)时,它才「已经在 DOM 里有稳定位置」,可以作 before 参照。
  5. 如果兄弟也是 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 删除算法最聪明的地方:

  1. hostParent = null 是关键技巧。在递归卸载子树(recursivelyTraverseDeletionEffects)之前,把栈上的 hostParent 临时置为 null。这样,子树里无论有多少层嵌套的 HostComponent,都不会再触发任何 removeChild ------因为 commitHostRemoveChild 只有在 hostParent !== null 时才执行(见 1603 的 if (hostParent !== null))。

  2. 递归完成后,只对外层那个被删的 deletedFiber.stateNode 调用一次 commitHostRemoveChild 。由于浏览器 DOM 是树形结构 ,删掉一个父节点,它下面的所有子孙节点会由浏览器级联回收(cascade回收)------根本不需要手动逐个删孩子。

  3. 这样做的好处:

    • 性能 :删 N 层子树只调 1 次 removeChild,而非 N 次。
    • 正确性 :unmount 副作用(ref detach、componentWillUnmount、layout effect cleanup)已经在 recursivelyTraverseDeletionEffects 里从外到内递归执行完毕;等到真正删 DOM 时,只剩纯结构删除,没有任何副作用会再被错误触发。
  4. 最后 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
    }
  }
}

关键设计:

  1. 函数式 ref 的返回值即 cleanup :finishedWork.refCleanup = ref(instanceToUse)(803)。之后当这个 ref 被 detach 时,safelyDetachRef 会调用这个 refCleanup(见下)。这是 React 19 新增的 ref cleanup 函数机制,等价于 useEffect 返回 cleanup。
  2. 对象式 ref 直接赋 ref.current(821)。注意 DEV 下对字符串 ref 报错("String refs are no longer supported.")和对无 current 属性的对象 ref 报错。
  3. 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 串起来,得到一次更新的完整副作用流水线:

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

时序铁律(务必记住):

  1. Deletion < Placement < Update < Ref-detach(均在 Mutation 内,root.current 仍是旧树)。
  2. root.current = finishedWork(4051)夹在 Mutation 与 Layout 之间。
  3. Ref-attach 在 Layout 内(root.current 已是新树)。
  4. 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;
  }

三个要点:

  1. passiveSubtreeMask 在 View Transition 场景下换成 PassiveTransitionMask (3782)。PassiveTransitionMask = PassiveMask | Update | Placement(ReactFiberFlags.js:136)------View Transition 需要把「仅仅更新过」或「新插入」的节点也纳入 passive 遍历,因为要在动画结束后恢复这些节点的 view-transition-name。这是同一个 Mask 随相位语义扩张的典型例子。
  2. 调度条件是三路 OR (3790~3797):Profiler 渲染过(actualDuration !== 0,即使没有 passive flag 也要进去记性能数据)、subtreeFlags 命中、flags 命中。
  3. 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 的完整状态机(每条边都是真实代码行):

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 指示器」(root.pendingIndicator)且所有相关车道都已提交完(root.indicatorLanes === NoLanes), cleanup 函数在 layout 阶段以 DiscreteEventPriority 同步执行。注释 4084 4086 解释了这个选择的理由:「we do this in the layout phase so it has the same semantics as if you did it with a useLayoutEffect or if it was reset automatically with useOptimistic」。
  • 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();
  1. requestPaint()(4189):显式告诉 Scheduler「这一帧到此为止,让浏览器去 paint」。这是 React 把主线程交还给浏览器的确切位置------Mutation 阶段改好的 DOM 直到这里之后才可能被绘制。想看「React 什么时候让出主线程」,答案就是这一行。
  2. passive 相位决策 (4197~4221):用和 ② 相同的 passiveSubtreeMask 再判一次(注意要重算,因为 effect 里可能改过状态),有 passive 就 PENDING_PASSIVE_PHASE,没有就 NO_PENDING_EFFECTS 并立刻 releaseRootPooledCache(4216)。
  3. DevTools 通知 (4249):onCommitRootDevTools(finishedWork.stateNode, renderPriority)------把 FiberRoot 和本次 render 的优先级递给 DevTools 后端,React DevTools 的 commit 高亮、组件更新计数都从这里拿数据。4257 的 onCommitRootTestSelector() 是测试选择器的孪生钩子。4261~4288 则把 render 期间累计的 recoverableErrors(如 hydration mismatch)统一回调给 root.onRecoverableError。
  4. 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 解释了一个常见现象:同样写 useEffect,在离散事件(click)里触发时它几乎同步执行,而在 transition 里触发时要等 paint 之后 ------差别就在这个 includesSyncLane 分支。4342 4385 是无限更新循环检测:如果本次 render/commit 又排了 sync 更新(didIncludeRenderPhaseUpdate || 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 模式兼容性的关键一环。

把 ①~⑨ 串起来,一次点击触发的完整时间线如下:

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 的真实定义
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 几乎零成本」的位运算原理。

flowchart TD A[finishedWork.subtreeFlags] --> B{&#34;& (BeforeMutationMask|MutationMask)<br/>3857&#34;} B -- 0 --> C[跳过 before mutation 相位] B -- 非0 --> D[commitBeforeMutationEffects] D --> E{&#34;subtreeFlags & subtreeMask<br/>逐层下钻 449&#34;} E -- 0 --> F[本层不停留, 不递归子节点] E -- 非0 --> G[递归 child] G --> H{&#34;subtreeFlags & MutationMask<br/>453&#34;} H -- 0 --> I[不递归子节点] H -- 非0 --> J[commitMutationEffectsOnFiber 每个子节点] J --> K{&#34;本层 flags & Mask&#34;} K -- 命中 --> L[执行对应副作用] K -- 未命中 --> M[仅继续遍历] L --> N{&#34;subtreeFlags & LayoutMask<br/>3085&#34;} 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 为什么接受这个代价。

① 注释原文
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 分支增长。

还有两个容易忽略的副作用:

  1. getHostSibling 不是只读的 :288 行 node.sibling.return = node.return;、318 行 node.child.return = node; 会顺手修复沿途 fiber 的 return 指针 。因为 WIP 树上部分节点的 return 在 commit 时可能是过期的,搜索路径必须边走边修,否则后续遍历会读到错误的父节点。这意味着每次「无功而返」的搜索也留下了指针修复的副产物------搜索本身不完全是无用功。
  2. 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 只落在真正的新节点上。此时每个新节点向后扫描遇到的第一个兄弟就是稳定节点,getHostSibling O(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;
    }
  }
}

四条路径决定递归走向:

  1. 命中宿主叶子(HostComponent/HostText) :真正执行 insertBefore/appendChild,trackHostMutation() 后 return。这是唯一的「干活」出口。
  2. 命中 HostPortal :直接 return------Portal 的内容不属于当前宿主树 ,Portal 内每个子节点的 Placement 由它们自己的 commitPlacement 处理(那时它们的宿主父是这个 Portal)。
  3. 命中 Singleton scope(如 <head>) :parent 被切换为该 Singleton 的 stateNode,后续深层节点都插到它里面。
  4. child === null :递归自然终止------该 fiber 下没有任何宿主后代(例如一个 render 出 null 的组件)。

注意 before 在整棵被插入子树里始终不变 :insertOrAppendPlacementNode(child, before, ...) 和兄弟循环都传同一个 before。原因是同一棵新子树的所有宿主节点在最终 DOM 里必须连续排列 在同一个参照节点之前;用固定 before 反复 insertBefore 即可把它们一个个「挤」到正确位置。

insertOrAppendPlacementNodeIntoContainer(330392)是容器版的孪生函数,结构对称,但有一处细微差别 :进入 Singleton scope 时它除了切 parent,还显式 before = null(368369)------因为容器场景下没有「更后的稳定兄弟」可参照,Singleton 内容只能 append。这种「两个函数 95% 相同、5% 语义分叉」的结构是阅读 React 源码时的常见陷阱,对照阅读时容易看串行。

flowchart TD A[commitPlacement 452] --> B[向上找 isHostParent 宿主父] B --> C{&#34;hostParentFiber.tag&#34;} C -- HostSingleton --> D[parent = stateNode<br/>insertOrAppendPlacementNode] C -- HostComponent --> E[&#34;flags & ContentReset?&#34;] E -- 是 --> F[resetTextContent 清父文本] F --> G[getHostSibling 找 before] E -- 否 --> G G --> D C -- HostRoot / HostPortal --> H[&#34;parent = stateNode.containerInfo<br/>insertOrAppendPlacementNodeIntoContainer&#34;] C -- 其他 --> I[throw Invalid host parent fiber] D --> J[&#34;node 是 HostComponent/HostText?&#34;] J -- 是 --> K[insertBefore / appendChild + return] J -- 否 --> L{&#34;node 是 HostPortal?&#34;} L -- 是 --> M[return, 子节点各自处理] L -- 否 --> N{&#34;node 是 Singleton scope?&#34;} N -- 是 --> O[&#34;parent = node.stateNode<br/>(容器版 before = null)&#34;] N -- 否 --> P{&#34;node.child !== null?&#34;] 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)
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(15451548);commitHostSingletonRelease 提前到 mutation(15551560 注释解释:为避免「先删同 key 的 singleton 再插入新的」时 acquire 冲突)
HostComponent 1569 safelyDetachRef(守卫) 是本分支执行 removeChild 1576 行 // Intentional fallthrough to next branch ------ 故意不掉 break,直接进 HostText
HostText 1578 (Fragment refs 簿记) 真正的 removeChild 发生地 hostParent = null 技巧(15911601)+ 唯一一次 commitHostRemoveChild(FromContainer)(16031621)
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 后面永远不设 offscreenSubtreeWasHidden,且无论如何都要把 hostParent 切成 portal 的 containerInfo------因为 Portal 属于另一棵宿主树,外层怎么隐藏都掩盖不到它,它的内容必须显式从自己的容器里摘掉。这正是 17701772 TODO 抱怨的场景:"portals... need to be detached no matter how deeply they are nested"。

④ 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 各一次)。注释给出三条不能多清的理由:

  1. child/sibling 还要用 :"They're needed for passive effects and for findDOMNode."------passive 阶段(见下)要遍历被删子树跑 useEffect 的 destroy,没有 child/sibling 就下不去。
  2. alternate 不能动:"We need that so we can detach the alternate's fields in the passive phase."------双缓冲的两个 fiber 都要在 passive 阶段被清理,mutually 引用着。
  3. 切 return 的三重收益 :检测「给已卸载组件 setState」并告警;阻止已断开组件里的事件继续冒泡;切断被删子树与父树的循环引用,让子树整体可被 GC(父树下次更新时连 deletions 数组一起释放)。

真正的深度清理在 passive 阶段,调用链非常长,值得完整走一遍:

flowchart TD A[flushPassiveEffectsImpl 4788<br/>commitPassiveUnmountEffects root.current] --> B[commitPassiveUnmountOnFiber 5118] B --> C[recursivelyTraversePassiveUnmountEffects 5071] C --> D{&#34;parentFiber.flags & ChildDeletion<br/>5076&#34;} D -- 是 --> E[&#34;对 deletions 里每个 childToDelete:<br/>commitPassiveUnmountEffectsInsideOfDeletedTree_begin 5083&#34;] E --> F[commitPassiveUnmountInsideDeletedTreeOnFiber 5398<br/>父→子顺序跑 useEffect destroy] F --> G[&#34;commitPassiveUnmountEffectsInsideOfDeletedTree_complete 5371<br/>nextEffect 链表遍历&#34;] G --> H[detachFiberAfterEffects 5382 → 1332] H --> I[&#34;fiber.child/deletions/sibling = null<br/>stateNode = null(HostComponent 额外 detachDeletedInstance)<br/>memoizedProps/State、dependencies、updateQueue 全清&#34;] C --> J{&#34;parentFiber.subtreeFlags & PassiveMask<br/>5109&#34;} J -- 非0 --> K[递归子节点继续 unmount]

detachFiberAfterEffects(13321377)清掉的字段清单:alternate 链接(并递归清理 alternate 自己,1335 1337)、child、deletions、sibling、stateNode(HostComponent 会先 detachDeletedInstance(hostInstance) 砍断 DOM↔fiber 的循环,13531359)、_debugOwner(DEV)、return、dependencies、memoizedProps、memoizedState、pendingProps、updateQueue。注释 1339 1344 还解释了设计哲学:未来 Fiber 重构后 setState 会绑定到独立的「实例对象」而不是 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

上篇小结

  1. Commit 之所以必须分成三个子阶段,是因为有些 DOM 操作必须在"读"之前、有些必须在"写"之后 :Before Mutation 用来在 DOM 变更前 快照(如 getSnapshotBeforeUpdate);Mutation 做真实增删改;Layout 在 DOM 已更新但浏览器尚未绘制时同步运行(useLayoutEffect、ref attach)。漏掉任何一阶段,某些浏览器 API 就取不到正确的值。

  2. 遍历方式从 effect list 改成了递归 + subtreeFlags 短路 :nextEffect 不再是一条全局链表,而是靠每个 fiber 的 subtreeFlags 判断"这棵子树有没有我要的 flag"。这让并发渲染下的中断/恢复变得安全------不再有一条需要跨渲染维护的链表状态。

  3. Placement 不总是"插到末尾" :commitPlacement 要区分宿主父节点类型、是否有 ContentReset、是否需要 moveBefore,最坏情况下 getHostSibling 会指数级回退(本篇 6.4.12 已给出最坏树形与 React 的取舍)。

  4. Deletion 不逐个 removeChild:它一次性找到子树最外层的宿主节点批量摘除,但必须逐个向下遍历以执行 unmount 与 ref detach,这是"删得快"与"语义正确"的折中。

  5. Mask 是"保守并集" :BeforeMutationMask | MutationMask 这类写法意味着一个阶段会顺带处理相邻阶段的 flag,代价是多遍历一点,收益是少写一套分支。

下篇 接着讲 6.5 起的内容:与其他模块的交互、边界场景与坑、性能影响、最佳实践,以及两段式 detach 与 commitMount 这两个容易被忽略的收尾细节。

相关推荐
鶴哥只手遮天1 小时前
React 19.3.1 源码深度剖析-第 9 章(下)lazy、retry、hydration 与实战
前端·react.js·开源
鶴哥只手遮天1 小时前
React 19.3.1 源码深度剖析-第 6 章 React DOM 渲染器与 Commit 阶段(把 Fiber 副作用落到真实 DOM)(下)
前端·react.js·开源
鶴哥只手遮天1 小时前
React 19.3.1 源码深度剖析-第 13 章:React 19 新特性深解
前端·react.js·开源
wflynn1 小时前
GitHub 今日推荐|open_scion:为AI代理设计的并发版本控制
git·开源·go·github·agent·coding-agents
donk13141 小时前
React Native for OpenHarmony 实战:三方库 react-native-circular-action-menu 的鸿蒙化适配指南
javascript·分布式·react native·react.js·wpf·harmonyos
鶴哥只手遮天1 小时前
React 19.3.1 源码深度剖析-第 1 章 总览与架构哲学
前端·react.js·开源
鶴哥只手遮天1 小时前
React 19.3.1 源码深度剖析-第 4 章 Reconciliation 协调算法(Diff 算法全解)
前端·react.js·开源
小小龙学IT1 小时前
Nginx 开源 Web 服务器与反向代理深度解
前端·nginx·开源
miofly2 小时前
腾讯开源 Hy Memory 2.0 记忆系统,3B 激活参数超越 claude-sonnet-5-5
开源·github