React 19 内核探秘

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

作者 杨艺韬 · 12,224 字

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

本章要点

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

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

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

6.1 为什么 Commit 必须同步

想象一个简单的场景:你要把列表从 [A, B, C] 更新为 [A, C, B]。这需要两步 DOM 操作:移动 C 到 B 前面,或者移动 B 到 C 后面。如果在执行完第一步之后被中断了会怎样?

// 列表更新:[A, B, C] → [A, C, B]
// 需要的 DOM 操作:
// 1. 将 C 移动到 B 之前
// 2. (或者等价地) 将 B 移动到最后

// 如果在步骤1之后被中断:
// 用户看到的是 [A, C, B, C] 还是 [A, C] ?
// 无论哪种,都是错误的中间状态

这就是 Commit 阶段必须同步执行的根本原因。DOM 操作不像 Fiber 树的构建那样可以随时丢弃重来——每一次 appendChildremoveChildinsertBefore 都会立即修改用户可见的 DOM 树。中间状态的暴露不仅会导致视觉闪烁,更可能引起布局抖动(Layout Thrashing),在严重情况下甚至会导致事件处理器绑定到错误的 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 的入口,让我们看看它的核心结构:

// 简化版 commitRoot
function commitRoot(root: FiberRoot) {
  const finishedWork = root.finishedWork;
  if (finishedWork === null) return;

  // 重置状态
  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 & MutationMask | LayoutMask | PassiveMask) !== NoFlags;
  const rootHasEffect =
    (finishedWork.flags & MutationMask | LayoutMask | PassiveMask) !== NoFlags;

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

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

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

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

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

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

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

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

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

打开 ReactFiberWorkLoop.js:2628-2636commitRootImpl 最最一开始有一个看似无害但内涵极深的循环:

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.
  // Maintenance note: it may be better if `flushPassiveEffects` did not
  // automatically flush synchronous work at the end, to avoid factoring hazards.
  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

注释里的维护标记是 React 团队对自己设计的自我批评——理想情况下 flushPassiveEffects 不应该”附带 flush sync queue”、这个耦合让代码需要写这种循环。但当前架构已经深度依赖这个行为、短期内没法解耦。这种维护注释是 React 源码诚实暴露技术债的典型。

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

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

commitRootImpl 里的一道关键 assert(line 2681-2686):

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 上反复走、指针相互覆盖、最后整棵树乱套——用户看到的表象可能是”页面莫名其妙消失”或”点击响应错位”——debug 到根因要几天

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

这个检查每次 commit 都跑一遍——几十纳秒的开销、但保证了 双缓冲不变式从未被破坏。这类”便宜的运行时断言守护核心不变式”是 Rust / React / 其他大框架共同的工程习惯——和 §14.6.1 hyper 的 assert!(max >= MIN)、§16.3.2.5 h2 的 assert!(window >= sz) 都是同一种工程直觉。

6.3 BeforeMutation 阶段:最后的准备

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

function commitBeforeMutationEffects(
  root: FiberRoot,
  firstChild: Fiber
) {
  nextEffect = firstChild;
  // 深度优先遍历 Fiber 树
  commitBeforeMutationEffects_begin();
}

function commitBeforeMutationEffects_complete() {
  while (nextEffect !== null) {
    const fiber = nextEffect;

    try {
      commitBeforeMutationEffectsOnFiber(fiber);
    } catch (error) {
      captureCommitPhaseError(fiber, fiber.return, error);
    }

    const sibling = fiber.sibling;
    if (sibling !== null) {
      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) {
          const prevProps = current.memoizedProps;
          const prevState = current.memoizedState;
          const instance = finishedWork.stateNode;
          // 调用 getSnapshotBeforeUpdate
          const snapshot = instance.getSnapshotBeforeUpdate(
            finishedWork.elementType === finishedWork.type
              ? prevProps
              : resolveDefaultProps(finishedWork.type, prevProps),
            prevState
          );
          // 保存快照值,后续在 componentDidUpdate 中作为第三个参数传入
          instance.__reactInternalSnapshotBeforeUpdate = snapshot;
        }
        break;
      }
      case HostRoot: {
        // 如果 root 的容器需要清空内容
        if (supportsMutation) {
          const container = finishedWork.stateNode.containerInfo;
          clearContainer(container);
        }
        break;
      }
    }
  }
}

