08|Commit 阶段:副作用如何被组织、执行与约束

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.js
  • packages/react-reconciler/src/ReactFiberFlags.js
  • packages/react-reconciler/src/ReactFiberCompleteWork.js
  • packages/react-reconciler/src/ReactFiberCommitWork.js
  • packages/react-reconciler/src/ReactFiberCommitEffects.js
  • packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
  • packages/react-reconciler/src/ReactFiberThenable.js

0) commit 的 3 条铁律

从"架构意图"出发,commit 的规则可以总结成三条:

  1. 必须同步

    • render 可以被打断,但 commit 不能被打断。
    • 你可以把 commit 理解成"把世界从 A 状态切换到 B 状态"的一段原子事务。
  2. 必须有序

    • React 允许你在不同阶段做不同类型的事情:
      • BeforeMutation:读取 host tree 旧状态
      • Mutation:写 host tree
      • Layout:读取 host tree 新状态
      • Passive:可延后执行的副作用
  3. 必须可剪枝

    • 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;
  • 但如果 bailoutalternate.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_PHASE
  • PENDING_LAYOUT_PHASE
  • PENDING_AFTER_MUTATION_PHASE
  • PENDING_SPAWNED_WORK
  • PENDING_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.jscommitRootWhenReadyfinishedWork.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 计数加一,完成后 ping
  • suspendResource():对 stylesheet 走 preload/link onload/onerror
  • waitForCommitToBeReady()
    • 如果还有未完成的资源,返回 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 的顺序是:

  1. commitDeletionEffects
  2. 再对子 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)

典型行为:

  • useLayoutEffect mount
  • 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_PHASE
  • flushPassiveEffects() 真正执行

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 的核心顺序是:

  1. commitPassiveUnmountEffects(root.current)
  2. 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 等语义会丢。
  • 证据:bubbleProperties bailout 分支。

误解 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
  • 事件系统/属性系统为什么要这样设计
相关推荐
奋斗的小青年!!2 小时前
Flutter跨平台开发OpenHarmony应用:个人中心实现
开发语言·前端·flutter·harmonyos·鸿蒙
kkce2 小时前
域名CDN检测意义
服务器·前端·网络
ZoeLandia2 小时前
Qiankun 生命周期与数据通信实战
前端·微前端·qiankun
LawrenceLan2 小时前
Flutter 零基础入门(十五):继承、多态与面向对象三大特性
开发语言·前端·flutter·dart
二川bro2 小时前
详细解析 cesiumViewer.render() 和 requestAnimationFrame(render)
前端
前端付豪2 小时前
必知Node应用性能提升及API test 接口测试
前端·react.js·node.js
王同学 学出来3 小时前
vue+nodejs项目在服务器实现docker部署
服务器·前端·vue.js·docker·node.js
一道雷3 小时前
让 Vant 弹出层适配 Uniapp Webview 返回键
前端·vue.js·前端框架
bug总结3 小时前
uniapp+动态设置顶部导航栏使用详解
java·前端·javascript