08|Commit 阶段:副作用如何被组织、执行与约束
本栏目是「React 源码剖析」系列:我会以源码为证据、以架构为线索,讲清 React 从运行时到核心算法的关键设计。开源仓库:https://github.com/facebook/react
引言:为什么 commit 必须"短且确定"?
在第 07 篇我们把 Offscreen 的 Visibility 这条线跑通了:
- render 阶段谁打
Visibility - commit 阶段如何 hide/unhide、disappear/reappear、connect/disconnect
但如果你继续顺着源码读下去,会发现一个更大的框架问题:
React 为什么要把 commit 拆成 BeforeMutation / Mutation / Layout / Passive?
更重要的是:
- commit 不是"做完就好",它必须满足非常强的约束:
- 不可中断(不能像 render 一样 yield)
- 结果确定(可观察副作用必须按严格顺序发生)
- 尽可能短(否则会卡主线程,直接影响交互)
- 既然 commit 很贵,React 就必须有一套"声明式的副作用系统"来剪枝。
所以第 08 篇不再围绕某个组件,而是把镜头拉远:
- Flags(
flags/subtreeFlags)如何声明副作用 - commitRoot 如何把副作用分层执行
- Static flags 为什么存在,以及它如何让 commit 避免无意义遍历
- 一个你可能没注意过的分支:commit 也能 suspend(Suspensey Commit)
本文涉及的关键文件:
packages/react-reconciler/src/ReactFiberWorkLoop.jspackages/react-reconciler/src/ReactFiberFlags.jspackages/react-reconciler/src/ReactFiberCompleteWork.jspackages/react-reconciler/src/ReactFiberCommitWork.jspackages/react-reconciler/src/ReactFiberCommitEffects.jspackages/react-dom-bindings/src/client/ReactFiberConfigDOM.jspackages/react-reconciler/src/ReactFiberThenable.js
0) commit 的 3 条铁律
从"架构意图"出发,commit 的规则可以总结成三条:
-
必须同步:
- render 可以被打断,但 commit 不能被打断。
- 你可以把 commit 理解成"把世界从 A 状态切换到 B 状态"的一段原子事务。
-
必须有序:
- React 允许你在不同阶段做不同类型的事情:
- BeforeMutation:读取 host tree 旧状态
- Mutation:写 host tree
- Layout:读取 host tree 新状态
- Passive:可延后执行的副作用
- React 允许你在不同阶段做不同类型的事情:
-
必须可剪枝:
- commit 会遍历 Fiber 树。
- 没有副作用的子树必须能够被快速跳过。
理解这三条,后面所有实现细节(mask、static flags、pendingEffectsStatus)都只是"把约束落到代码里"。
1) Flags:React 用"声明"来驱动 commit
1.1 flags vs subtreeFlags
每个 fiber 有两套位图:
fiber.flags:这一个节点自己有哪些副作用fiber.subtreeFlags:这棵子树里(包含所有后代)是否存在某类副作用
commit 的核心剪枝点就是:
先看
subtreeFlags & Mask,为 0 就不用往下走。
1.2 StaticMask:为什么要有"静态 flags"?
ReactFiberFlags.js 的注释非常直白:
- static flags 描述的是"一个 fiber 的长期属性",不只属于本次 render
- 典型例子:某个函数组件用了
useEffect,即使这次 render 没更新,也要在卸载时做 cleanup
对应的定义:
js
export const StaticMask =
LayoutStatic |
PassiveStatic |
RefStatic |
MaySuspendCommit |
ViewTransitionStatic |
ViewTransitionNamedStatic |
PortalStatic;
这套设计在 bubbleProperties 里体现得特别明显。
1.3 bubbleProperties:bailout 时只冒泡 static flags
ReactFiberCompleteWork.js:
- 正常情况下:
subtreeFlags |= child.subtreeFlags; subtreeFlags |= child.flags; - 但如果 bailout (
alternate.child === child),只冒泡StaticMask
核心片段:
js
if (didBailout) {
subtreeFlags |= child.subtreeFlags & StaticMask;
subtreeFlags |= child.flags & StaticMask;
}
直觉:
- bailout 意味着"本次更新没动这棵子树",所以 commit flags 没必要传播
- 但 static flags 关系到"未来某次 commit 的 cleanup/特殊遍历",必须保留
2) commitRoot 总览:一段副作用状态机
先给一个全局视角:commitRoot 不是一个函数,而是一套状态机。
2.1 时序图:从 commitRootWhenReady 到 flushPendingEffects
否
是
需要等待
不需要等待
render 完成得到 finishedWork
commitRootWhenReady
是否可能需要 suspend commit?
commitRoot
startSuspendingCommit
accumulateSuspenseyCommit
waitForCommitToBeReady
订阅 ready 事件, 延后 commit
pendingEffectsStatus = PENDING_MUTATION_PHASE
flushMutationEffects
flushLayoutEffects
flushSpawnedWork
flushPassiveEffects(可能 after paint)
2.2 pendingEffectsStatus:commit 的显式 phase 状态
ReactFiberWorkLoop.js 定义了一个关键状态变量:
pendingEffectsStatus
它的值是枚举:
PENDING_MUTATION_PHASEPENDING_LAYOUT_PHASEPENDING_AFTER_MUTATION_PHASEPENDING_SPAWNED_WORKPENDING_PASSIVE_PHASE
每个 flush* 函数都会先检查当前状态:
- 不匹配就直接 return
- 匹配就把状态推进到下一步
这让 commit 能做到:
- 同步 commit:按顺序一次性 flush
- 带 ViewTransition/gesture 的 commit:允许"在中间阶段被外部机制切开再继续"
2.3 为什么 commitRoot 一开始要循环 flush?
commitRoot 的开头有一个非常关键的 do/while:
flushPendingEffects()- 直到
pendingEffectsStatus === NO_PENDING_EFFECTS
原因是:
flushPassiveEffects末尾会flushSyncWorkOnAllRoots()- 这可能立即产生新的 passive effects
- 所以必须循环直到"真的没有 pending effects"
这也是"commit 必须确定"的体现:
- 不能把"副作用链"留到一个不确定的未来时刻
3) Phase 0:commit 也能 suspend(Suspensey Commit)
很多人只知道 render 会 suspend,但现在的 React 里:
- commit 也可能 suspend
这主要服务两类 host 资源:
- Suspensey stylesheets
- Suspensey images(
img.decode())
以及与 ViewTransition 的联动。
3.1 render 侧:MaySuspendCommit / ShouldSuspendCommit
在 ReactFiberCompleteWork.js 里,HostComponent 完成时会调用 preloadInstanceAndSuspendIfNeeded:
- 用 HostConfig 的
maySuspendCommit/maySuspendCommitOnUpdate判断"未来是否可能需要 commit suspend" - 如果可能:设置
workInProgress.flags |= MaySuspendCommit
并且在某些 lanes 下(transition/retry/idle 等)会立即尝试 preload:
preloadInstance返回 false:- 如果
shouldRemainOnPreviousScreen()为真:只打ShouldSuspendCommit - 否则:直接
suspendCommit()
- 如果
preloadInstance返回 true:也会打ShouldSuspendCommit("即使现在 ready,也要在 pre-commit 再检查一遍")
SuspenseyCommitException 的来源在 ReactFiberThenable.js:
js
export function suspendCommit(): void {
suspendedThenable = noopSuspenseyCommitThenable;
throw SuspenseyCommitException;
}
3.2 commitRootWhenReady:决定是否进入"可挂起 commit"流程
ReactFiberWorkLoop.js 的 commitRootWhenReady 用 finishedWork.subtreeFlags 计算:
maySuspendCommit = subtreeFlags & ShouldSuspendCommit || (subtreeFlags 同时包含 Visibility 和 MaySuspendCommit)
如果成立,会走:
startSuspendingCommit()accumulateSuspenseyCommit(finishedWork, lanes, suspendedState)waitForCommitToBeReady(suspendedState, timeoutOffset)
若 waitForCommitToBeReady 返回 subscribe 函数:
- commit 会被延后
- React 会把
commitRoot绑定进 subscribe 的回调
3.3 HostConfigDOM:把"commit 可挂起"落到浏览器现实
ReactFiberConfigDOM.js 里提供了这套接口:
startSuspendingCommit():创建一个SuspendedState,记录 stylesheets、图片数量、预计 bytes 等suspendInstance():对图片走instance.decode(),把 pending 计数加一,完成后 pingsuspendResource():对 stylesheet 走 preload/link onload/onerrorwaitForCommitToBeReady():- 如果还有未完成的资源,返回
commit => { state.unsuspend = commit; return cancel; } - 同时设置 CSS 超时(60s)与图片超时(800ms 或降级为 50ms)
- 如果还有未完成的资源,返回
这里你能清楚看到 React 的"工程化取舍":
- CSS:尽量不展示无样式内容,但有极限超时兜底
- 图片:更倾向于"不要等太久",避免长时间卡住 commit
4) Phase 1:BeforeMutation(Snapshot)
BeforeMutation 的职责是:
- 读取 host tree 在 mutation 之前的状态
- 典型就是
getSnapshotBeforeUpdate
对应入口:
commitBeforeMutationEffects(root, finishedWork, lanes)
实现特点:
- 这段遍历用
nextEffect做了一套"可剪枝的 DFS" - subtree 剪枝用的是
BeforeMutationMask(或 ViewTransition 时用BeforeAndAfterMutationTransitionMask)
你可以把它理解成:
"只访问可能需要 snapshot 的子树"。
这里还有两个很"顺手"的插入点:
createEventHandle的 beforeblur- ViewTransition 的扫描/配对(属于本篇主线之外,但它解释了为什么 mask 里会额外包含 Placement/Update/Visibility)
5) Phase 2:Mutation
Mutation 阶段就是"真正修改世界"的地方:
- 插入/删除/更新 DOM
- 更新 host instance 属性
- 处理 ref detach(某些情况下)
入口:
flushMutationEffects()(workloop)commitMutationEffects(root, finishedWork, lanes)(commit work)
5.1 删除必须先于子树更新
recursivelyTraverseMutationEffects 的顺序是:
commitDeletionEffects- 再对子 fiber 递归
commitMutationEffectsOnFiber
原因很直觉:
- 删除可能会影响子树遍历的"宿主环境"
- 并且需要保证 unmount/cleanup 的顺序一致
5.2 Placement 必须在子树后、当前 fiber 前
commitReconciliationEffects 的注释写得很清楚:
- Placement(插入/重排)必须在 children effects 之后
- 但又必须在当前 fiber 的 effects 之前
并且 Placement 在这里会被清掉:
js
if (flags & Placement) {
commitHostPlacement(finishedWork);
finishedWork.flags &= ~Placement;
}
这会影响后续某些阶段判断"是否是新插入节点"的方式:
- 例如 passive 阶段就不能再依赖
Placement
5.3 为什么 root.current = finishedWork 必须发生在 mutation 之后?
flushMutationEffects 末尾有一行很关键:
root.current = finishedWork;
注释说明了顺序原因:
- mutation 阶段运行
componentWillUnmount之类需要 previous tree 仍然是 current - layout 阶段运行
componentDidMount/Update需要 finished tree 成为 current
这就是 commit 分层的核心收益:
- 通过切换 current 的时机,保证生命周期"读到的世界"一致。
6) Phase 3:Layout
Layout 阶段的关键词是:
- 读取 mutation 后的新 host tree
入口:
flushLayoutEffects()commitLayoutEffects(finishedWork, root, lanes)
典型行为:
useLayoutEffectmount- class
componentDidMount/Update - refs attach
- Host mount(例如 DOM autoFocus)
这也是为什么 layout effects 被设计成"同步立即可观察"的:
- 它是给布局/测量等逻辑用的
与第 07 篇的连接点:
- Offscreen 从 hidden → visible 时会走
reappearLayoutEffects - 它是 commitLayoutEffects 的"超集遍历"
7) Phase 4:Passive
Passive 阶段是 React 为"可延后副作用"设计的缓冲区:
useEffect的 create/cleanup- cache retain/release
- transition tracing 的回调处理
入口:
flushSpawnedWork()把状态推进到PENDING_PASSIVE_PHASEflushPassiveEffects()真正执行
7.1 passive 为什么通常 after paint?
commitRoot 在检测到 root 有 passive effects 时,会 schedule 一个 NormalPriority callback:
- 让 passive effects 在浏览器有机会 paint 之后执行
这样做的直觉是:
useEffect通常不应该阻塞首帧- 把它延后,可以显著减少"commit 卡顿"
7.2 但 passive 有时也会同步 flush
commitRoot 末尾还有一段:
- 如果本次 commit 是 sync lane(且是 modern root),会
flushPendingEffects()
原因是:
- 某些外部系统(测试、事件观察)需要立即可观察到 effect 结果
7.3 执行顺序:先 unmount 再 mount
flushPassiveEffectsImpl 的核心顺序是:
commitPassiveUnmountEffects(root.current)commitPassiveMountEffects(root, root.current, lanes, transitions, ...)
这保证了:
- cleanup 总发生在新的 create 之前
- 避免"同一 commit 中 sibling effect 互相干扰"的一类问题
8) 常见误解(以及源码如何反驳)
误解 1:commit 是一次 DFS 遍历,从上到下顺便把所有事做完
- 实际:commit 是"多阶段、多次遍历/多套剪枝"的组合。
- 证据:
BeforeMutationMask/MutationMask/LayoutMask/PassiveMask四套 mask。
误解 2:bailout 的子树 commit 就完全不需要再访问
- 实际:bailout 时仍然要保留
StaticMask,否则卸载/断连/portal 等语义会丢。 - 证据:
bubblePropertiesbailout 分支。
误解 3:只有 render 能 suspend
- 实际:commit 也能 suspend(suspensey CSS / suspensey images / view transitions)。
- 证据:
commitRootWhenReady+startSuspendingCommit/waitForCommitToBeReady+suspendCommit()。
总结:commit 的本质是"声明驱动的分层事务"
如果把第 08 篇压缩成一句话:
React 用 flags 声明副作用,用 mask + subtreeFlags 做剪枝,用 before/mutation/layout/passive 分层保证"同步、确定、尽可能短",并在必要时把"commit 是否可进行"交给 HostConfig 做资源就绪判断。
这也是为什么你在第 07 篇看到 Offscreen 的 Visibility 会同时出现在多个 mask 上:
- 它不是"一个功能点",而是 commit 分层模型里的一个"横切副作用"。
下一篇预告
第 09 篇我们会把视角从 Reconciler 推到 DOM Renderer:
- HostConfig/HostComponent 更新路径
commitHostUpdate/commitHostPlacement/commitHostTextUpdate如何落到真实 DOM- 事件系统/属性系统为什么要这样设计