这个阶段最重要的工作就是调用 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 2760-2766):

const prevTransition = ReactCurrentBatchConfig.transition;
ReactCurrentBatchConfig.transition = null;
const previousPriority = getCurrentUpdatePriority();
setCurrentUpdatePriority(DiscreteEventPriority);

const prevExecutionContext = executionContext;
executionContext |= CommitContext;

三个关键动作:

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

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

executionContext |= CommitContext——设置 execution context 的 CommitContext 位。这是个全局状态、让其他代码能知道”现在处于 commit 中”——比如某些错误处理会根据 CommitContext 判断是否用 captureCommitPhaseError 而不是常规错误路径。

为什么要保存 prev 然后 restore?——因为 commit 可能被嵌套调用(比如 cDM 里 flushSync)、不 restore 会污染外层调用的状态。每一层都自己管好”进入时保存、退出时恢复”。

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

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

6.4 Mutation 阶段:真正的 DOM 操作

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

function commitMutationEffects(
  root: FiberRoot,
  firstChild: Fiber
) {
  nextEffect = firstChild;
  commitMutationEffects_begin(root);
}

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

  switch (flags & (Placement | Update | ChildDeletion | Hydrating)) {
    case Placement: {
      // 新节点插入
      commitPlacement(finishedWork);
      finishedWork.flags &= ~Placement;
      break;
    }
    case PlacementAndUpdate: {
      // 先插入,再更新
      commitPlacement(finishedWork);
      finishedWork.flags &= ~Placement;
      commitWork(current, finishedWork);
      break;
    }
    case Update: {
      // 属性更新
      commitWork(current, finishedWork);
      break;
    }
    case ChildDeletion: {
      // 子节点删除(注意:deletion 标记在父节点上)
      const deletions = finishedWork.deletions;
      if (deletions !== null) {
        for (let i = 0; i < deletions.length; i++) {
          const childToDelete = deletions[i];
          commitDeletion(root, childToDelete, finishedWork);
        }
      }
      break;
    }
  }
}

6.4.0 Mutation 阶段的深度优先 + 回溯模式

commitMutationEffects_begin / _complete 这对函数(真实实现在 ReactFiberCommitWork.js)用了一种手写的深度优先遍历 + 回溯模式、而不是递归:

function commitMutationEffects_begin(root: FiberRoot) {
  while (nextEffect !== null) {
    const fiber = nextEffect;
    const deletions = fiber.deletions;
    if (deletions !== null) {
      for (const childToDelete of deletions) {
        commitDeletion(root, childToDelete, fiber);
      }
    }

    const child = fiber.child;
    if ((fiber.subtreeFlags & MutationMask) !== NoFlags && child !== null) {
      // 有子树 effect、下降
      child.return = fiber;
      nextEffect = child;
    } else {
      // 没子树 effect 或无子节点、处理自己再回溯
      commitMutationEffects_complete(root);
    }
  }
}

为什么不用递归?——因为 Fiber 树可能非常深(React 树超过 100 层深度不罕见)——JS 引擎的调用栈有限(V8 默认约 10000 帧)、递归超深会栈溢出。

手写迭代 + 用 Fiber 的 return 指针代替调用栈——把状态存在堆上的 Fiber 节点里、而不是运行时栈。这样即使树有几万层、也不会栈溢出。

nextEffect模块级全局变量——作为当前遍历游标。为什么不用局部变量传递?——因为 commit 过程中可能抛错、catch 里要能继续从当前游标处恢复、全局 nextEffect 让错误恢复不需要额外状态传递

