React 源码探秘(四):commit 阶段,DOM 更新的最后一公里
前面三篇我们走完了两条路:JSX 变成 ReactElement(第一篇),ReactElement 变成 Fiber 树(第二篇),以及 render 阶段 Reconciler 如何对比新旧 Fiber、打上增删改的标记(第三篇)。但别忘了——render 阶段做完这一切,屏幕上什么都没有变。真正把虚拟 DOM 的差异落到浏览器 DOM 上的,是 commit 阶段。这一篇我们就来拆解这「最后一公里」。
从 render 到 commit:一条调用链
render 阶段结束时,Fiber 树上的节点带着两类信息:
- effectTag(React 17 及更早)/ flags(React 18):Placement、Update、Deletion 等,标记了这个节点在 commit 时需要做什么;
- updateQueue:需要执行的 effect(生命周期、hooks 副作用等)。
随后 performConcurrentWorkOnRoot 走到 commitRoot,把控制权交给 commit 阶段。render 阶段是"纯计算",可以被打断、重来;commit 阶段则同步、不可中断——因为它要操作真实 DOM,一旦中断,界面就会处于不一致的中间状态。
effectList:一条只含"有副作用节点"的链表
一个常见的误区是:commit 阶段会遍历整棵 Fiber 树,挨个检查谁有 flags。React 没这么笨。
render 阶段在 completeWork 归并时,会维护一条 effectList(副作用链表):只有带 flags 的 Fiber 才会被串进链表。它像拉链一样把树上有变化的节点挑出来:
JAVASCRIPT// completeUnitOfWork 中的归并逻辑(简化)
if (returnFiber.flags !== NoFlags) {
// 把当前节点的 effectList 接到父节点上
} else {
returnFiber.firstEffect = completedWork.firstEffect;
}
root.firstEffect 就是整条链表的头。commit 阶段只需沿这条链表走一遍,没变化的节点完全不用碰。这也是为什么"大树的局部小更新"依然很快——复杂度只和实际变化的节点数有关。
commitRoot:三个子阶段
打开 ReactFiberWorkLoop.js,commitRoot 的核心逻辑清晰分为三幕:
1. before mutation 阶段(commitBeforeMutationEffects)
DOM 变更之前执行的收尾工作:调用 getSnapshotBeforeUpdate(class 组件可以在 DOM 真正改变前读取旧状态)、调度 useEffect 的被动 effect。它把"需要异步执行的活儿"先安排出去。
2. mutation 阶段(commitMutationEffects)
真正动 DOM 的地方,对应 ReactFiberCommitWork.js 里的 commitWork / commitPlacement / commitDeletion:
JAVASCRIPTswitch (flags & Placement) {
// 插入:找到宿主父节点,insertBefore / appendChild
// 更新:commitWork 里调 commitUpdate,diffProperties 逐属性更新
// 删除:commitDeletion,递归卸载子树并解绑 ref
}
- Placement(插入):commitPlacement 找到宿主父节点,新节点插到指定兄弟之前或末尾,对应
insertBefore/appendChild; - Update(更新):commitWork 走到 commitUpdate,把 render 阶段准备好的属性 diff(style、className、事件等)逐项应用到真实 DOM;
- Deletion(删除):commitDeletion 递归处理整个子树,调用 componentWillUnmount、解绑 ref、清除布局 effect——子树的删除是一个整体,不能只删根节点。
mutation 结束后,root.current 从旧的 finishedWork 切换到新树(root.current = finishedWork),React 把"当前显示的树"指针原子地换掉。
3. layout 阶段(commitLayoutEffects)
DOM 已经更新完毕,此时可以安全地读取布局信息(比如测量元素尺寸)。这里同步执行 useLayoutEffect 的 destroy + create、class 组件的 componentDidMount / componentDidUpdate,并触发一些收尾调度。
三个阶段里最容易混淆的就是 effect 的执行时机,我们单独拎出来讲。
useLayoutEffect 与 useEffect:差在哪
两者名字像,执行时机天差地别:
| useLayoutEffect | useEffect | |
|---|---|---|
| 执行时机 | commit 的 layout 阶段,同步执行 | DOM 更新之后异步执行(被动 effect) |
| 阻塞绘制 | 会,执行完浏览器才 paint | 不会,浏览器先绘制 |
| 适用场景 | 需要同步读 DOM 布局、避免闪烁 | 绝大多数副作用:请求、订阅、日志 |
关键在 mutation 阶段末尾,React 会调用 schedulePassiveEffects,把 useEffect 的清理/执行函数挂到 rootWithPendingPassiveEffects 上,等浏览器绘制完成后再通过 flushPassiveEffects 执行——这就是所谓被动 effect(passive effect),优先级低,不阻塞交互。
useLayoutEffect 则直接进 layout 阶段的同步队列,和 componentDidMount 一起跑。所以一个经验法则:能引起视觉跳动的副作用用 useLayoutEffect(在绘制前改 DOM),其余一律 useEffect。
同步与被动:为什么 useEffect 偶尔会闪
React 18 里 useEffect 的异步执行由 Scheduler 调度(normal priority),理论上不阻塞 paint。但如果一次 commit 里被动 effect 很多、或调度被高优任务抢占,用户可能会先看到一帧"副作用还没生效"的画面,这就是闪烁的根源。而 useLayoutEffect 因为同步,永远不会出现这个问题。
小结
- commit 阶段 = before mutation(收尾 + 调度被动 effect)→ mutation(插入/更新/删除 DOM)→ layout(同步执行 layout effect)三幕;
- effectList 让 React 只处理真正变化的节点;
- mutation 结束后才切换 current 指针,保证一致性;
- useEffect 异步、不阻塞绘制;useLayoutEffect 同步、绘制前执行。
至此,一次完整的 React 更新之旅闭环了:JSX → Element → Fiber → render 打标 → commit 落 DOM。下一篇我们钻进 Hooks,看看 useState 背后那条神秘的链表。