Skip to content

第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 后面。如果在执行完第一步之后被中断了会怎样?

tsx
// 列表更新:[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 节点上。

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

6.2 commitRoot 的整体结构

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

typescript
// 简化版 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.3 BeforeMutation 阶段:最后的准备

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

typescript
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 变更之前"拍一张快照"。最经典的用例是保存滚动位置:

tsx
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.4 Mutation 阶段:真正的 DOM 操作

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

typescript
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.1 Placement:节点插入

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

typescript
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);
  }
}

基于 VitePress 构建