subtreeFlags & MutationMask 判断是否要下降——如果子树里没有 mutation effect、直接跳过整棵子树、不做无用遍历。这就是 §6.4.4 讲的 subtreeFlags 位标记的核心价值——让遍历不做白工

这种 “手写迭代 + 全局游标 + 位标记跳过” 的模式性能远超递归——React 17 → 18 的性能提升里、这套遍历优化贡献不小。

6.4.1 Placement:节点插入

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

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. 执行插入
  if (before) {
    insertBefore(parent, finishedWork.stateNode, before);
  } else {
    appendChild(parent, finishedWork.stateNode);
  }
}

其中最复杂的部分是 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 兄弟:

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
    while (node.tag !== HostComponent && node.tag !== HostText) {
      // 如果这个节点也是 Placement,跳过它(它还没有被插入 DOM)
      if (node.flags & Placement) {
        continue siblings;
      }
      if (node.child === null) {
        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.1.5 getHostSibling 的 “跳过 Placement” 规则深解

§6.4.1 展示的 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。

这就让 commit 的插入顺序必须有特定规则——多个 Placement 兄弟节点按从右到左的顺序插入

假设有 <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 一直稳定。这让整个插入过程不依赖 A 和 B 的处理顺序——交换 A 和 B 的处理、最终结果一样。

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

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

6.4.2 Update:属性更新

当节点被标记为 Update,React 需要更新它的属性。在 Render 阶段的 completeWork 中,React 已经预先计算好了一个 updateQueue(对于 HostComponent 来说,这是一个 [propKey1, propValue1, propKey2, propValue2, ...] 格式的数组):

function commitUpdate(
  domElement: Element,
  updatePayload: Array<mixed>,
  type: string,
  oldProps: Props,
  newProps: Props
) {
  // 应用预计算的属性差异
  updateProperties(domElement, updatePayload, type, oldProps, newProps);

  // 更新 Fiber 上缓存的 props
  updateFiberProps(domElement, newProps);
}

function updateProperties(
  domElement: Element,
  updatePayload: Array<mixed>,
  tag: string,
  lastRawProps: Props,
  nextRawProps: Props
) {
  // 两两一组处理 updatePayload
  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 === DANGEROUSLY_SET_INNER_HTML) {
      setInnerHTML(domElement, propValue);
    } else if (propKey === CHILDREN) {
      setTextContent(domElement, propValue);
    } else {
      setValueForProperty(domElement, propKey, propValue);
    }
  }
}

这种”预计算差异 + 批量应用”的模式是一个重要的优化。在 Render 阶段(可以被中断的),React 已经做好了”哪些属性变了”的计算。到了 Commit 阶段(不可中断的),只需要机械地将这些变更应用到 DOM 上,尽可能减少同步阶段的工作量。

6.4.2.5 updatePayload 作为数组的编码选择

updatePayload 不是对象 { prop1: value1, prop2: value2 }、而是扁平数组 [prop1, value1, prop2, value2, ...]——这个选择有具体原因:

① 内存紧凑——数组在 V8 里存为连续的 FixedArray、几乎没有 overhead。对象有 hidden class、每个 property 有 shape 管理——小对象的 overhead 能达到 50%+。

② 迭代更快——for loop 的 i += 2for..in 枚举对象 property 快 3-5 倍(v8 微基准)。commit 阶段在热路径上、微秒级的差别累积成帧级的差别

③ 避免 hidden class 碎片化——每个组件的 update 属性集可能不同、如果用对象、V8 会为每个不同 shape 生成新的 hidden class、megamorphic 后整体性能骤降。数组不受影响。

④ 便于 batch 处理——Rect 的 updatePayload 可以无障碍地被 Uint32Array 或类似 TypedArray 替代(future work)——只有扁平数组能这样做。

代价是 可读性稍差——updatePayload[i+1] 不如 updatePayload.value 直观。但在 React 这种 runtime 热路径、数据结构选型优先于可读性。内部代码给 React 团队读、用户根本看不到 updatePayload。

