React 19 内核探秘

第6章 Commit 阶段:从虚拟到真实

作者 杨艺韬 · 13,935 字 · 发布于 · 更新于

第6章 Commit 阶段:从虚拟到真实

本章要点

  • Commit 阶段的三个子阶段:BeforeMutation、Mutation、Layout
  • 为什么 Commit 阶段必须是同步的——不可中断的 DOM 操作
  • Placement、Update、Deletion 三种 effect 的执行路径
  • commitRoot 的完整源码解读与执行流程
  • useLayoutEffect 与 useEffect 的调度时机差异
  • Ref 的绑定与解绑发生在哪个子阶段
  • React 如何保证 DOM 操作的原子性与一致性
  • Passive Effects(useEffect)的异步调度机制

如果说 Reconciliation 是 React 的"参谋部"——负责分析形势、制定作战计划,那么 Commit 阶段就是"前线部队"——负责将计划执行为真实的 DOM 操作。在上一章中,我们看到 Diff 算法如何为每个需要变更的 Fiber 节点打上 Placement、Update、Deletion 等 flags。现在,是时候看看这些 flags 如何被翻译成浏览器真正理解的 DOM API 调用了。

Commit 阶段的设计有一个核心约束:它必须是同步的、不可中断的。这与 Render 阶段形成了鲜明对比。并发渲染时,Render 阶段可以被更高优先级的任务打断、可以重新开始、甚至可以丢弃中间结果。但 Commit 阶段一旦开始,就必须一气呵成——因为用户不能看到"DOM 改了一半"的中间状态。

6.1 为什么 Commit 必须同步

想象一个简单的场景:你要把列表从 [A, B, C] 更新为 [C, B, A]。按第 5 章的 Diff 规则,C 留在原位,B 和 A 被标记为移动,需要两步 DOM 操作:先把 B 移到末尾,再把 A 移到末尾。如果在执行完第一步之后被中断了会怎样?

// 列表更新:[A, B, C] → [C, B, A]
// 需要的 DOM 操作:
// 1. 将 B 移到末尾 → [A, C, B]
// 2. 将 A 移到末尾 → [C, B, A]

// 如果在步骤1之后被中断:
// 用户看到的是 [A, C, B]——既不是旧列表,也不是新列表
// 这就是错误的中间状态

这就是 Commit 阶段必须同步执行的根本原因。DOM 操作不像 Fiber 树的构建那样可以随时丢弃重来——每一次 appendChild、removeChild、insertBefore 都会立即修改用户可见的 DOM 树。中间状态一旦被浏览器绘制出来,用户就会看到既非旧、也非新的界面;紧随其后的 Layout Effect 和 ref 回调也都假定 DOM 已经完整反映了本次更新,读到半成品就会得出错误的测量结果。

graph LR
    subgraph "Render 阶段(并发渲染时可中断)"
        A["beginWork"] --> B["reconcileChildren"]
        B --> C["completeWork"]
        C -.->|"可能被中断"| A
    end
    subgraph "Commit 阶段(同步、不可中断)"
        D["BeforeMutation"] --> E["Mutation"]
        E --> F["Layout"]
    end
    C -->|"Fiber 树完成"| D
    F --> G["浏览器绘制"]

图 6-1:Render 阶段与 Commit 阶段的执行模式对比

6.2 commitRoot 的整体结构

当 Render 阶段完成后,React 会调用 commitRoot 启动 Commit 阶段。这是整个 Commit 的入口,让我们看看它的核心结构:

