React 在 Fiber 架构下将一次完整的更新过程拆分为两个截然不同的阶段:Render 阶段和Commit 阶段。理解这两个阶段的分工与特性,是理解并发渲染、时间切片、优先级调度等高级特性的基础,也能帮助你避免一些常见的性能陷阱。
两阶段的分工
| 阶段 | 主要工作 | 是否可中断 | 副作用处理 |
|------|---------|------------|------------|
| Render 阶段 | 计算变更:构建新的 Fiber 树、执行组件函数/Hooks、Diff 对比、收集需要更新的 DOM 操作 | ✅ 可中断 | ❌ 不应包含副作用 |
| Commit 阶段 | 应用变更:将 Render 阶段计算出的 DOM 操作同步应用到真实 DOM、执行生命周期/副作用 | ❌ 不可中断 | ✅ 执行副作用(useEffect、ref 绑定等) |
简单来说:Render 阶段负责“算出需要改什么”,Commit 阶段负责“真正动手改”。
Render 阶段:可中断的计算过程
在触发一次更新后(比如调用 setState),React 会启动一次 Render 阶段。这个阶段的工作是:
- 从触发更新的 Fiber 节点开始,遍历整棵 Fiber 树,为每个节点调用组件函数(或类组件的
render方法),得到新的子元素。 - 对新旧 Fiber 树进行 Diff,标记出需要新增、删除、修改的 DOM 操作(统称为“副作用”,即 Effect)。
- 在整个遍历过程中,React 会生成一棵完整的
workInProgress树,用来承载最新的状态和 UI 描述。
为什么可中断?
- Render 阶段本质上是一个纯计算过程,不直接操作真实 DOM,不会产生浏览器可见的界面变化。
- 当遍历树的工作量很大(例如渲染一个包含数千条列表的页面)时,如果这个过程是同步且不中断的,主线程会被占用过久,导致浏览器无法响应用户输入、动画卡顿。
- 借助浏览器的空闲时间(
requestIdleCallback的 polyfill),React 的调度器可以将 Render 阶段拆分成多个小时间片,在每个时间片内执行一段工作,执行完后检查是否有更高优先级的任务(如用户点击)。如果有,就中断当前 Render 过程,让出主线程,先处理高优任务。 - 中断后,React 可以在稍后恢复执行,或因为状态更新而丢弃本次 Render 结果并重新开始。
重要约束:Render 阶段不能包含副作用
正是由于 Render 阶段可能被中断、重放甚至丢弃,这个阶段内所有生命周期和 Hooks 必须是纯函数,不能执行诸如修改数据、发送网络请求、订阅事件等副作用。React 会在开发环境中调用两次组件函数(严格模式)来帮助你暴露这类问题。
// 🤔 Render 阶段就是执行组件函数本体
function MyComponent({ id }) {
const [data, setData] = useState(null);
// 🚫 不要在函数体(Render 阶段)直接执行副作用!
// fetchData(id).then(setData); // 可能被重复执行,产生错误数据或内存泄漏
// ✅ 副作用必须放在 useEffect 中(在 Commit 阶段之后执行)
useEffect(() => {
fetchData(id).then(setData);
}, [id]);
return <div>{data?.name}</div>;
}
同样,类组件中的 render 方法、constructor、shouldComponentUpdate 等都属于 Render 阶段,也必须保证纯净。
Commit 阶段:同步不可中断的“真实变更”
当 Render 阶段完成后,你得到了一棵新的 Fiber 树以及它上面标记的副作用列表(Effect List)。Commit 阶段的任务就是将这些副作用“兑现”到真实的 DOM 上。
Commit 阶段内部又细分为三个子阶段:
- Before Mutation(突变前)
- 主要用于类组件
getSnapshotBeforeUpdate生命周期,可以在 DOM 更改前读取一些布局信息(如滚动位置)。
- Mutation(突变)
- 同步地执行所有 DOM 操作:插入、更新、删除节点。此时浏览器会更新渲染树,用户可以看到新的界面。
- 处理 ref 的绑定与解绑。
- 触发类组件的
componentDidMount/componentDidUpdate/componentWillUnmount。
- Layout(布局)
- 此时 DOM 已经更新完毕,浏览器计算出新的布局信息(尺寸、位置等)。
- 执行
useLayoutEffect的回调(同步执行,会阻塞浏览器绘制)。 - 可以进行需要同步读取布局的操作,如测量 DOM 尺寸、手动滚动等。
为什么不可中断?
Commit 阶段一旦开始,就必须一口气执行完,不能被打断。原因很简单:它直接修改了真实 DOM。如果 DOM 操作只做一半就被中断,用户会看到不完整的界面,浏览器也会处于不一致的状态,这会导致严重的视觉故障和难以调试的问题。因此,React 确保 Commit 是一个短暂的、连续的同步过程。
副作用执行顺序注意:
useEffect的回调实际上是在 Commit 阶段完成(浏览器绘制之后)异步执行的,它不会阻塞浏览器渲染,但也不属于 Commit 的同步执行序列。这里为了心知肚明,通常描述为“在 Commit 之后”。
从 Render 到 Commit 的完整示例
假设有一个计数器组件:
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
当用户点击按钮时,触发更新,React 会:
- 调度更新(优先级处理)。
- Render 阶段:
- 重新调用
Counter函数,得到新的虚拟 DOM:<button>1</button>。 - 与旧 Fiber 树中的
<button>0</button>对比,发现文本节点变化。 - 在对应的 Fiber 节点上标记“文本更新”副作用。
- 这个过程可以被更紧急的任务打断(比如用户快速点击多次,React 可能批量处理)。
- Commit 阶段:
- Mutation 子阶段:同步更新真实 button 元素的
textContent为1。 - 浏览器绘制,用户看到界面从 0 变为 1。
- 之后(异步)执行
useEffect回调(如果有)。
为什么要理解这两个阶段?
- 性能优化:避免在 Render 阶段做耗时计算,可以使用
useMemo、虚拟列表等方式减少 Render 阶段的工作量,保证时间切片流畅。 - 正确放置副作用:了解
useEffect与useLayoutEffect的执行时机差异(后者在 Commit 的 Layout 同步执行),避免闪烁或不正确读取 DOM。 - 并发渲染认知:React 18 的并发特性(如
useTransition)主要利用 Render 阶段的可中断性,将低优先级更新的 Render 工作打断并推迟,让高优交互先得到处理。 - 排查问题:当你遇到“界面更新一半”或“状态丢失”时,多半是误在 Render 阶段执行了副作导致,而非 React 本身的 bug。
总之,将渲染分为可中断的计算和不可中断的提交,是 React 实现流畅用户体验的核心秘诀,一切优先级调度和并发能力都建立在这个基础之上。