这种 “扁平数组代替对象” 的性能模式在前端性能库里非常常见——React 自己的 Fiber 某些字段也考虑过(最后没改、因为 Fiber 本身太重)、Vue 3 的 vnode props 也对小对象特别处理、SolidJS / Svelte 这些 reactive 框架更是把 “不用对象” 作为核心设计。

6.4.3 Deletion:节点删除

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

function commitDeletion(
  root: FiberRoot,
  childToDelete: Fiber,
  nearestMountedAncestor: Fiber
) {
  let node = childToDelete;

  // 递归遍历被删除子树的每一个节点
  while (true) {
    commitUnmount(root, node, nearestMountedAncestor);

    if (node.child !== null) {
      node.child.return = node;
      node = node.child;
      continue;
    }

    if (node === childToDelete) {
      return;
    }

    while (node.sibling === null) {
      if (node.return === null || node.return === childToDelete) {
        return;
      }
      node = node.return;
    }

    node.sibling.return = node.return;
    node = node.sibling;
  }
}

function commitUnmount(
  root: FiberRoot,
  current: Fiber,
  nearestMountedAncestor: Fiber
) {
  switch (current.tag) {
    case FunctionComponent: {
      // 执行 useEffect 和 useLayoutEffect 的清理函数
      const updateQueue = current.updateQueue;
      if (updateQueue !== null) {
        const lastEffect = updateQueue.lastEffect;
        if (lastEffect !== null) {
          const firstEffect = lastEffect.next;
          let effect = firstEffect;
          do {
            const { destroy, tag } = effect;
            if (destroy !== undefined) {
              if ((tag & HookLayout) !== NoFlags) {
                // useLayoutEffect 的清理函数 —— 同步执行
                safelyCallDestroy(current, nearestMountedAncestor, destroy);
              }
              // useEffect 的清理函数会在后续异步执行
            }
            effect = effect.next;
          } while (effect !== firstEffect);
        }
      }
      break;
    }
    case ClassComponent: {
      // 解绑 ref
      safelyDetachRef(current, nearestMountedAncestor);
      // 调用 componentWillUnmount
      const instance = current.stateNode;
      if (typeof instance.componentWillUnmount === 'function') {
        safelyCallComponentWillUnmount(
          current,
          nearestMountedAncestor,
          instance
        );
      }
      break;
    }
    case HostComponent: {
      // 解绑 ref
      safelyDetachRef(current, nearestMountedAncestor);
      break;
    }
    // ... 其他类型
  }
}

注意删除的执行顺序:先递归地对子树中的每个节点执行 commitUnmount(调用清理函数、解绑 ref),最后才从 DOM 中移除整棵子树的根节点。这确保了清理函数在 DOM 移除之前执行,组件仍然可以在清理函数中读取 DOM 信息。

graph TD
    A["commitDeletion 开始"] --> 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(异步)"]

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

6.4.4 subtreeFlagsflags 双层检查的意义

commitRoot 里判断”是否有任何 effect 要处理”的代码(line 2750-2757):

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 16 → 17 时从”effect list(单独一条链表)“替换过来的设计——不再维护一条指针链表、而是在每个 Fiber 上冗余记录”我的子树里有什么 flag”。这样:

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

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

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

这种 “位标记 + 子树聚合” 的设计和 vite 第 15 章的 mod.transformResult 作为缓存标记、hyper 第 14 章的 KA::Busy/Idle/Disabled 三态是同一种思路——把状态信息压缩到最紧凑的 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 2803):

resetAfterCommit(root.containerInfo);

这个函数做什么?看 react-dom 的 HostConfig 实现:

export function resetAfterCommit(containerInfo: Container): void {
  restoreSelection(selectionInformation);
  setCurrentUpdatePriority(currentUpdatePriority);
  currentUpdatePriority = NoEventPriority;
  eventsEnabled = true;
  selectionInformation = null;
}

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

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

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

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

这对用户而言是透明的——用户不知道 React 在 DOM 下面做了什么。但如果关掉这个机制、每一次 state 更新用户都会失去输入焦点——在表单密集型应用里用户会疯掉。