// packages/react-reconciler/src/ReactFiberWorkLoop.js:2962(commitRoot)与 :3000(commitRootImpl)
// 简化版 commitRoot(v19.0.0 真实入口是 commitRoot → commitRootImpl,§6.2.1 起逐段对照)
function commitRoot(root: FiberRoot) {
  const finishedWork = root.finishedWork;
  if (finishedWork === null) return;
  const lanes = root.finishedLanes;

  // 重置状态
  root.finishedWork = null;
  root.finishedLanes = NoLanes;

  // 检查是否存在 passive effects(useEffect)
  // 如果有,调度一个异步任务去执行它们
  if (
    (finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
    (finishedWork.flags & PassiveMask) !== NoFlags
  ) {
    if (!rootDoesHavePassiveEffects) {
      rootDoesHavePassiveEffects = true;
      scheduleCallback(NormalSchedulerPriority, () => {
        flushPassiveEffects();
        return null;
      });
    }
  }

  // 判断是否有需要处理的副作用
  const subtreeHasEffects =
    (finishedWork.subtreeFlags &
      (BeforeMutationMask | MutationMask | LayoutMask | PassiveMask)) !== NoFlags;
  const rootHasEffect =
    (finishedWork.flags &
      (BeforeMutationMask | MutationMask | LayoutMask | PassiveMask)) !== NoFlags;

  if (subtreeHasEffects || rootHasEffect) {
    // ========== 第一阶段:BeforeMutation ==========
    commitBeforeMutationEffects(root, finishedWork);

    // ========== 第二阶段:Mutation ==========
    commitMutationEffects(root, finishedWork, lanes);

    // 还原 selection / focus,恢复事件派发(§6.5.1)
    resetAfterCommit(root.containerInfo);

    // 关键:在 Mutation 和 Layout 之间切换 Fiber 树
    root.current = finishedWork;

    // ========== 第三阶段:Layout ==========
    commitLayoutEffects(finishedWork, root, lanes);
  } else {
    // 没有副作用,直接切换 Fiber 树
    root.current = finishedWork;
  }

  // 调度可能的后续更新
  ensureRootIsScheduled(root);
}

注意代码中那行看似不起眼的 root.current = finishedWork。这是 React 双缓冲机制的关键——它将"当前显示的 Fiber 树"从 current 切换到了 finishedWork(也就是 workInProgress 树)。这个切换的位置至关重要:它发生在 Mutation 之后、Layout 之前。这意味着:

  • 在 BeforeMutation 和 Mutation 阶段,root.current 仍然指向旧树(可以读取旧的 state 和 props)
  • 在 Layout 阶段,root.current 已经指向新树(读取到的是新的 state 和 props)

这个设计保证了生命周期方法能在正确的时机读取到正确的值。

6.2.1 do..while 循环排空 passive effects 的"鸡生蛋"递归

打开 packages/react-reconciler/src/ReactFiberWorkLoop.js:3013-3021(tag v19.0.0)、commitRootImpl 最开始有一个看似无害但内涵极深的循环:

do {
  // `flushPassiveEffects` will call `flushSyncUpdateQueue` at the end, which
  // means `flushPassiveEffects` will sometimes result in additional
  // passive effects. So we need to keep flushing in a loop until there are
  // no more pending effects.
  // TODO: Might be better if `flushPassiveEffects` did not automatically
  // flush synchronous work at the end, to avoid factoring hazards like this.
  flushPassiveEffects();
} while (rootWithPendingPassiveEffects !== null);

为什么 commit 前要先 flushPassiveEffects?——因为上一次 render 可能留下了 还没执行的 useEffect(还在异步队列里等)。如果不先把它们跑完就直接 commit 新的 render、会出现"上一次的 effect 跟下一次的 DOM 混在一起"——比如 effect 里基于旧 DOM 做的 measure 会拿到新 DOM 的尺寸、断言失败。

为什么用 do..while 而不是一次性 flush?——源码注释说得清清楚楚:flushPassiveEffects 末尾会 flushSyncUpdateQueue、这可能再产生 passive effect(比如 effect 里又 setState、该 setState 又 scheduled 另一个 effect)。这就成"鸡生蛋"——一次 flush 后 rootWithPendingPassiveEffects 又变成非 null、需要再 flush。

用 do..while 配合 while (rootWithPendingPassiveEffects !== null) 的循环条件、无限次 flush 直到真的干净。React 不假设 effect 链条有限——让状态停稳才开始下一个 commit。

源码里的 // TODO: Might be better if... 是 React 团队对自己设计的自我批评——理想情况下 flushPassiveEffects 不应该"附带 flush sync queue"、这个耦合让代码需要写这种循环。注释只写了"Might be better if…",没有说何时、如何解耦。这种 TODO 是 React 源码诚实暴露技术债的典型。

这个 do..while 在绝大部分 commit 里只跑一次(rootWithPendingPassiveEffects 一开始就是 null)——但存在是为了覆盖边界、不是无用保险。

6.2.2 finishedWork === root.current 的 "同树 commit" 防御

commitRootImpl 里的一道关键 assert(line 3072-3077):

if (finishedWork === root.current) {
  throw new Error(
    'Cannot commit the same tree as before. This error is likely caused by ' +
      'a bug in React. Please file an issue.',
  );
}

finishedWork === root.current 意味着"当前要 commit 的树和显示中的树是同一个对象"——这违反了 React 的双缓冲模型。双缓冲要求 current 和 workInProgress 是两棵独立的 Fiber 树、commit 时交换角色。

这种错误只会发生在React 内部 bug——用户代码无法触发。为什么还要写这个检查?因为内部 bug 被尽早捕获比晚捕获好——如果 commit 的是同一棵树、后续的 mutation effect 会在同一棵 Fiber 上反复走、指针相互覆盖、最后整棵树乱套——用户看到的表象可能是"页面莫名其妙消失"或"点击响应错位"——很难从表象追到根因。

throw new Error 加上 "Please file an issue" 的友好提示——体现 React 对用户的尊重:这不是你代码写错、是我们的 bug、请上报让我们修。很多框架遇到这种内部一致性问题是 silent crash 或者 wrong behavior、React 的选择是 explicit throw + actionable message。

这个检查每次 commit 都跑一遍——只是一次指针比较的开销、但保证了 双缓冲不变式从未被破坏。这类"便宜的运行时断言守护核心不变式"是 Rust / React / 其他大框架共同的工程习惯——和 Hyper 与 Tower 专栏《Hyper 与 Tower:工业级 HTTP 栈》 第 14 章 §14.6.1 hyper 的 assert!(max >= MINIMUM_MAX_BUFFER_SIZE)、第 16 章 §16.3.5 h2 的 assert!(self.window_size.0 >= sz as i32) 都是同一种工程直觉。

6.3 BeforeMutation 阶段:最后的准备

BeforeMutation 是 Commit 阶段的第一幕。在这个阶段,DOM 尚未被修改,React 需要做一些"变更前的准备工作"。

// packages/react-reconciler/src/ReactFiberCommitWork.js:234(源码带 export)
function commitBeforeMutationEffects(
  root: FiberRoot,
  firstChild: Fiber
): boolean {
  // 先拍 selection / focus 快照并暂停事件派发(react-dom 的 prepareForCommit,见 §6.5.1)
  focusedInstanceHandle = prepareForCommit(root.containerInfo);
  nextEffect = firstChild;
  // 深度优先遍历 Fiber 树(v19 这一子阶段仍是 _begin/_complete 的迭代式)
  commitBeforeMutationEffects_begin();
  // ... 返回 shouldFireAfterActiveInstanceBlur(仅 enableCreateEventHandleAPI 用)
}

function commitBeforeMutationEffects_complete() {
  while (nextEffect !== null) {
    const fiber = nextEffect;
    // 注意这里没有 try/catch——错误捕获下沉到了 commitClassSnapshot 内部
    commitBeforeMutationEffectsOnFiber(fiber);

    const sibling = fiber.sibling;
    if (sibling !== null) {
      sibling.return = fiber.return;
      nextEffect = sibling;
      return; // 回到 _begin 处理兄弟节点
    }
    nextEffect = fiber.return;
  }
}

function commitBeforeMutationEffectsOnFiber(finishedWork: Fiber) {
  const current = finishedWork.alternate;
  const flags = finishedWork.flags;

  // 1. 处理 getSnapshotBeforeUpdate
  if ((flags & Snapshot) !== NoFlags) {
    switch (finishedWork.tag) {
      case ClassComponent: {
        if (current !== null) {
          // v19 抽成了 ReactFiberCommitEffects.js 里的 commitClassSnapshot
          commitClassSnapshot(finishedWork, current);
        }
        break;
      }
      case HostRoot: {
        // 如果 root 的容器需要清空内容
        if (supportsMutation) {
          const container = finishedWork.stateNode.containerInfo;
          clearContainer(container);
        }
        break;
      }
    }
  }
}

// packages/react-reconciler/src/ReactFiberCommitEffects.js(v19.0.0,精简)
function commitClassSnapshot(finishedWork: Fiber, current: Fiber) {
  const prevProps = current.memoizedProps;
  const prevState = current.memoizedState;
  const instance = finishedWork.stateNode;
  try {
    // class 组件的 defaultProps 在 19 仍受支持,这里用 resolveClassComponentProps 解析
    const resolvedPrevProps = resolveClassComponentProps(
      finishedWork.type,
      prevProps,
      finishedWork.elementType === finishedWork.type,
    );
    // 调用 getSnapshotBeforeUpdate
    const snapshot = instance.getSnapshotBeforeUpdate(resolvedPrevProps, prevState);
    // 保存快照值,后续在 componentDidUpdate 中作为第三个参数传入
    instance.__reactInternalSnapshotBeforeUpdate = snapshot;
  } catch (error) {
    captureCommitPhaseError(finishedWork, finishedWork.return, error);
  }
}

这个阶段最重要的工作就是调用 Class 组件的 getSnapshotBeforeUpdate 生命周期方法。这个方法的名字已经说明了一切——它让组件有机会在 DOM 变更之前"拍一张快照"。最经典的用例是保存滚动位置:

class ChatRoom extends React.Component<Props, State> {
  listRef = React.createRef<HTMLDivElement>();

  getSnapshotBeforeUpdate(prevProps: Props, prevState: State) {
    // 在 DOM 变更前,记录当前的滚动位置
    if (prevState.messages.length < this.state.messages.length) {
      const list = this.listRef.current!;
      return list.scrollHeight - list.scrollTop;
    }
    return null;
  }

  componentDidUpdate(
    prevProps: Props,
    prevState: State,
    snapshot: number | null
  ) {
    // DOM 已经更新,使用快照恢复滚动位置
    if (snapshot !== null) {
      const list = this.listRef.current!;
      list.scrollTop = list.scrollHeight - snapshot;
    }
  }

  render() {
    return (
      <div ref={this.listRef} className="chat-list">
        {this.state.messages.map((msg) => (
          <Message key={msg.id} data={msg} />
        ))}
      </div>
    );
  }
}

为什么不能在 componentDidUpdate 中做这件事?因为在 componentDidUpdate 执行时,DOM 已经被修改了,新的消息已经被插入到列表中,scrollHeight 已经改变了——你无法准确地计算出需要滚动多少才能保持原来的视觉位置。

6.3.1 setCurrentUpdatePriority(DiscreteEventPriority) 的临时优先级升级

commitRootImpl 在进入子阶段前有一组临时环境变量保存/切换(line 3179-3185):

const prevTransition = ReactSharedInternals.T;   // v19:ReactCurrentBatchConfig.transition 并入了 ReactSharedInternals.T
ReactSharedInternals.T = null;
const previousPriority = getCurrentUpdatePriority();
setCurrentUpdatePriority(DiscreteEventPriority);

const prevExecutionContext = executionContext;
executionContext |= CommitContext;

三个关键动作:

① ReactSharedInternals.T = null——如果当前 render 是 transition 引起的(比如用 startTransition 触发)、commit 阶段里不应该还挂着 transition 标记。一旦 commit 开始、就是"已决定要更新"的同步行为——transition 标记在这里已经完成历史使命。

② setCurrentUpdatePriority(DiscreteEventPriority)——把当前更新优先级升级到 DiscreteEventPriority(最高级)。这保证了 commit 过程中如果任何代码路径触发了新的 setState(比如 cDM 里 setState)、这个新 setState 会被视为离散事件级紧急——不会被其他任务饿死。

③ executionContext |= CommitContext——设置 execution context 的 CommitContext 位。这是个全局状态、让其他代码能知道"现在处于 commit 中"——比如 cDM 里调用 flushSync 时,flushSyncWork 看到 CommitContext 就不会同步 flush;commitRootImpl 开头也用它拒绝重入('Should not already be working.')。

为什么要保存 prev 然后 restore?——commit 本身不会嵌套(上面那道重入检查就是保证),但 commit 结束后控制权要交还给调用方(事件处理、Scheduler 回调等),它们有自己的优先级和 transition 上下文、不 restore 会污染外层调用的状态。每一层都自己管好"进入时保存、退出时恢复"。

这种全局变量的 save/restore pattern在 React 核心代码里是 pervasive——ReactSharedInternals.T(旧名 ReactCurrentBatchConfig.transition)、ReactSharedInternals.H(旧名 ReactCurrentDispatcher)、executionContext 都用这种写法。不是严格受控的局部变量、是函数间共享的上下文——要求代码维护者高度自律、每一个修改这些变量的地方都要配对 restore。

这种风格在现代 JavaScript 里有点过时——更流行的是用 Node 的 AsyncLocalStorage 或 Zone.js 那样的显式 scope。但 React 坚持全局变量+save/restore——换来的是每次 scheduler 回调启动时几乎零开销——不用创建新 context、不用打开 scope——只改一个引用。

6.4 Mutation 阶段:真正的 DOM 操作

Mutation 阶段是整个 Commit 的核心——这里是 React 将虚拟 DOM 的变更转化为真实 DOM 操作的地方。

// React 18.1+ / 19:递归遍历(ReactFiberCommitWork.js)
function commitMutationEffects(
  root: FiberRoot,
  finishedWork: Fiber,
  lanes: Lanes,
) {
  commitMutationEffectsOnFiber(finishedWork, root, lanes);
}

function recursivelyTraverseMutationEffects(
  root: FiberRoot,
  parentFiber: Fiber,
  lanes: Lanes,
) {
  // 先处理 deletions,再按 child → sibling 递归
  const deletions = parentFiber.deletions;
  if (deletions !== null) {
    for (let i = 0; i < deletions.length; i++) {
      commitDeletionEffects(root, parentFiber, deletions[i]);
    }
  }

  if (parentFiber.subtreeFlags & MutationMask) {
    let child = parentFiber.child;
    while (child !== null) {
      commitMutationEffectsOnFiber(child, root, lanes);
      child = child.sibling;
    }
  }
}

function commitMutationEffectsOnFiber(
  finishedWork: Fiber,
  root: FiberRoot,
  lanes: Lanes,
) {
  const current = finishedWork.alternate;
  const flags = finishedWork.flags;

  // 按 tag 分派;下面以 HostComponent 分支为例
  recursivelyTraverseMutationEffects(root, finishedWork, lanes); // 先子树(含 deletions)
  commitReconciliationEffects(finishedWork);                     // 再自己的 Placement

  if (flags & Ref && current !== null) {
    safelyDetachRef(current, current.return);                    // 解绑旧 ref(§6.6.4)
  }
  if (flags & Update) {
    const newProps = finishedWork.memoizedProps;
    const oldProps = current !== null ? current.memoizedProps : newProps;
    commitHostUpdate(finishedWork, newProps, oldProps);          // → HostConfig.commitUpdate(§6.4.4)
  }
  // Deletion 已在父节点的 deletions 列表里提前处理
}

function commitReconciliationEffects(finishedWork: Fiber) {
  const flags = finishedWork.flags;
  if (flags & Placement) {
    commitHostPlacement(finishedWork); // → commitPlacement(ReactFiberCommitHostEffects.js)
    // 清掉 Placement 位,这样 componentDidMount 等生命周期跑到时能知道"已插入"
    finishedWork.flags &= ~Placement;
  }
  if (flags & Hydrating) {
    finishedWork.flags &= ~Hydrating;
  }
}

6.4.1 Mutation 阶段:递归遍历 + subtreeFlags 剪枝

React 18.1 起(含 19),Mutation 的真实入口是 commitMutationEffects → recursivelyTraverseMutationEffects 的纯递归,而不是早期实验里那套 commitMutationEffects_begin / _complete 手写迭代。

历史上只有 18.0 的 Mutation 用过 _begin/_complete + 模块级 nextEffect 游标的迭代式 DFS(git grep commitMutationEffects_begin v18.0.0 能搜到、v18.1.0 起就没了),当初以为循环比递归快。18.1 起 Mutation 改为递归,Layout 则一直到 19 才改(18.3.1 仍是 commitLayoutEffects_begin;v19 的 Mutation / Layout 都是 recursivelyTraverse*)。改动的提交说明(481dece)给了两条理由:实测循环反而更慢,因为进入子树前要重设 return 指针;递归还能借 JS 调用栈记录"最近的 host 父节点"这类上下文信息。注意 BeforeMutation 在 v19 仍是 commitBeforeMutationEffects_begin / _complete + nextEffect 的迭代形态——并非所有子阶段都统一成递归。

当前实现里真正的性能开关不是"迭代 vs 递归",而是 subtreeFlags & MutationMask:子树没有任何 mutation 相关 flag 时整枝跳过——这才是 §6.4.7 位标记的核心价值。

读旧文或旧笔记时若看到 Mutation / Layout 的 _begin/_complete,把它当成 18.x 的旧实现即可;不要再把它讲成"React 19 真实实现"或"17→18 性能跃升的主因"。

6.4.2 Placement:节点插入

当一个 Fiber 节点被标记为 Placement,意味着它需要被插入到 DOM 中。但"插入到哪里"是一个比想象中复杂得多的问题:

// packages/react-reconciler/src/ReactFiberCommitHostEffects.js:340(v19 从 CommitWork 拆出,精简)
function commitPlacement(finishedWork: Fiber) {
  // 1. 找到最近的 DOM 类型的父节点
  const parentFiber = getHostParentFiber(finishedWork);
  let parent: Element;

  switch (parentFiber.tag) {
    case HostComponent:
      parent = parentFiber.stateNode;
      break;
    case HostRoot:
      parent = parentFiber.stateNode.containerInfo;
      break;
    // ... 其他情况
  }

  // 2. 找到插入锚点——需要找到"下一个 DOM 兄弟节点"
  const before = getHostSibling(finishedWork);

  // 3. 执行插入:finishedWork 自身若不是 Host 节点(比如是函数组件),
  //    会递归下去把它最近的所有 Host 子节点逐个 insertBefore / appendChild
  insertOrAppendPlacementNode(finishedWork, before, parent);
}

其中最复杂的部分是 getHostSibling——寻找"下一个 DOM 兄弟节点"。为什么这很复杂?因为 Fiber 树和 DOM 树的结构并不是一一对应的。

// Fiber 树中有很多"不产生 DOM 节点"的层级
function App() {
  return (
    <div>
      <Header />           {/* FunctionComponent → 不产生 DOM */}
      <React.Fragment>     {/* Fragment → 不产生 DOM */}
        <Sidebar />        {/* FunctionComponent → 不产生 DOM */}
        <Content />        {/* FunctionComponent → 不产生 DOM */}
      </React.Fragment>
      <Footer />           {/* FunctionComponent → 不产生 DOM */}
    </div>
  );
}

// 如果 Sidebar 返回 <nav>...</nav>
// 而 Content 返回 <main>...</main>
// 那么在 DOM 中,<nav> 和 <main> 是兄弟关系
// 但在 Fiber 树中,它们隔着 Fragment 和 FunctionComponent 层

getHostSibling 的实现需要在 Fiber 树中"向右、向上、向下"搜索,跳过所有不产生 DOM 节点的 Fiber,才能找到真正的 DOM 兄弟:

// packages/react-reconciler/src/ReactFiberCommitHostEffects.js:222(源码返回类型写作 ?Instance,DOM 端即 Element | null)
function getHostSibling(fiber: Fiber): Element | null {
  let node = fiber;

  siblings: while (true) {
    // 向上找到有兄弟节点的祖先
    while (node.sibling === null) {
      if (node.return === null || isHostParent(node.return)) {
        return null;
      }
      node = node.return;
    }

    // 移动到兄弟节点
    node.sibling.return = node.return;
    node = node.sibling;

    // 向下找到第一个 HostComponent 或 HostText(v19 还排除 HostSingleton / DehydratedFragment)
    while (node.tag !== HostComponent && node.tag !== HostText) {
      // 如果这个节点也是 Placement,跳过它(它还没有被插入 DOM)
      if (node.flags & Placement) {
        continue siblings;
      }
      // 没有子节点、或者是 Portal(不属于这棵 host 树)→ 换下一个兄弟
      if (node.child === null || node.tag === HostPortal) {
        continue siblings;
      }
      node.child.return = node;
      node = node.child;
    }

    // 如果找到的节点不是 Placement,那就是我们要找的 DOM 兄弟
    if (!(node.flags & Placement)) {
      return node.stateNode;
    }
  }
}

这段代码看起来复杂,但它解决的问题本质上是Fiber 树到 DOM 树的映射——在一棵包含组件、Fragment、Context Provider 等抽象节点的 Fiber 树中,找到一个具体 DOM 节点在 DOM 树中的正确位置。

6.4.3 getHostSibling 的 "跳过 Placement" 规则深解

§6.4.2 展示的 getHostSibling 有一个关键分支:

while (node.tag !== HostComponent && node.tag !== HostText) {
  // 如果这个节点也是 Placement、跳过它(它还没有被插入 DOM)
  if (node.flags & Placement) {
    continue siblings;
  }
  // ...
}

为什么 Placement 节点要跳过?——因为 getHostSibling 要找的是 "已经在 DOM 里、且顺序在我之后" 的节点——它将作为 insertBefore 的 anchor。如果找到的 sibling 本身也是 Placement、它还没被插入 DOM、不能作为 anchor。

这就让多个相邻的 Placement 兄弟节点共用同一个锚点——右边第一个已在 DOM 里的节点:

假设有 <div>[A?] [B?] [C]</div>、其中 A 和 B 是新增的 Placement、C 是已存在的:

  1. 处理 A 时、getHostSibling 从 A 向右找——B 是 Placement、跳过;C 不是 Placement、返回 C
  2. insertBefore(parent, A_dom, C_dom) → <div>[A] [C]</div>
  3. 处理 B 时、getHostSibling 从 B 向右找——C 不是 Placement、返回 C
  4. insertBefore(parent, B_dom, C_dom) → <div>[A] [B] [C]</div>

注意两步都是 "insert before C"——因为 getHostSibling 永远找"已经在 DOM 里的节点"作为 anchor、C 一直稳定。配合 Mutation 阶段按 child → sibling 从左到右的处理顺序,依次 insert before 同一个锚点,最终顺序自然就是 A、B、C(反过来先处理 B 再处理 A,就会得到 B、A、C)。

这是 React 保证 commit 正确性的一个巧妙不变式——每次 Placement 的 anchor 是"右边第一个非 Placement 的 Host 节点"、这个 anchor 稳定。 没有这个不变式、多个兄弟 Placement 的插入顺序会变得复杂、出 off-by-one 几率大增。

理解这个规则、你就能解释本章 §6.4.2 示例代码里 getHostSibling 为什么要 continue siblings——不是 skip 当前节点、而是整个大循环从头再来、去找下一个兄弟。这是 siblings: 标签的用意。

6.4.4 Update:属性更新

当节点被标记为 Update,React 需要更新 DOM 属性。版本分界很重要(与第 15 章口径一致):

  • React ≤18(稳定路径):Render 阶段 completeWork 里 prepareUpdate / diffProperties 预计算扁平数组 updatePayload = [key, value, …],Commit 再按清单写入。
  • React 19:Render 阶段只在 oldProps !== newProps 时打 Update flag,不再生成 updatePayload;Commit 里 commitUpdate(domElement, type, oldProps, newProps) 调用 updateProperties 边 diff 边写(教学常写四参;源码与第 15 章五参全签名另含 internalInstanceHandle)。
// React 19:commit 阶段一次完成 diff + 写入
// 教学四参;完整 HostConfig 为五参:
// commitUpdate(domElement, type, oldProps, newProps, internalInstanceHandle)
function commitUpdate(
  domElement: Element,
  type: string,
  oldProps: Props,
  newProps: Props,
  // internalInstanceHandle: Object,  // 第 5 参,与 ch15 对照
) {
  updateProperties(domElement, type, oldProps, newProps);
  updateFiberProps(domElement, newProps);
}

// React ≤18 历史路径(理解旧资料时有用)
function commitUpdate_legacy(
  domElement: Element,
  updatePayload: Array<mixed>,
  type: string,
  oldProps: Props,
  newProps: Props,
) {
  // updatePayload 为 [propKey, propValue, ...] 扁平数组
  for (let i = 0; i < updatePayload.length; i += 2) {
    const propKey = updatePayload[i];
    const propValue = updatePayload[i + 1];
    if (propKey === STYLE) {
      setValueForStyles(domElement, propValue);
    } else if (propKey === CHILDREN) {
      setTextContent(domElement, propValue);
    } else {
      setValueForProperty(domElement, propKey, propValue);
    }
  }
  updateFiberProps(domElement, newProps);
}

≤18 把 diff 放在可中断的 Render、Commit 只做机械写入;19 把 diff 合并进 Commit,去掉中间数组分配、总工作量更少(PR #26583 的说明也承认代价:大树里 Commit 会停顿更久)。两种设计都守住同一底线:Render 阶段绝不修改页面上的 DOM(completeWork 创建的只是尚未挂到页面上的离屏节点)。

6.4.5 历史:updatePayload 扁平数组(≤18)

在仍使用预计算 payload 的版本里,payload 不是对象而是 [key, value, …] 扁平数组——迭代简单、形状稳定,适合当时"Render 算完、Commit 照单写"的热路径。React 19 去掉这一中间结构后,本节更多是读 17/18 源码或旧资料时的背景知识,而不是现行机制。

6.4.6 Deletion:节点删除

节点删除是三种操作中最复杂的,因为它不仅要从 DOM 中移除节点,还要做大量的清理工作:

// packages/react-reconciler/src/ReactFiberCommitWork.js:1023(递归体 commitDeletionEffectsOnFiber 在 :1100)
// v19.0.0:commitDeletionEffects → commitDeletionEffectsOnFiber(递归),精简,
// 省略 Offscreen 隐藏子树的 guard 与 HostHoistable / HostSingleton / Portal 分支
function commitDeletionEffects(root: FiberRoot, returnFiber: Fiber, deletedFiber: Fiber) {
  // 先沿 return 链找到最近的 Host 父节点,存进模块级 hostParent,供真正 removeChild 时用
  let parent: null | Fiber = returnFiber;
  findParent: while (parent !== null) {
    switch (parent.tag) {
      case HostSingleton:
      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); // 断开 return 指针,帮 GC
}

function recursivelyTraverseDeletionEffects(
  finishedRoot: FiberRoot,
  nearestMountedAncestor: Fiber,
  parent: Fiber,
) {
  let child = parent.child;
  while (child !== null) {
    commitDeletionEffectsOnFiber(finishedRoot, nearestMountedAncestor, child);
    child = child.sibling;
  }
}

function commitDeletionEffectsOnFiber(
  finishedRoot: FiberRoot,
  nearestMountedAncestor: Fiber,
  deletedFiber: Fiber,
) {
  switch (deletedFiber.tag) {
    case HostComponent:
      safelyDetachRef(deletedFiber, nearestMountedAncestor); // 解绑 ref
      // 有意 fallthrough
    case HostText: {
      // 只需要摘掉"最上层"的 Host 节点:把 hostParent 置 null 再递归,
      // 子孙 Host 节点就不会再各自 removeChild
      const prevHostParent = hostParent;
      hostParent = null;
      recursivelyTraverseDeletionEffects(finishedRoot, nearestMountedAncestor, deletedFiber);
      hostParent = prevHostParent;
      if (hostParent !== null) {
        // 整棵子树的 effect 都卸载完了,才真正从 DOM 上摘掉这个节点
        if (hostParentIsContainer) {
          commitHostRemoveChildFromContainer(
            deletedFiber, nearestMountedAncestor, hostParent, deletedFiber.stateNode,
          );
        } else {
          commitHostRemoveChild(
            deletedFiber, nearestMountedAncestor, hostParent, deletedFiber.stateNode,
          );
        }
      }
      return;
    }
    case FunctionComponent:
    case ForwardRef:
    case MemoComponent:
    case SimpleMemoComponent: {
      // useInsertionEffect / useLayoutEffect 的 destroy —— 同步执行,且父先于子
      commitHookEffectListUnmount(HookInsertion, deletedFiber, nearestMountedAncestor);
      commitHookLayoutUnmountEffects(deletedFiber, nearestMountedAncestor, HookLayout);
      // useEffect 的 destroy 不在这里,留给 passive 阶段(§6.7)
      recursivelyTraverseDeletionEffects(finishedRoot, nearestMountedAncestor, deletedFiber);
      return;
    }
    case ClassComponent: {
      safelyDetachRef(deletedFiber, nearestMountedAncestor); // 解绑 ref
      const instance = deletedFiber.stateNode;
      if (typeof instance.componentWillUnmount === 'function') {
        // 调用 componentWillUnmount(内部 try/catch → captureCommitPhaseError)
        safelyCallComponentWillUnmount(deletedFiber, nearestMountedAncestor, instance);
      }
      recursivelyTraverseDeletionEffects(finishedRoot, nearestMountedAncestor, deletedFiber);
      return;
    }
    default:
      recursivelyTraverseDeletionEffects(finishedRoot, nearestMountedAncestor, deletedFiber);
      return;
  }
}

注意删除的执行顺序:对每个 Fiber 先执行它自己的清理(layout destroy、解绑 ref、componentWillUnmount),再递归子节点——父先于子;而最上层 Host 节点的 removeChild 放在它整棵子树递归完之后。这确保了清理函数在 DOM 移除之前执行,组件仍然可以在清理函数中读取 DOM 信息。

graph TD
    A["commitDeletionEffects 开始"] --> B["遍历被删除子树"]
    B --> C{"当前节点类型"}
    C -->|FunctionComponent| D["执行 useLayoutEffect cleanup"]
    C -->|ClassComponent| E["解绑 ref<br/>调用 componentWillUnmount"]
    C -->|HostComponent| F["解绑 ref"]
    D --> G["继续遍历子节点"]
    E --> G
    F --> G
    G --> H{"还有子节点?"}
    H -->|是| B
    H -->|否| I["从 DOM 中移除根节点"]
    I --> J["useEffect cleanup 留给 Passive 阶段"]

图 6-2:节点删除的完整执行流程

6.4.7 subtreeFlags 与 flags 双层检查的意义

commitRoot 里判断"是否有任何 effect 要处理"的代码(line 3169-3176):

// packages/react-reconciler/src/ReactFiberWorkLoop.js:3169
const subtreeHasEffects =
  (finishedWork.subtreeFlags &
    (BeforeMutationMask | MutationMask | LayoutMask | PassiveMask)) !==
  NoFlags;
const rootHasEffect =
  (finishedWork.flags &
    (BeforeMutationMask | MutationMask | LayoutMask | PassiveMask)) !==
  NoFlags;

两个字段是并存的:

  • flags:当前 Fiber 节点自己的 effects(比如 HostRoot 本身要不要清空 container)
  • subtreeFlags:当前 Fiber 子树里任何节点的 effects 的位或聚合

subtreeFlags 是 React 17 → 18 时从"effect list(单独一条链表,firstEffect/lastEffect/nextEffect)"替换过来的设计——React 17 仍在用 effect list——不再维护一条指针链表、而是在每个 Fiber 上冗余记录"我的子树里有什么 flag"。这样:

  • 遍历到一个节点、只要 subtreeFlags 没有 target mask 的位、就整棵子树跳过——避免深度遍历
  • 不再需要维护 effect list 的 next 指针——省内存
  • subtreeFlags 在 completeWork 时自下而上汇总——每个 Fiber 只算一次

flags 和 subtreeFlags 不是冗余——一个包含自己、另一个包含子孙。合在一起才是"这个节点或其子孙有没有相关工作"。

BeforeMutationMask | MutationMask | LayoutMask | PassiveMask 是四个掩码的或——任何一个子阶段有 effect、都走进"有 effect"分支。

这种 "位标记 + 子树聚合" 的设计,思路是把状态信息压缩到最紧凑的 bit 位、用 bit 运算快速检索。React 的 Fiber flag 定义里、所有 effect 类型都是 2 的幂次方的 bit——都是为了这种位运算高效。

6.5 Fiber 树的切换

在 Mutation 阶段完成后、Layout 阶段开始前,有一行极其关键的代码:

root.current = finishedWork;

这行代码完成了 React 双缓冲机制的"翻页"操作。在此之前,root.current 指向旧的 Fiber 树(代表屏幕上正在显示的 UI),finishedWork 是新构建的 Fiber 树。执行这行代码后,新树变成了当前树。

graph LR
    subgraph "Mutation 阶段(切换前)"
        R1["root.current"] --> OT["旧 Fiber 树"]
        FW1["finishedWork"] --> NT1["新 Fiber 树"]
    end
    subgraph "Layout 阶段(切换后)"
        R2["root.current"] --> NT2["新 Fiber 树"]
        style NT2 fill:#90EE90
    end

图 6-3:Fiber 树切换时机

这个时机的选择非常微妙:

  • componentWillUnmount(在 Mutation 阶段调用):此时 root.current 还指向旧树。所以在 componentWillUnmount 中,如果组件通过某种方式读取全局状态,读到的是"变更前"的状态。
  • componentDidMount / componentDidUpdate(在 Layout 阶段调用):此时 root.current 已经指向新树。组件读到的是"变更后"的最新状态。

这种设计保证了生命周期方法的语义一致性:卸载相关的方法看到旧世界,挂载/更新相关的方法看到新世界。

6.5.1 resetAfterCommit(root.containerInfo) 的 selection / focus 还原

在 root.current = finishedWork 这一行之前、commitRootImpl 还调用了一个关键函数(line 3207):

resetAfterCommit(root.containerInfo);

这个函数做什么?看 react-dom 的 HostConfig 实现(packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js 的 resetAfterCommit,L359-364;对应的快照函数 prepareForCommit 在 L326-338):

export function resetAfterCommit(containerInfo: Container): void {
  restoreSelection(selectionInformation, containerInfo);
  ReactBrowserEventEmitterSetEnabled(eventsEnabled);
  eventsEnabled = null;
  selectionInformation = null;
}

最关键的是 restoreSelection(selectionInformation)——还原文本选择(selection)和焦点(focus)。

为什么要还原?想象这个场景:

  1. 用户在 <input> 里选中了 "hello" 这几个字
  2. React 更新了包含这个 input 的组件
  3. 更新过程中 DOM 被修改(可能 input 被从 DOM 移除再插回)
  4. 如果不还原、用户的 selection / focus 就丢了——光标消失、选中的文字没了

React 在 BeforeMutation 子阶段开头(commitBeforeMutationEffects 第一行调 react-dom 的 prepareForCommit)用 getSelectionInformation 拍下当前 selection/focus 快照、在 Mutation 阶段完成后用 restoreSelection 还原。这就是 React UI 更新不打断用户输入的秘密——一整套隐形的 state 保存/恢复机制。

这对用户而言是透明的——用户不知道 React 在 DOM 下面做了什么。但如果关掉这个机制、聚焦的元素一旦在更新中被移动或重新插入,用户就会失去输入焦点——在表单密集型应用里用户会疯掉。

ReactBrowserEventEmitterSetEnabled(eventsEnabled) 则是恢复事件系统——同样在 prepareForCommit 里 React 会暂时关掉自己的事件派发(ReactDOMEventListener.js 里的 _enabled 开关,防止 DOM 变更过程中触发的事件看到"改了一半"的树),commit 后在这里恢复。至于 §6.3.1 提到的优先级临时升级(DiscreteEventPriority),它不在 resetAfterCommit 里恢复——是 commitRootImpl 自己在收尾处 setCurrentUpdatePriority(previousPriority) 还原的。这些 "commit 开始保存、结束还原" 的状态都是 React 作为"无副作用地嵌入用户代码"所必需的——commit 期间看起来改了很多全局状态、退出时必须全部恢复、这样外层代码感受不到任何残留影响。

6.6 Layout 阶段:DOM 已就绪

Layout 阶段是 Commit 的最后一幕。此时 DOM 已经被修改完成,但浏览器尚未进行重绘(因为 JavaScript 仍在同步执行)。这是一个特殊的窗口期——你可以读取变更后的 DOM 布局信息(如 offsetHeight、getBoundingClientRect(),读取会触发一次同步布局计算),据此再做调整,用户也看不到中间帧。

// v19.0.0:Layout 子阶段是递归遍历(ReactFiberCommitWork.js),精简
function commitLayoutEffects(finishedWork: Fiber, root: FiberRoot, committedLanes: Lanes) {
  const current = finishedWork.alternate;
  commitLayoutEffectOnFiber(root, current, finishedWork, committedLanes);
}

function recursivelyTraverseLayoutEffects(root: FiberRoot, parentFiber: Fiber, lanes: Lanes) {
  if (parentFiber.subtreeFlags & LayoutMask) { // 子树没有 layout 相关 flag → 整枝跳过
    let child = parentFiber.child;
    while (child !== null) {
      commitLayoutEffectOnFiber(root, child.alternate, child, lanes);
      child = child.sibling;
    }
  }
}

function commitLayoutEffectOnFiber(
  finishedRoot: FiberRoot,
  current: Fiber | null,
  finishedWork: Fiber,
  committedLanes: Lanes,
) {
  const flags = finishedWork.flags;

  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent: {
      recursivelyTraverseLayoutEffects(finishedRoot, finishedWork, committedLanes); // 先子、后己
      if (flags & Update) {
        // 执行 useLayoutEffect 的 create(内部走 commitHookEffectListMount)
        commitHookLayoutEffects(finishedWork, HookLayout | HookHasEffect);
      }
      break;
    }
    case ClassComponent: {
      recursivelyTraverseLayoutEffects(finishedRoot, finishedWork, committedLanes);
      if (flags & Update) {
        // current === null → componentDidMount
        // 否则 → componentDidUpdate(prevProps, prevState, __reactInternalSnapshotBeforeUpdate)
        commitClassLayoutLifecycles(finishedWork, current);
      }
      if (flags & Callback) {
        // setState 第二个参数的回调
        commitClassCallbacks(finishedWork);
      }
      if (flags & Ref) {
        safelyAttachRef(finishedWork, finishedWork.return); // 绑定 ref
      }
      break;
    }
    case HostSingleton:
    case HostComponent: {
      recursivelyTraverseLayoutEffects(finishedRoot, finishedWork, committedLanes);
      // 首次挂载时处理 autoFocus 等(→ HostConfig.commitMount)
      if (current === null && flags & Update) {
        commitHostMount(finishedWork);
      }
      if (flags & Ref) {
        safelyAttachRef(finishedWork, finishedWork.return);
      }
      break;
    }
    // ... HostRoot / Profiler / SuspenseComponent / OffscreenComponent 等
  }
}

6.6.1 enableProfilerTimer 条件编译与 recordCommitTime

commitRootImpl 里有几段被 if (enableProfilerTimer) 守卫的代码(line 3151-3162):

// packages/react-reconciler/src/ReactFiberWorkLoop.js:3151
if (enableProfilerTimer) {
  // Mark the current commit time to be shared by all Profilers in this
  // batch. This enables them to be grouped later.
  recordCommitTime();
  if (enableComponentPerformanceTrack) {
    if (suspendedCommitReason === SUSPENDED_COMMIT) {
      logSuspendedCommitPhase(completedRenderEndTime, commitStartTime);
    } else if (suspendedCommitReason === THROTTLED_COMMIT) {
      logSuspenseThrottlePhase(completedRenderEndTime, commitStartTime);
    }
  }
}

enableProfilerTimer 是 React 的 feature flag——packages/shared/ReactFeatureFlags.js:258 里定义(v19.0.0:= __PROFILE__)、整个 build 期固定。所有被它守卫的代码在生产 build 里会被 tree-shake 掉——零运行时开销。

开发 build 与 profiling build 开它、让 <Profiler> 组件能收集精确的 commit 时间戳。普通生产 build 关它、省下这部分代码的包体积和每次 commit 的判断开销。

为什么不直接 if (__DEV__)?——因为 __DEV__ 是最粗粒度的分支——DEV 会开所有开发相关逻辑。enableProfilerTimer 是更细粒度——单独控制 Profiler 功能。用户可以自己 build React、只开某些 feature flag——得到一个比完整 DEV 小、比完整 PROD 功能多的定制版本。

这种 "细粒度 feature flag" 是大型 runtime 库的标配——React / Vue / Angular 都有类似机制。对普通用户透明(默认 build 已经是调整好的)、但给深度用户可定制的空间——比如 Facebook 内部会开一些 public 没有的 flag、提前验证新功能。

6.6.2 嵌套更新检测:enableProfilerNestedUpdatePhase 与 markNestedUpdateScheduled

旧笔记里常见的 rootCommittingMutationOrLayoutEffects / enableProfilerNestedUpdateScheduledHook(18.x 的一条 Profiler 实验通道)在 v19.0.0 的 commitRootImpl 里已经不存在。现行的"commit 期间又触发了更新"检测,是 commitRootImpl 尾部(line 3357 起)的这一段:

// packages/react-reconciler/src/ReactFiberWorkLoop.js:3357
if (
  // 本次 render / commit 期间是否显式记录到了递归更新
  (enableInfiniteRenderLoopDetection &&
    (didIncludeRenderPhaseUpdate || didIncludeCommitPhaseUpdate)) ||
  // 或者:这次 render 本身是一次 update,且它又排了一个同步 update
  (includesSomeLane(lanes, UpdateLanes) &&
    includesSomeLane(remainingLanes, SyncUpdateLanes))
) {
  if (enableProfilerTimer && enableProfilerNestedUpdatePhase) {
    markNestedUpdateScheduled();
  }
  if (root === rootWithNestedUpdates) {
    nestedUpdateCount++;
  } else {
    nestedUpdateCount = 0;
    rootWithNestedUpdates = root;
  }
} else {
  nestedUpdateCount = 0;
}

它同时服务两个目的:

① Profiler 的因果信息——enableProfilerNestedUpdatePhase(v19.0.0 里同样 = __PROFILE__)开着时,markNestedUpdateScheduled() 把接下来这次 render 标成 "nested-update" 阶段:<Profiler> 的 onRender / onCommit 回调收到的 phase 就是 'nested-update'(packages/react-reconciler/src/ReactFiberCommitEffects.js:909),据此能区分"被 cDM / useLayoutEffect 里的 setState 间接触发"的渲染和一次顶层交互。

② 无限更新循环的熔断——nestedUpdateCount 每次同步级联 +1、一旦超过 NESTED_UPDATE_LIMIT = 50(定义在 packages/react-reconciler/src/ReactFiberWorkLoop.js:610;计数在 commit 收尾处的 L3375,判定在同文件 L3908 的 throwIfInfiniteUpdateLoopDetected——19 里已经没有 18 那个 checkForNestedUpdates 了,而且它不是在 commit 里自查,是下一次 setState 入队时由 packages/react-reconciler/src/ReactFiberConcurrentUpdates.js:252 的 getRootForUpdatedFiber 调用;markRootUpdated/markRootPinged(L1412 / L1431)里也有调用,但包在 enableInfiniteRenderLoopDetection 里,这个开关在 v19.0.0 默认为 false),下一次就抛出那条熟悉的 "Maximum update depth exceeded"。也就是说,componentDidUpdate 里无条件 setState 的死循环,计数靠 commit 尾部这个计数器,抛错发生在下一次 setState 入队时。

这是一条 高度特化的 observability 通道——绝大多数用户完全不知道它存在。但在 profiling 构建里、这些状态变量会被记录下来,交给 <Profiler> 回调和 DevTools 的 Profiler 使用。React 在代码里埋了无数这样的挂钩——对普通用户零成本、对深度用户强大的内省能力。

6.6.3 useLayoutEffect 的同步执行

useLayoutEffect 的回调在 Layout 阶段同步执行。这意味着它在浏览器绘制之前完成,用户不会看到中间状态:

function MeasuredComponent() {
  const ref = useRef<HTMLDivElement>(null);
  const [height, setHeight] = useState(0);

  useLayoutEffect(() => {
    // 在浏览器绘制前同步读取布局信息
    // 并触发同步更新
    if (ref.current) {
      const measuredHeight = ref.current.getBoundingClientRect().height;
      setHeight(measuredHeight);
      // 这个 setState 会触发一次同步的重渲染
      // 用户永远不会看到 height 为 0 的帧
    }
  }, []);

  return (
    <div>
      <div ref={ref} className="content">
        {/* ... 内容 ... */}
      </div>
      <p>内容高度:{height}px</p>
    </div>
  );
}

这是 useLayoutEffect 相对于 useEffect 的核心优势:它保证在浏览器绘制前执行,避免了"先渲染旧值、再闪烁到新值"的视觉问题。但代价是它会延迟浏览器的绘制——如果 useLayoutEffect 中做了耗时操作,用户会感受到明显的卡顿。

6.6.4 Ref 的绑定

Layout 阶段还负责 ref 的绑定。React 的 ref 绑定分两步:

  1. Mutation 阶段:ref 有变化(render 阶段 markRef 打了 Ref flag)且是更新时,先解绑旧 ref(safelyDetachRef:有 refCleanup 就调它;否则回调 ref 传 null、对象 ref 置 current = null)
  2. Layout 阶段:绑定新的 ref(safelyAttachRef → commitAttachRef,设为 DOM 节点或组件实例)
// packages/react-reconciler/src/ReactFiberCommitEffects.js:742(v19.0.0,精简)
function commitAttachRef(finishedWork: Fiber) {
  const ref = finishedWork.ref;
  if (ref !== null) {
    const instance = finishedWork.stateNode;
    const instanceToUse =
      finishedWork.tag === HostComponent ? getPublicInstance(instance) : instance;

    if (typeof ref === 'function') {
      // 回调 ref;v19 新增:返回值若是函数,存为 refCleanup,解绑时优先调它
      finishedWork.refCleanup = ref(instanceToUse);
    } else {
      // createRef / useRef
      ref.current = instanceToUse;
    }
  }
}

这个两步策略保证了 ref 的生命周期是清晰的:旧 ref 在 Mutation 阶段被清空,新 ref 在全部 DOM 变更完成后(Layout 阶段)才被设置。被删除的节点则在删除流程里解绑(§6.4.6)。

6.6.5 markRootFinished 与 lanes 的释放语义

commitRootImpl 在进入子阶段前有一处调用(line 3094-3101;v19.0.0 的签名已扩成 6 个参数):

markRootFinished(
  root,
  lanes,               // 本次 commit 完成的 lanes(root.finishedLanes)
  remainingLanes,      // commit 后仍要处理的 lanes
  spawnedLane,         // 本次 render 派生出的 deferred lane(useDeferredValue)
  updatedLanes,
  suspendedRetryLanes, // 新 Suspense 边界挂起、尚未被 ping 的 retry lanes
);

markRootFinished 把当前 root 上的某些 lane 标记为"已完成"。关键在于 remainingLanes 的计算(line 3087-3092):

let remainingLanes = mergeLanes(finishedWork.lanes, finishedWork.childLanes);
const concurrentlyUpdatedLanes = getConcurrentlyUpdatedLanes();
remainingLanes = mergeLanes(remainingLanes, concurrentlyUpdatedLanes);

三部分合并:

  1. finishedWork.lanes——当前 Fiber 本身的未完成 lane
  2. finishedWork.childLanes——子孙 Fiber 的未完成 lane
  3. getConcurrentlyUpdatedLanes()——render 期间并发触发的新 lane

第三部分在 18.x 只存在于未启用的 .new 分叉里(v18.3.1 实际使用的 .old 分叉没有它),随 19 进入正式实现——在 render 执行过程中、用户可能又触发了新 setState(比如点了按钮)、这些新 setState 创建了新的 lane、但这次 render 没来得及处理它们。markRootFinished 不能把这些 lane 当作"已完成"——它们还要等下一次 render。

所以 remainingLanes 收集的是 "即将要 commit 的这一轮过后、还剩下哪些 lane 要处理"——这些 lane 保留 scheduled、其他 lane 在 markRootFinished 里被清理(它们的 callback 不再需要运行)。

这种精确的 lane 管理是并发渲染能成立的关键——render 过程中触发的新 setState 不会被 commit 吞掉、而是被保留到下一次 render 循环。

6.6.6 root.callbackNode = null; root.callbackPriority = NoLane; 的清理

commitRootImpl 里(line 3079-3083):

// commitRoot never returns a continuation; it always finishes synchronously.
// So we can clear these now to allow a new callback to be scheduled.
root.callbackNode = null;
root.callbackPriority = NoLane;
root.cancelPendingCommit = null;

注释明确:commitRoot 永远不返回 continuation、总是同步完成——这回到 §6.1 讲的"Commit 必须同步"的核心原则。

但为什么还需要显式清理这三个字段?——因为 root.callbackNode 是 "调度器已调度的 callback 句柄"、用来支持"取消/重新调度"。commit 已经开始——这个 callback 正在执行中、不可能再被取消——所以置 null 表示"没有 pending callback"。

root.callbackPriority = NoLane——把该 root 的调度优先级重置。下次 scheduleUpdateOnFiber 来判断"要不要新建 callback"时、看到 NoLane 就知道"没有 in-flight callback、可以起新的"。

root.cancelPendingCommit = null——cancel 函数也清掉、避免误调(现在已经在 commit 里、取消它本身就是矛盾)。

这三行看起来琐碎、却精确反映了调度器和 commit 的状态协议:

  • 有 callback in-flight → callbackNode 非 null、callbackPriority 非 NoLane
  • 没有 callback → callbackNode 为 null、callbackPriority 为 NoLane
  • commit 开始 = callback 开始执行 = 进入"没有 pending callback"状态

这种状态字段的精确约束是 React 状态机健壮的基础——每次读这些字段的代码都能依赖"null 就是 null、non-null 就有东西"的清晰语义、不需要处理模糊中间态。

6.7 Passive Effects:useEffect 的异步调度

useEffect 的执行时机与 useLayoutEffect 完全不同。它不在 Commit 阶段的三个子阶段中同步执行,而是经 Scheduler 以 NormalPriority 异步调度(底层是 MessageChannel 宏任务,"尽快执行"而非空闲执行)。通常这个宏任务落在浏览器绘制之后,但并不保证——它可能在 paint 前就跑,也可能被后续的同步渲染用 flushPassiveEffects 提前强制执行。还有一条 v19 明确写在 commitRootImpl 尾部的规则:如果这次 render 含 SyncLane(典型是 click 等离散事件触发的更新),passive effects 会在 commit 收尾时同步 flush(includesSyncLane(pendingPassiveEffectsLanes) → flushPassiveEffects()),根本不等宏任务:

// packages/react-reconciler/src/ReactFiberWorkLoop.js:3122-3148(节选,略去 profiling 那一支)
// 在 commitRoot 的开头,如果检测到有 passive effects
if (
  (finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
  (finishedWork.flags & PassiveMask) !== NoFlags
) {
  scheduleCallback(NormalSchedulerPriority, () => {
    flushPassiveEffects(true);
    return null;
  });
}

// commitRootImpl 收尾处(line 3343-3348):离散事件触发的 render,effects 不等宏任务
if (includesSyncLane(pendingPassiveEffectsLanes) && (disableLegacyMode || root.tag !== LegacyRoot)) {
  flushPassiveEffects();
}

flushPassiveEffects 执行所有 useEffect 的清理函数和回调函数(通常在绘制后的宏任务里,也可能被提前 flush):

function flushPassiveEffects() {
  if (rootWithPendingPassiveEffects !== null) {
    const root = rootWithPendingPassiveEffects;
    rootWithPendingPassiveEffects = null;

    // 1. 先执行所有 useEffect 的清理函数(destroy)
    commitPassiveUnmountEffects(root.current);

    // 2. 再执行所有 useEffect 的回调函数(create)
    commitPassiveMountEffects(root, root.current, lanes, transitions);
  }
}

注意执行顺序:先执行所有的 destroy,再执行所有的 create。这不是逐个 effect "destroy → create",而是全局地先批量 destroy,再批量 create。

// 假设有三个组件,每个都有 useEffect
function A() {
  useEffect(() => {
    console.log('A create');
    return () => console.log('A destroy');
  });
}

function B() {
  useEffect(() => {
    console.log('B create');
    return () => console.log('B destroy');
  });
}

function C() {
  useEffect(() => {
    console.log('C create');
    return () => console.log('C destroy');
  });
}

// 更新时的执行顺序:
// A destroy → B destroy → C destroy → A create → B create → C create
// 而不是:A destroy → A create → B destroy → B create → ...

这种"先全部销毁、再全部创建"的策略避免了一个微妙的问题:如果 A 的 create 添加了某个全局事件监听器,而 B 的 destroy 恰好移除同一个监听器(比如两者共用同一个 handler 引用),逐个执行时顺序是 A create → B destroy,B 的清理会把 A 刚装上的监听器一并移除。先全部销毁、再全部创建,所有清理都发生在任何新的 create 之前,就不会互相踩踏。

sequenceDiagram
    participant App as React
    participant DOM
    participant Browser as 浏览器
    participant Effects as Passive Effects
    App->>DOM: Mutation 阶段(DOM 操作)
    App->>App: Layout 阶段(useLayoutEffect)
    App->>Browser: 同步执行完毕,交还控制权
    Browser->>Browser: 绘制(通常先于 effects,但无保证)
    Browser->>Effects: Scheduler NormalPriority 宏任务(MessageChannel)
    Effects->>Effects: 执行所有 useEffect destroy
    Effects->>Effects: 执行所有 useEffect create

图 6-4:useEffect 与 useLayoutEffect 的执行时机对比

6.7.1 pendingPassiveEffectsRemainingLanes 与 pendingPassiveTransitions 的跨时空快照

passive effects 调度的代码(line 3130-3149)里有两个字段值得细看:

// packages/react-reconciler/src/ReactFiberWorkLoop.js:3130
if (!rootDoesHavePassiveEffects) {
  rootDoesHavePassiveEffects = true;
  pendingPassiveEffectsRemainingLanes = remainingLanes;
  pendingPassiveEffectsRenderEndTime = completedRenderEndTime;
  // workInProgressTransitions might be overwritten, so we want
  // to store it in pendingPassiveTransitions until they get processed
  // We need to pass this through as an argument to commitRoot
  // because workInProgressTransitions might have changed between
  // the previous render and commit if we throttle the commit
  // with setTimeout
  pendingPassiveTransitions = transitions;
  scheduleCallback(NormalSchedulerPriority, () => {
    flushPassiveEffects(true);
    return null;
  });
}

pendingPassiveEffectsRemainingLanes 和 pendingPassiveTransitions 是两个跨时空的快照——它们保存 "这次 commit 发生时的状态"、让稍后异步执行的 passive effects 能看到正确的历史。

为什么不让回调闭包直接捕获局部的 remainingLanes 和 transitions?——因为 flushPassiveEffects 不只由这个回调调用:下一次 commitRootImpl 开头的 do..while、SyncLane 的收尾同步 flush 等路径都会提前调用它。源码注释说得明白:this method can be called from various places, not always from commitRoot where the remaining lanes are known。

把快照保存在模块级全局变量里、谁先 flush 谁取用并清空——不管从哪条路径进来,都能拿到这次 commit 的值。这是 React 在并发模式下管理延迟状态的常见做法。

注释里明确点出了问题:"workInProgressTransitions might be overwritten"——如果 commit 被 setTimeout throttle、previous render 的 transitions 和本次 commit 的 transitions 不是同一个。所以需要把"当时的 transitions"通过参数传进来、再存到 pendingPassiveTransitions。

rootDoesHavePassiveEffects 守卫——只在第一次遇到时 schedule callback、避免重复调度。多次 commit 都没 flush 时、只有一个 callback pending。

这就是"被延迟执行的阶段"要付出的状态管理代价——passive effects 可能从多条路径被提前 flush,commit 本身也可能被 setTimeout 推迟,所以延后执行的逻辑要自带当时的快照、不能假设"我跑的时候全局状态还是我刚离开时的样子"。这种隔离保证了正确性,但也带来了代码复杂度。

6.8 错误处理与错误边界

Commit 阶段的错误处理需要格外小心。在 Render 阶段,如果某个组件抛出错误,React 可以简单地丢弃未完成的工作,构建一个包含错误边界 fallback 的新树。但在 Commit 阶段,DOM 可能已经被部分修改了——此时的错误处理要复杂得多。

// packages/react-reconciler/src/ReactFiberWorkLoop.js:3675(源码带 export)
function captureCommitPhaseError(
  sourceFiber: Fiber,
  nearestMountedAncestor: Fiber | null,
  error: mixed
) {
  // 从出错的节点向上查找最近的 Error Boundary
  let fiber = nearestMountedAncestor;
  while (fiber !== null) {
    if (fiber.tag === ClassComponent) {
      const instance = fiber.stateNode;
      if (typeof instance.componentDidCatch === 'function') {
        // 找到了 Error Boundary
        // 在 boundary 上排一个 SyncLane 的 update 来显示 fallback UI
        const errorInfo = createCapturedValueAtFiber(error, sourceFiber);
        const update = createClassErrorUpdate(SyncLane);
        const root = enqueueUpdate(fiber, update, SyncLane);
        if (root !== null) {
          initializeClassErrorUpdate(update, root, fiber, errorInfo);
          markRootUpdated(root, SyncLane);
          ensureRootIsScheduled(root);
        }
        return;
      }
    }
    fiber = fiber.return;
  }

  // 冒泡到 HostRoot 仍没有 Error Boundary:走 captureCommitPhaseErrorOnRoot,
  // 在 root 上排一个 SyncLane 的 error update → 最终交给 root.onUncaughtError 并卸载整棵树
}

React 在 Commit 阶段的每一个关键操作都用 try-catch 包裹,确保单个组件的错误不会导致整个 Commit 过程中断(以 DOM 属性更新为例):

// ReactFiberCommitHostEffects.js:commitHostUpdate(v19.0.0)
try {
  commitUpdate(finishedWork.stateNode, finishedWork.type, oldProps, newProps, finishedWork);
} catch (error) {
  captureCommitPhaseError(finishedWork, finishedWork.return, error);
}

6.8.1 captureCommitPhaseError 的 Error Boundary 跃迁机制

§6.8 的 captureCommitPhaseError 伪代码简化了真实实现。翻真源(ReactFiberWorkLoop.js 里的同名函数、~50 行),关键步骤:

export function captureCommitPhaseError(
  sourceFiber: Fiber,
  nearestMountedAncestor: Fiber | null,
  error: mixed,
) {
  if (sourceFiber.tag === HostRoot) {
    // HostRoot 自身出错、没有 parent,由 root 自己捕获
    captureCommitPhaseErrorOnRoot(sourceFiber, sourceFiber, error);
    return;
  }

  let fiber = nearestMountedAncestor;
  while (fiber !== null) {
    if (fiber.tag === HostRoot) {
      // 一路冒泡到 root 仍没找到 boundary
      captureCommitPhaseErrorOnRoot(fiber, sourceFiber, error);
      return;
    } else if (fiber.tag === ClassComponent) {
      const ctor = fiber.type;
      const instance = fiber.stateNode;
      if (
        typeof ctor.getDerivedStateFromError === 'function' ||
        (typeof instance.componentDidCatch === 'function' &&
          !isAlreadyFailedLegacyErrorBoundary(instance))
      ) {
        // 找到 Error Boundary
        const errorInfo = createCapturedValueAtFiber(error, sourceFiber);
        const update = createClassErrorUpdate((SyncLane: Lane));
        const root = enqueueUpdate(fiber, update, (SyncLane: Lane));
        if (root !== null) {
          initializeClassErrorUpdate(update, root, fiber, errorInfo);
          markRootUpdated(root, SyncLane);
          ensureRootIsScheduled(root);
        }
        return;
      }
    }
    fiber = fiber.return;
  }

  if (__DEV__) {
    // 走到这里说明 return 链断了(fiber 被删了两次、提交了已完成的树……),是 React 的 bug
    console.error('Internal React error: Attempted to capture a commit phase error inside a detached tree. ...');
  }
}

function captureCommitPhaseErrorOnRoot(rootFiber: Fiber, sourceFiber: Fiber, error: mixed) {
  const errorInfo = createCapturedValueAtFiber(error, sourceFiber);
  const update = createRootErrorUpdate(rootFiber.stateNode, errorInfo, (SyncLane: Lane));
  const root = enqueueUpdate(rootFiber, update, (SyncLane: Lane));
  if (root !== null) {
    markRootUpdated(root, SyncLane);
    ensureRootIsScheduled(root);
  }
}

几个精妙之处:

① 两种 boundary 检测条件——getDerivedStateFromError(静态方法、推荐)或 componentDidCatch(实例方法、legacy)。两种都算 Error Boundary。

② isAlreadyFailedLegacyErrorBoundary 检查——只有 componentDidCatch、没有 getDerivedStateFromError 的 boundary,在本批次里已经捕获过一次错误后会被标记为 "failed"(packages/react-reconciler/src/ReactFiberThrow.js:168-174 的 markLegacyErrorBoundaryAsFailed)、不能再次用它捕获——错误会继续向上冒。这防止了同一个 legacy boundary 被反复用来接住错误、陷入重试循环;root 上没有剩余工作时这个集合会被清空。

③ HostRoot 兜底——如果一路到 HostRoot 没找到 boundary、就走 captureCommitPhaseErrorOnRoot,用 createRootErrorUpdate 在 root 上排一个 error update——最终触发 root.onUncaughtError 并卸载整棵树。这是 React 的最后一道防线——宁可全树重建也不让错误静默。

④ enqueueUpdate + SyncLane——boundary 上排一个同步优先级的 update、下次 render 会立刻把 boundary 切到 fallback UI。这就是 Error Boundary 的 fallback 机制——不是"立刻渲染 fallback"、而是"下次 render 时渲染 fallback"。

这种 "cascading up + fallback update" 的机制让 React 的错误处理既语义清晰(错误像异常一样冒泡、boundary 像 try/catch)、又不破坏 React 的单向数据流(boundary 通过 setState 切换 UI、不是直接修改 DOM)。

6.9 完整的 Commit 时间线

让我们将整个 Commit 阶段的流程串联起来,形成一个完整的时间线:

sequenceDiagram
    participant Scheduler as Scheduler
    participant Commit as commitRoot
    participant BM as BeforeMutation
    participant M as Mutation
    participant L as Layout
    participant Browser as 浏览器
    participant PE as Passive Effects
    Scheduler->>Commit: Render 完成,进入 Commit
    Note over Commit: 检查并调度 passive effects
    Commit->>BM: 第一阶段
    Note over BM: getSnapshotBeforeUpdate
    Note over BM: root.current → 旧树
    BM->>M: 第二阶段
    Note over M: commitPlacement (插入)
    Note over M: commitHostUpdate (更新)
    Note over M: commitDeletionEffects (删除)
    Note over M: useLayoutEffect cleanup
    Note over M: componentWillUnmount
    Note over M: 解绑旧 ref
    M->>M: root.current = finishedWork
    Note over M: 🔄 Fiber 树切换
    M->>L: 第三阶段
    Note over L: root.current → 新树
    Note over L: useLayoutEffect callback
    Note over L: componentDidMount
    Note over L: componentDidUpdate
    Note over L: 绑定新 ref
    Note over L: setState 回调
    L->>Browser: 同步执行完毕
    Browser->>Browser: 计算布局 & 绘制
    Browser->>PE: 异步回调
    Note over PE: useEffect destroy (全部)
    Note over PE: useEffect create (全部)

图 6-5:Commit 阶段完整时间线

6.10 实战:调试 Commit 阶段

理解 Commit 阶段的实现细节对于解决实际问题非常有帮助。以下是一些常见的调试场景:

场景一:useLayoutEffect 导致的性能问题

// ❌ 错误用法:在 useLayoutEffect 中做耗时计算
function HeavyComponent({ data }: { data: DataItem[] }) {
  const [processedData, setProcessedData] = useState<ProcessedItem[]>([]);

  useLayoutEffect(() => {
    // 这会阻塞浏览器绘制!
    const result = expensiveDataProcessing(data); // 可能需要 100ms+
    setProcessedData(result);
  }, [data]);

  return <Chart data={processedData} />;
}

// ✅ 正确用法:只在需要读取布局信息时使用 useLayoutEffect
function SmartComponent({ data }: { data: DataItem[] }) {
  const [processedData, setProcessedData] = useState<ProcessedItem[]>([]);

  // 数据处理用 useEffect(不阻塞绘制)
  useEffect(() => {
    const result = expensiveDataProcessing(data);
    setProcessedData(result);
  }, [data]);

  return <Chart data={processedData} />;
}

场景二:ref 回调的时机陷阱

function ListWithScroll({ items }: { items: string[] }) {
  const listRef = useRef<HTMLUListElement>(null);

  useLayoutEffect(() => {
    // ✅ 在 Layout 阶段,ref 已经绑定,DOM 已更新
    // 可以安全地读取和操作 DOM
    if (listRef.current) {
      listRef.current.scrollTop = listRef.current.scrollHeight;
    }
  }, [items]);

  useEffect(() => {
    // ⚠️ 在这里操作滚动位置也可以工作,
    // 但用户可能会先看到未滚动的状态(闪烁)
    if (listRef.current) {
      listRef.current.scrollTop = listRef.current.scrollHeight;
    }
  }, [items]);

  return (
    <ul ref={listRef}>
      {items.map((item, i) => (
        <li key={i}>{item}</li>
      ))}
    </ul>
  );
}

场景三:理解 Deletion 的清理顺序

function ParentComponent() {
  const [show, setShow] = useState(true);

  return (
    <div>
      {show && (
        <Outer>
          <Inner />
        </Outer>
      )}
      <button onClick={() => setShow(false)}>隐藏</button>
    </div>
  );
}

function Outer({ children }: { children: React.ReactNode }) {
  useLayoutEffect(() => {
    console.log('Outer layout mount');
    return () => console.log('Outer layout cleanup');
  }, []);

  useEffect(() => {
    console.log('Outer effect mount');
    return () => console.log('Outer effect cleanup');
  }, []);

  return <div>{children}</div>;
}

function Inner() {
  useLayoutEffect(() => {
    console.log('Inner layout mount');
    return () => console.log('Inner layout cleanup');
  }, []);

  useEffect(() => {
    console.log('Inner effect mount');
    return () => console.log('Inner effect cleanup');
  }, []);

  return <span>内部组件</span>;
}

// 当 show 变为 false 时的清理顺序:
// 1. Outer layout cleanup  (Mutation 阶段,deletion 是父先于子——
//                           commitDeletionEffectsOnFiber 先执行当前节点的
//                           layout destroy 再 recursivelyTraverseDeletionEffects
//                           下降;passive 侧的 commitPassiveUnmountEffectsInsideOfDeletedTree_begin
//                           同样顺序,源码注释原话:
//                           "Deletion effects fire in parent -> child order",
//                           见 packages/react-reconciler/src/ReactFiberCommitWork.js:3538)
// 2. Inner layout cleanup  (Mutation 阶段)
// 3. DOM 移除
// 4. Outer effect cleanup  (Passive 阶段,同样父先于子;本例由 click 触发、
//                           属 SyncLane,在 commit 收尾同步 flush)
// 5. Inner effect cleanup  (Passive 阶段)

注意区分:卸载(deletion)场景是父先于子;而更新场景(依赖变化触发的 effect cleanup)在 commitPassiveUnmountEffects 的常规子树遍历里执行,子先于父。两个方向相反、不要混淆。

6.11 concurrentlyUpdatedLanes 的并发更新合并

§6.6.5 提过 getConcurrentlyUpdatedLanes() 用来捕获 render 期间新触发的 lane。v19.0.0 的真实实现极其简单——直接返回模块级累加器,不会在 getter 里扫队列:

// packages/react-reconciler/src/ReactFiberConcurrentUpdates.js:48(v19.0.0)
let concurrentlyUpdatedLanes: Lanes = NoLanes;

export function getConcurrentlyUpdatedLanes(): Lanes {
  return concurrentlyUpdatedLanes;
}

// lanes 在 enqueueUpdate 时 merge,不在 getter 里扫描
function enqueueUpdate(fiber, queue, update, lane) {
  concurrentQueues[concurrentQueuesIndex++] = fiber;
  concurrentQueues[concurrentQueuesIndex++] = queue;
  concurrentQueues[concurrentQueuesIndex++] = update;
  concurrentQueues[concurrentQueuesIndex++] = lane;

  concurrentlyUpdatedLanes = mergeLanes(concurrentlyUpdatedLanes, lane);
  // ... 同步 merge fiber.lanes / alternate.lanes
}

concurrentQueues 是全局的 pending update 缓冲——hook 更新经 enqueueConcurrentHookUpdate 等路径先入队,再由 finishQueueingConcurrentUpdates 在安全点(如 prepareFreshStack)统一 flush 到各 Fiber 的 updateQueue;flush 时会把 concurrentlyUpdatedLanes 清回 NoLanes。

为什么要这样绕一圈?——因为 setState 发生时可能正有一次 render 在进行、Fiber 树处于半更新状态(workInProgress 树未完成)。源码在 concurrentQueues 定义处的注释写得很直接:如果 render 正在进行、又收到并发事件里的 update,要等这次 render 结束(完成或被打断)后,才把它加进 fiber / hook 的更新队列。否则正在进行的 render 可能只在一部分组件里读到这个 update。

concurrentQueues 是 render 过程中的 update 缓冲区——render 看不到这些 update、只在边界上统一应用。commit 时 getConcurrentlyUpdatedLanes O(1) 读已 merge 的 lanes——让 markRootFinished 知道「这些 lane 还没处理完」,update 本身留给下一次 render。

这是 concurrent mode 解决「render 期间用户交互」的核心——不是阻塞新交互、不是丢弃,而是先收着、在安全点统一应用。

6.12 ReactCurrentOwner.current = null:一行在 v19 里消失的重置

18.x 的 commitRootImpl 在进入子阶段前有一行很多旧笔记都会引用的代码:

// React 18.x(v19.0.0 里已不存在)
// Reset this to null before calling lifecycles
ReactCurrentOwner.current = null;

ReactCurrentOwner.current 当年是 React 追踪"当前正在渲染哪个组件"的全局指针:render 阶段每进入一个组件就把它设到那个 Fiber、退出时恢复;commit 前清零,是为了让生命周期执行时不再挂着上一次 render 残留的 owner(18 里 createElement 会把它记进 element._owner,供字符串 ref 和开发期警告使用)。

v19.0.0 的 commitRootImpl 里没有这一行——不是漏掉,而是这个全局指针本身从生产 runtime 里退场了:

  • 客户端的 ReactSharedInternals(packages/react/src/ReactSharedInternalsClient.js)只剩 H(Dispatcher)、A(AsyncDispatcher / Cache)、T(transition)、S(onStartTransitionFinish)等字段,没有 owner;
  • owner 只在 DEV 下以 element._owner / fiber._debugOwner 的形式存在——ReactChildFiber.js 里所有 existing._debugOwner = element._owner 都包在 __DEV__ 内,只服务于警告和组件栈。

所以 v19 commit 前真正的"环境清零"就是 §6.3.1 那三件:ReactSharedInternals.T = null、优先级临时切到 DiscreteEventPriority、executionContext |= CommitContext——退出时逐一 restore。

这种全局变量清零 / 配对恢复的小细节体现了 React 对 "每一个子阶段的环境变量都要干净" 的执着。也顺带提醒:读 React 源码笔记时先对一下 tag——ReactCurrentOwner、ReactCurrentDispatcher、ReactCurrentBatchConfig 这些名字在 19 里都并进了 ReactSharedInternals,或者干脆消失了。

6.13 本章小结

Commit 阶段是 React 将虚拟 DOM 的变更计划执行为真实 DOM 操作的关键环节。它的设计体现了一个核心原则:在不可逆的操作面前,确定性比灵活性更重要。

Render 阶段可以重来(并发渲染时还可以中断),因为它只是在内存中构建 Fiber 树,没有副作用。但 Commit 阶段的每一步都在修改用户可见的 DOM——所以它必须是同步的、有序的、不可中断的。

关键要点:

  1. 三个子阶段各有分工:BeforeMutation 拍快照、Mutation 改 DOM、Layout 处理副作用
  2. Fiber 树切换的时机:在 Mutation 和 Layout 之间,保证卸载方法看到旧世界、挂载方法看到新世界
  3. useLayoutEffect vs useEffect:前者同步于 Layout 阶段,后者默认经 Scheduler 异步执行(含 SyncLane 的更新则在 commit 收尾同步 flush)
  4. Deletion 是最复杂的操作:需要递归清理整棵子树的 effects、refs、生命周期
  5. Passive Effects 的批量执行:先全部 destroy,再全部 create,避免交叉执行的微妙问题
  6. 错误处理是安全网:每个关键操作都有 try-catch,确保单点失败不影响全局

在下一章中,我们将深入 Hooks 的内部实现——那些看似简单的 useState、useEffect 背后,隐藏着怎样精妙的链表结构和调度机制。

6.14 源码读完之后:Commit 阶段的十条工程原则

把本章所有源码观察收束成 React Commit 阶段体现的十条工程原则:

① 同步不可中断 vs 可中断的二分(§6.1)——DOM 操作是物理副作用、必须原子完成。

② do..while 排空异步任务(§6.2.1)——异步任务可能产生新任务、循环到干净为止。

③ 内部不变式的运行时断言(§6.2.2)——finishedWork !== root.current 在 commit 入口一票否决。

④ 全局状态的 save/restore(§6.3.1, §6.12)——commit 期间临时改的 transition/priority/executionContext、退出时必须还原。

⑤ 三子阶段的中间切换点(§6.5)——root.current = finishedWork 在 mutation 和 layout 之间、让卸载看旧、挂载看新。

⑥ selection/focus 透明保护(§6.5.1)——用户输入上下文跨 DOM 变更保持、零配置零打扰。

⑦ subtreeFlags + flags 双层位标记(§6.4.7)——子树聚合 + 节点自身分开记录、位运算快速跳过无 effect 子树。

⑧ concurrentQueues 缓冲区模式(§6.11)——render 期间的 setState 进全局 queue、边界上统一 flush、不污染半更新状态。

⑨ 跨时空快照(§6.7.1)——pendingPassiveEffectsRemainingLanes / Transitions 把 commit 时的状态保存给异步 flush 用。

⑩ Error Boundary 的 cascading-up + fallback update(§6.8.1)——错误冒泡找 boundary、找到后排同步 update 切 fallback、不直接改 DOM。

这十条横跨"同步保证、状态清理、位运算优化、异步缓冲、错误隔离"五个维度——每一条都是为了让 React 的 reactive 模型在浏览器不合作的约束下可靠运行。

读到这里再看 commitRoot 的主体代码——除去 profiling、DevTools 这类可观测性分支,主干上的语句几乎都能对上其中一条原则。

下一章进入 Hooks 内部、我们会看到同样的密度在 useState 的实现里被压缩得多紧。

6.15 和其他章节 / 其他专栏的呼应

与第 5 章(Reconciliation)的呼应——第 5 章讲"标记 Placement/Update/Deletion"、本章讲"消费这些标记执行 DOM 操作"。两章是计划层 vs 执行层的一对——对称但不对等(render 可中断、commit 不可)。

与第 7 章(Hooks)的呼应——本章提到的 useLayoutEffect 同步执行、useEffect 异步 schedule、都是 Hook 的消费侧。第 7 章会讲生产侧——这些 hook 在 render 阶段如何创建、如何建链表、如何被 effect 阶段扫描到。

与《vLLM 推理内核深度解析》专栏第 8 章(前向计算与 CUDA Graph)的呼应——React 的 "标记 + 批量执行"(Render 标记 flag、Commit 消费 flag)和 vLLM 的 "CPU 端先把输入填进 numpy 数组、再一次性搬到 GPU 做 forward" 有相通之处——都是先在一侧把要做的事准备好,再集中一次批量执行,避免零碎的小操作。

与《Hyper 与 Tower:工业级 HTTP 栈》专栏第 14 章的呼应——React 的 root.current = finishedWork 和 hyper 的 conn.try_keep_alive 都是 关键时间点的 atomic state flip——一个在 commit 中间、一个在 response 末尾——之后的逻辑必须看到新状态、之前的逻辑必须看到旧状态。这种 "atomic flip + 时间分界" 在状态机设计里是通用基建。

与《LangGraph 设计与实现》专栏第 10 章(Command 与高级控制流)的呼应——React Commit 的 error boundary 冒泡和 LangGraph 的 ParentCommand 冒泡是同构的错误路径——都是 "子节点错了、向上找处理者"。前端 JSX 树 / 后端图状态机都用 return 指针 / parent chain 做 cascading error。

与《Vite 设计与实现》专栏第 15 章(SSR / Module Runner)的呼应——vite 的 concurrentModuleNodePromises Map 做并发请求合并、React 的 concurrentQueues 做并发 update 合并——都是"同一瞬间多个请求/操作、用一个 map/队列合并"的模式。前端模块加载 / React state 更新 / hyper WINDOW_UPDATE 聚合——三层不同抽象下的同一种工程直觉。


思考题

  1. 为什么 root.current = finishedWork 这行代码放在 Mutation 和 Layout 之间,而不是放在 Commit 阶段的最开始或最末尾? 如果放在其他位置,会导致哪些生命周期方法读取到不正确的值?

  2. React 为什么选择在 Mutation 阶段执行 useLayoutEffect 的清理函数,而不是在 BeforeMutation 阶段? 考虑一个场景:清理函数需要读取即将被删除的 DOM 节点的尺寸信息。

  3. 在 getHostSibling 的实现中,为什么需要跳过同样被标记为 Placement 的节点? 构造一个具体的 JSX 结构来说明如果不跳过会导致什么问题。

  4. 假设一个组件的 useEffect 清理函数中调用了 setState,这个更新会在什么时候被处理? 追踪这个更新的调度路径。