Appearance
第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] 更新为 [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 树的构建那样可以随时丢弃重来——每一次 appendChild、removeChild、insertBefore 都会立即修改用户可见的 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 之前。这意味着:
- 在 BeforeMutation 和 Mutation 阶段,
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);
}
}