setCurrentUpdatePriority 在这里也被恢复——§6.3.1 提到 commit 开始时临时升级优先级、commit 结束后恢复原值。这些 “commit 开始保存、结束还原” 的状态都是 React 作为”无副作用地嵌入用户代码”所必需的——commit 期间看起来改了很多全局状态、退出时必须全部恢复、这样外层代码感受不到任何残留影响。

6.6 Layout 阶段:DOM 已就绪

Layout 阶段是 Commit 的最后一幕。此时 DOM 已经被修改完成,但浏览器尚未进行重绘(因为 JavaScript 仍在同步执行)。这是一个特殊的窗口期——你可以安全地读取 DOM 布局信息(如 offsetHeightgetBoundingClientRect()),而不会触发额外的回流。

function commitLayoutEffects(
  finishedWork: Fiber,
  root: FiberRoot
) {
  nextEffect = finishedWork;
  commitLayoutEffects_begin(finishedWork, root);
}

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

  switch (finishedWork.tag) {
    case FunctionComponent: {
      // 执行 useLayoutEffect 的回调
      if (flags & LayoutMask) {
        commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);
      }
      break;
    }
    case ClassComponent: {
      const instance = finishedWork.stateNode;
      if (flags & Update) {
        if (current === null) {
          // 首次挂载 → componentDidMount
          instance.componentDidMount();
        } else {
          // 更新 → componentDidUpdate
          const prevProps = current.memoizedProps;
          const prevState = current.memoizedState;
          instance.componentDidUpdate(
            prevProps,
            prevState,
            instance.__reactInternalSnapshotBeforeUpdate
          );
        }
      }

      // 处理 setState 的回调函数
      const updateQueue = finishedWork.updateQueue;
      if (updateQueue !== null) {
        commitUpdateQueue(finishedWork, updateQueue, instance);
      }
      break;
    }
    case HostComponent: {
      // 对于 HostComponent,处理 autoFocus 等
      if (current === null && flags & Update) {
        const instance = finishedWork.stateNode;
        commitMount(instance, finishedWork.type, finishedWork.memoizedProps);
      }
      break;
    }
  }

  // 绑定 ref
  if (flags & Ref) {
    commitAttachRef(finishedWork);
  }
}

6.6.0 enableProfilerTimer 条件编译与 recordCommitTime

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

if (enableProfilerTimer) {
  recordCommitTime();
}

if (enableProfilerTimer && enableProfilerNestedUpdateScheduledHook) {
  rootCommittingMutationOrLayoutEffects = root;
}

enableProfilerTimer 是 React 的 feature flag——ReactFeatureFlags.js 里定义、整个 build 期固定。所有被它守卫的代码在生产 build 里会被 tree-shake 掉——零运行时开销。

开发 build 开它、让 <Profiler> 组件能收集精确的 commit 时间戳。生产 build 关它、节省几 kb 包体积 + 几纳秒 per commit 的 if 判断。

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

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

6.6.0.5 rootCommittingMutationOrLayoutEffects 全局变量的作用

这个罕见变量只在一处被读(后续某个 effect 回调里)、表示”当前 root 是否正在 commit 的 mutation/layout 阶段”。配合另一个 flag:

if (enableProfilerTimer && enableProfilerNestedUpdateScheduledHook) {
  rootCommittingMutationOrLayoutEffects = root;
}

enableProfilerNestedUpdateScheduledHook——专门检测”在 commit 期间触发的新 update”的 hook、用来给 Profiler 提供 “这次 update 是被 xxx 组件的 cDM 间接触发的” 这样的因果信息。

profiler 以此把 update 归类为 “nested”(嵌套)或 “top-level”——给开发者看 React DevTools 时的 flame chart 精度更高。

这是一条 高度特化的 observability 通道——99.99% 用户完全不知道它存在。但当 DevTools 用户勾”profile rendering”、这些状态变量就开始记录、最终转为可视化。React 在代码里埋了无数这样的挂钩——对普通用户零成本、对深度用户强大的内省能力。

