React 源码深潜(二):双缓存与渲染流程的"两步走"策略
在第一篇中,我们理解了 Fiber 是为了解决”卡顿”而生的。那么,React 拿到 Fiber 链表后,到底是如何一步步把它变成网页上的真实像素的呢?
本篇我们进入 React 更新流程的核心地带:Render 阶段(打草稿)和 Commit 阶段(去发布),以及连接它们的双缓存机制。
1. 核心模型:Render 与 Commit 的职责划分
React 将每一次 UI 更新严格分成了两个阶段,这不是设计上的”洁癖”,而是为了实现可中断更新的前提条件。
| 特性 | Render 阶段(协调) | Commit 阶段(提交) |
|---|---|---|
| 工作内容 | 在内存中计算 Fiber 树的差异,标记增删改 | 将计算结果一次性应用到真实 DOM |
| 是否可中断 | 是(配合 Scheduler 时间切片) | 否(必须同步执行完) |
| 对用户可见 | 不可见(全在内存中进行) | 可见(用户看到界面变化) |
| 内部核心函数 | beginWork / completeWork |
commitMutationEffects / commitLayoutEffects |
| 有无副作用 | 无(纯计算) | 有(DOM 操作、ref 更新、effect 触发) |
这种分离的直接好处是:
Render 阶段可以随时中断、丢弃、重来,因为它不会产生任何用户可见的变化。只有当整棵”草稿树”计算完毕,React 才会进入 Commit 阶段一次性提交。
2. 双缓存技术:为什么需要两棵树
在理解 Render 阶段之前,需要先搞清一个基础设施:React 在内存中同时维护着两棵 Fiber 树。
两棵树的角色
- Current Tree:当前屏幕上正在显示的那棵树,每个 Fiber 节点对应着真实 DOM
- WorkInProgress Tree:正在内存中构建的”草稿树”,是下一次 UI 的预演
两棵树上的对应节点通过 alternate 属性互相引用:
1 | // current 树上的节点 |
为什么不在原树上直接改
如果 React 直接修改 Current Tree,会遇到两个严重问题:
- UI 撕裂:由于 Render 阶段是可中断的,如果直接改 Current Tree,用户可能看到”改了一半”的界面——比如列表前 5 项是新样式,后 5 项还是旧样式
- 无法回滚:如果一个高优先级更新打断了当前的低优先级渲染,React 需要丢弃未完成的工作。如果改的是原树,就无法恢复
双缓存解决了这两个问题:
- 所有修改都发生在 WorkInProgress Tree 上,Current Tree 保持不变
- 如果需要丢弃,直接扔掉 WorkInProgress Tree 即可
- 只有在所有计算都完成后,才通过切换指针让 WorkInProgress Tree “上位”成为新的 Current Tree
双缓存的生命周期
graph TD
subgraph mount["首次渲染 Mount"]
M1["rootFiber.current → 空的 Current Tree"] -->|创建 workInProgress| M2["WorkInProgress Tree<br/>从头构建每个节点"]
M2 -->|"Commit:切换指针"| M3["rootFiber.current → 新的 Current Tree<br/>就是刚才的 WIP"]
end
subgraph update["后续更新 Update"]
U1["rootFiber.current → Current Tree"] -->|"基于 Current 创建 WIP(复用节点)"| U2["WorkInProgress Tree<br/>只处理有变化的节点"]
U2 -->|"Commit:切换指针"| U3["rootFiber.current → 新的 Current Tree"]
U2 -.->|闲置| U4["旧的 Current → 等待下次复用为 WIP"]
end
注意最后一步:旧的 Current Tree 不会被销毁,它会在下次更新时被复用为新的 WorkInProgress Tree。这就是为什么叫”双缓存”——两棵树交替充当”正式版”和”草稿版”。
3. Render 阶段:精细的”打草稿”过程
Render 阶段的目标是:在 WorkInProgress Tree 上标记出所有需要变更的节点。
React 通过上一篇讲过的深度优先遍历(workLoop),对每个 Fiber 节点执行两个核心函数:
3.1 beginWork:自顶向下的”递”
beginWork 负责处理当前节点,并生成它的子 Fiber。
1 | function beginWork(current, workInProgress, renderLanes) { |
拿函数组件举例,updateFunctionComponent 会做这些事:
1 | function updateFunctionComponent(current, workInProgress, renderLanes) { |
Diff 算法的核心策略
reconcileChildren 是 Diff 算法的入口,React 对它做了三个重要的降级假设来把 O(n³) 的通用树 Diff 降低到 O(n):
- 跨层级的节点移动极少发生:只比较同一层级的节点,不做跨层级复用
- 不同类型的组件产生不同的树:如果一个
<div>变成了<span>,直接销毁整棵子树重建 - 通过
key标识同一元素:列表中同 key 的元素才认为是”同一个”
对于列表的 Diff(多节点),React 分两轮遍历:
1 | // 简化版列表 Diff 思路 |
每个需要变更的 Fiber 会被打上 Flags(旧版叫 effectTag):
1 | // 常见的 Flags |
3.2 completeWork:自底向上的”归”
当一个节点的所有子节点都处理完后(没有 child,或 child 的子树已经完成),React 会调用 completeWork 进行”收尾”。
1 | function completeWork(current, workInProgress) { |
副作用冒泡(bubbleProperties)
这是 React 18 引入的一个性能优化。每个节点在 completeWork 时,会把子树所有的 Flags 合并到自己的 subtreeFlags 上:
1 | function bubbleProperties(completedWork) { |
这样在 Commit 阶段,React 只需要检查 subtreeFlags 就知道某棵子树下面有没有需要处理的副作用——如果 subtreeFlags === 0,整棵子树都可以跳过,省去了大量无用遍历。
4. Commit 阶段:最终的”发布时刻”
一旦 Render 阶段走完,整棵 WorkInProgress Tree 上已经标记好了所有变更。React 进入 Commit 阶段,这个阶段是同步、不可中断的——因为它涉及到真实 DOM 操作,半途而废会让用户看到不一致的界面。
Commit 阶段在 React 内部被细分为三个子阶段:
4.1 Before Mutation(DOM 变更前)
这个阶段读取 DOM 的”旧状态”,为后续变更做准备:
- 调用 Class 组件的
getSnapshotBeforeUpdate生命周期 - 此时 DOM 还是旧的,可以安全地读取当前的 scrollTop、尺寸等信息
1 | function commitBeforeMutationEffects(firstChild) { |
4.2 Mutation(DOM 变更)
这是真正修改 DOM 的阶段:
1 | function commitMutationEffects(firstChild) { |
完成 Mutation 后,用户的屏幕发生了变化。 紧接着,React 执行关键的一步——指针切换:
1 | // 一行代码完成"双缓存切换" |
此时,原本的 WorkInProgress Tree 变成了新的 Current Tree,旧的 Current Tree 退居二线,等待下次更新复用。
4.3 Layout(DOM 变更后)
这个阶段 DOM 已经更新完毕,可以安全地读取新的布局信息:
- 调用 Class 组件的
componentDidMount/componentDidUpdate - 调用
useLayoutEffect的回调(同步执行,在浏览器绘制之前) - 更新 ref
1 | function commitLayoutEffects(firstChild) { |
需要注意 useLayoutEffect 和 useEffect 的区别:
useLayoutEffect:在 Layout 阶段同步执行,此时 DOM 已更新但浏览器还没绘制useEffect:在 Commit 阶段结束后异步调度执行,不阻塞浏览器绘制
graph LR
A["Before Mutation<br/>getSnapshot..."] --> B["Mutation<br/>DOM 增删改<br/>切换 current 指针"]
B --> C["Layout<br/>useLayoutEffect<br/>componentDidMount<br/>更新 ref"]
C --> D["浏览器绘制"]
D -.->|异步| E["useEffect"]
5. 完整更新流程串联
把前两篇的内容综合起来,一次由 setState 触发的更新从头到尾的完整流程是这样的:
graph TD
A["setState 调用"] --> B["创建 Update 对象<br/>挂到 Fiber 的 updateQueue"]
B --> C["scheduleUpdateOnFiber<br/>向上标记优先级"]
C --> D["Scheduler 根据优先级安排任务"]
D --> R
subgraph R["Render 阶段(可中断)"]
R1["workLoop"] --> R2["beginWork<br/>· 执行组件函数<br/>· Diff 子节点,标记 Flags<br/>· 返回 child"]
R2 --> R3["completeWork<br/>· 创建/更新 DOM 实例<br/>· 副作用冒泡<br/>· 返回 sibling 或 return"]
end
R --> CM
subgraph CM["Commit 阶段(同步不可中断)"]
C1["1. Before Mutation<br/>· getSnapshotBeforeUpdate"] --> C2["2. Mutation<br/>· 真实 DOM 增删改<br/>· root.current = finishedWork"]
C2 --> C3["3. Layout<br/>· useLayoutEffect<br/>· componentDidMount/Update<br/>· 更新 ref"]
end
CM --> P["浏览器绘制 Paint"]
P --> UE["异步调度 useEffect"]
6. 本章总结
通过将”计算”与”变更”分离,React 实现了渲染的原子性:
- Render 阶段纯计算、可中断、可丢弃——保证了并发渲染的可能性
- Commit 阶段同步执行、不可中断——保证了 UI 的一致性
- 双缓存机制让两个阶段之间有了安全的”缓冲区”
React 的更新策略可以用一句话概括:要么不更新,要更新就一次性把完美的成品呈现给用户。
下一篇,我们要探索一个更有趣的问题:既然函数组件每次渲染都会重新执行,那组件里的状态(State)是如何逃过”被重置”的命运,稳稳地留在内存里的呢?
Happy Coding!