6.6.1 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.2 Ref 的绑定

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

  1. Mutation 阶段:如果旧的 ref 存在,先解绑旧 ref(设为 null)
  2. Layout 阶段:绑定新的 ref(设为 DOM 节点或组件实例)
function commitAttachRef(finishedWork: Fiber) {
  const ref = finishedWork.ref;
  if (ref !== null) {
    const instance = finishedWork.stateNode;

    if (typeof ref === 'function') {
      // 回调 ref
      ref(instance);
    } else {
      // createRef / useRef
      ref.current = instance;
    }
  }
}

这个两步策略保证了 ref 的生命周期是清晰的:旧 ref 在 DOM 变更后被清空,新 ref 在 DOM 变更完成后被设置。

6.6.3 markRootFinished 与 lanes 的释放语义

commitRootImpl 在进入子阶段前有一行(line 2703):

markRootFinished(root, remainingLanes);

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

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

第三部分是 React 18 concurrent mode 引入的——在 render 执行过程中、用户可能又触发了新 setState(比如点了按钮)、这些新 setState 创建了新的 lane、但这次 render 没来得及处理它们。markRootFinished 不能把这些 lane 当作”已完成”——它们还要等下一次 render。

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

这种精确的 lane 管理是 React 18 能支持 concurrent render 的关键——commit 过程中触发的新 setState 不会被 commit 吞掉、而是被保留到下一次 render 循环。

这种”渐进完成 + 持续调度”的模型和 LangGraph 第 14 章讲的 execution_info patch-based 更新是同构的——两者都是”大的整体操作里用细粒度状态字段追踪每个子部分的进度”、让中断-恢复可行。

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

commitRootImpl 里(line 2690-2692):

// 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 / callbackPriority 非 null
  • 没有 callback → 全部 null
  • commit 开始 = callback 开始执行 = 进入”没有 pending callback”状态

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

6.7 Passive Effects:useEffect 的异步调度

useEffect 的执行时机与 useLayoutEffect 完全不同。它不在 Commit 阶段的三个子阶段中同步执行,而是被异步调度到浏览器绘制之后:

// 在 commitRoot 的开头,如果检测到有 passive effects
if (
  (finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
  (finishedWork.flags & PassiveMask) !== NoFlags
) {
  scheduleCallback(NormalSchedulerPriority, () => {
    flushPassiveEffects();
    return null;
  });
}

flushPassiveEffects 会在浏览器完成绘制后执行所有 useEffect 的清理函数和回调函数:

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

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

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

注意执行顺序:先执行所有的 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 的 destroy 中清理了某个全局事件监听器,而 B 的 create 需要添加同一个监听器,逐个执行可能导致 B 的 create 先于 A 的 destroy 执行(取决于组件树的顺序),造成重复监听。

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: 绘制
    Browser->>Effects: 浏览器空闲时回调
    Effects->>Effects: 执行所有 useEffect destroy
    Effects->>Effects: 执行所有 useEffect create

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

6.7.1 pendingPassiveEffectsRemainingLanespendingPassiveTransitions 的跨时空快照

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

if (!rootDoesHavePassiveEffects) {
  rootDoesHavePassiveEffects = true;
  pendingPassiveEffectsRemainingLanes = remainingLanes;
  // 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();
    return null;
  });
}

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

为什么不直接读 remainingLanestransitions——因为这两个变量在下一次 render 开始时会被覆盖。flushPassiveEffects 跑的时候、可能已经是下下一次 render 的中期——remainingLanes 指向不同的 lane 集合、transitions 指向不同的 transition。

把快照保存在模块级全局变量里、让 flushPassiveEffects 闭包访问到正确的值——这是 React 在 concurrent mode 下管理异步状态的标准姿势。

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

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

这就是 React 18 “可中断渲染” 背后的状态管理开销——每个可被延迟的阶段都要自带时间戳快照、不能假设”我跑的时候全局状态还是我刚离开时的样子”。这种严苛的隔离让 concurrent mode 能工作、但也带来了代码复杂度。

6.8 错误处理与错误边界

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

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
        // 调度一次更新来显示 fallback UI
        const update = createClassErrorUpdate(fiber, error);
        enqueueUpdate(fiber, update);
        const root = markUpdateLaneFromFiberToRoot(fiber);
        if (root !== null) {
          ensureRootIsScheduled(root);
        }
        return;
      }
    }
    fiber = fiber.return;
  }

  // 如果没有找到 Error Boundary,这是一个未捕获的错误
  // React 会卸载整棵组件树
}

React 在 Commit 阶段的每一个关键操作都用 try-catch 包裹,确保单个组件的错误不会导致整个 Commit 过程中断:

try {
  commitBeforeMutationEffectsOnFiber(fiber);
} catch (error) {
  captureCommitPhaseError(fiber, fiber.return, error);
}

6.8.1 captureCommitPhaseError 的 Error Boundary 跃迁机制

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

function captureCommitPhaseError(
  sourceFiber: Fiber,
  nearestMountedAncestor: Fiber | null,
  error: mixed,
) {
  if (sourceFiber.tag === HostRoot) {
    // HostRoot 自身出错、直接标记 root 为 errored
    const errorInfo = createCapturedValue(error, sourceFiber);
    const update = createRootErrorUpdate(
      sourceFiber,
      errorInfo,
      SyncLane,
    );
    enqueueUpdate(sourceFiber, update, SyncLane);
    // ...
    return;
  }

  let fiber = nearestMountedAncestor;
  while (fiber !== null) {
    if (fiber.tag === HostRoot) {
      // 一路冒泡到 root 仍没找到 boundary
      const errorInfo = createCapturedValue(error, sourceFiber);
      const update = createRootErrorUpdate(fiber, errorInfo, SyncLane);
      enqueueUpdate(fiber, update, SyncLane);
      // ...
      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 = createCapturedValue(error, sourceFiber);
        const update = createClassErrorUpdate(
          fiber,
          errorInfo,
          SyncLane,
        );
        enqueueUpdate(fiber, update, SyncLane);
        // ...
        return;
      }
    }
    fiber = fiber.return;
  }
}

几个精妙之处:

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

isAlreadyFailedLegacyErrorBoundary 检查——如果某个 componentDidCatch boundary 自己抛错过、它被标记为 “failed”、不能再次用它捕获——错误会继续向上冒。这防止了 “boundary 的 componentDidCatch 写错导致无限捕获” 的死循环。

③ HostRoot 兜底——如果一路到 HostRoot 没找到 boundary、就在 root 上排一个 error update——会让整棵树 unmount 重建。这是 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: commitWork (更新)
    Note over M: commitDeletion (删除)
    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. Inner layout cleanup  (Mutation 阶段,深度优先,子先于父)
// 2. Outer layout cleanup  (Mutation 阶段)
// 3. DOM 移除
// 4. Inner effect cleanup  (异步 passive effects)
// 5. Outer effect cleanup  (异步 passive effects)

6.10.4 concurrentlyUpdatedLanes 的并发更新合并

§6.6.3 提过 getConcurrentlyUpdatedLanes() 用来捕获 render 期间新触发的 lane。这函数的内部实现揭示了 React 18 concurrent mode 最巧妙的设计之一:

// 伪代码、基于 ReactFiberConcurrentUpdates.js 真实实现
function getConcurrentlyUpdatedLanes(): Lanes {
  let lanes = NoLanes;
  for (const update of concurrentQueues) {
    lanes = mergeLanes(lanes, update.lane);
  }
  return lanes;
}

concurrentQueues 是一个全局的 pending update 列表——render 期间任何 setState 不直接 enqueue 到 Fiber 的 updateQueue、而是先 enqueue 到这个全局 concurrentQueues、然后在 render 完成时统一 flush 到各自 Fiber 的 updateQueue。

为什么要这样绕一圈?——因为 render 期间 Fiber 树的状态是半更新的(workInProgress 树未完、current 树还是旧的)。如果新 setState 直接改 Fiber 的 updateQueue、可能:

  • 改到 workInProgress 上——被 commit 覆盖到 current、state 丢失
  • 改到 current 上——下次 render 读 current 时多了不该有的 update

concurrentQueues 是 “render 过程中的 update 缓冲区——render 看不到这些 update、只在 render 边界上统一应用。commit 时通过 getConcurrentlyUpdatedLanes 读 lanes 但不读 update——让 markRootFinished 知道”这些 lane 还没处理完”、但 update 本身留给下一次 render 再处理。

这是 React 18 解决 “render 期间用户交互” 的核心——不是阻塞新交互(破坏响应性)、不是丢弃新交互(错误)、而是 “先收着、在安全点统一应用”。这个设计跨越 Fiber、Scheduler、Commit 三层、是 React 18 concurrent mode 的基石。

6.10.5 ReactCurrentOwner.current = null 的重置

commitRootImpl 在进入子阶段前(line 2769):

// Reset this to null before calling lifecycles
ReactCurrentOwner.current = null;

ReactCurrentOwner.current 是 React 追踪”当前正在渲染哪个组件”的全局指针。render 阶段、每进入一个组件的 render 函数就把它设到那个 Fiber、退出时恢复。

为什么 commit 前要重置?——因为 render 已结束、但 commit 里会调用各种生命周期方法(componentDidMount、useLayoutEffect、…)。如果 ReactCurrentOwner 还指向上次 render 的某个组件、而这个组件在生命周期里 setState、这个 setState 就会被误归到 ReactCurrentOwner 指向的那个组件——产生 warning 或错误行为。

重置为 null 明确表示 “此刻没有特定 owner、setState 自己说是哪个组件发起的”。commit 里的 setState 本身会在那个组件自己的 Fiber 上触发更新——不需要依赖 ReactCurrentOwner。

这种全局变量清零的小细节体现了 React 对 “每一个子阶段的环境变量都要干净” 的执着——和 §6.3.1 讲的 transition / priority / executionContext 临时切换是同样思路。React 源码里这种 xxx.current = null 的清零几乎总是配对、保证状态机不漏状态。

6.11 本章小结

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

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

关键要点:

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

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

6.12 源码读完之后: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.10.5)——commit 期间临时改的 transition/priority/owner、退出时必须还原。

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

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

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

concurrentQueues 缓冲区模式(§6.10.4)——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 的主体代码——你会发现每一行都对应一条原则的落地、没有一行是装饰性的。这就是工业级 runtime 的密度——简短但每字必要。

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

6.13 和其他章节 / 其他书的呼应

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

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

与 vllm 第 8 章的呼应——React 的 “标记 + 批量执行”(Render 标记 flag、Commit 消费 flag)和 vllm 的 “CPU 端 numpy 打包、GPU 端一次 forward” 是同一种”划分快慢阶段”思路——把能重的工作尽量前置到可随时中断/重来的阶段、把必须确定的工作压到不可中断的阶段。前端和后端同一条优化哲学

与 hyper-tower 第 14 章的呼应——React 的 root.current = finishedWork 和 hyper 的 conn.try_keep_alive 都是 关键时间点的 atomic state flip——一个在 commit 中间、一个在 response 末尾——之后的逻辑必须看到新状态、之前的逻辑必须看到旧状态。这种 “atomic flip + 时间分界” 在状态机设计里是通用基建

与 LangGraph 第 14 章(Runtime)的呼应——React Commit 的 error boundary 冒泡和 LangGraph 的 ParentCommand 冒泡是同构的错误路径——都是 “子节点错了、向上找处理者”。前端 JSX 树 / 后端图状态机都用 return 指针 / parent chain 做 cascading error。

与 Vite 第 15 章(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,这个更新会在什么时候被处理? 追踪这个更新的调度路径。