React 更新的三个阶段
对于 React 的底层架构(Fiber),面试官最爱考察的就是更新的三个阶段以及相关的衍生问题。本篇将源码的复杂逻辑剥离,提炼出最适合面试回答的核心知识点。
宏观总结:三阶段概览
在 Fiber 架构下,React 的更新过程可以严谨地划分为三个核心阶段:
- Scheduler (调度阶段):给任务分配优先级,统筹任务调度。
- Render (协调阶段):更新组件内部状态,通过 Diff 计算找出 DOM 的变更。(可中断)
- Commit (提交阶段):将变更一次性应用到真实 DOM 上,执行副作用与生命周期。(不可中断)
🗣️ 面试一句话回答: Scheduler 排优先级 → Render 算出「要改什么」(标记 flags、构建 effectList,可被打断重来)→ Commit「真正去改」DOM 并触发生命周期/副作用(一气呵成,不可中断)。
一、 Scheduler (调度阶段)
核心任务:给任务分配优先级,利用浏览器的空闲时间执行任务(时间切片)。
1. 时间切片(Time Slicing)
- React 维护了一个工作循环(
workLoop)。 - 每次处理完一个 Fiber 节点,都会通过
shouldYield()判断当前分配的 5ms 时间切片是否耗尽。 - 如果超过 5ms,则主动让出主线程(yield),将剩下的工作打包成下一个宏任务交给浏览器。
- 意义:保证浏览器在每 5ms 后有机会去响应用户的点击事件或渲染高优先级的动画,彻底杜绝“页面卡死”。
2. 为什么是 MessageChannel?(高频考点)
在早期,React 使用了 requestIdleCallback,但由于其兼容性差且帧率不稳定,React 团队决定自己 Polyfill 一套调度器。
面试追问:为什么不用
setTimeout作为 Polyfill,而用MessageChannel? 回答:因为setTimeout嵌套层级超过 5 层后,会有最小 4ms 的延迟限制。如果用它来调度渲染任务,一帧 16.6ms 里面最多只能执行 4 次任务,导致严重的帧率浪费。而MessageChannel是真正的无延迟宏任务。
二、 Render 阶段 (协调阶段)
核心任务:遍历 Fiber 树,进行 Diff 算法,打上副作用标签(flags),得出需要更新的节点信息。 特点:可以被打断,让位于优先级更高的操作(如用户输入)。
Render 阶段的过程本质上是以 DFS(深度优先遍历) 遍历 Fiber 树,分为「递」和「归」两个过程交替进行:
1. 「递」阶段:beginWork
从根节点向下,主要做三件事:
- 判断复用:判断当前的 Fiber 节点是否可以复用(基于 key 和 type)。
- 生成新节点:根据不同的组件类型(函数组件、类组件、原生 DOM 节点),执行组件的 render 方法(或函数组件自身),生成子 Fiber 节点。
- 打标记(Flags):在 Diff 的过程中,为发生变化的节点打上标记(如
Placement插入、Update更新、Deletion删除)。
2. 「归」阶段:completeWork
当遍历到叶子节点(没有子节点)时,开始向上回溯:
- 创建/更新 DOM:为 Fiber 节点创建真实的 DOM 实例,并处理 props。
- 收集副作用(EffectList):(极其重要)
向上回溯时,会把所有被打上 flags(有副作用)的子节点,以单向链表的形式收集到父节点的
effectList中。 最终回到根节点(Root)时,Root 上就挂载了一条只包含「有变更节点」的 effectList 链表。供下一阶段直接消费,避免了再次遍历整棵树。
⚠️ 衍生考点:为什么废弃 will 相关的生命周期?
面试题:为什么
componentWillMount/componentWillReceiveProps/componentWillUpdate会被废弃? 回答:因为这几个生命周期都处于 Render 阶段。在 Fiber 架构下,Render 阶段是可中断且可重启的。这意味着这几个生命周期函数可能会被执行多次。如果开发者在这些函数里发起 AJAX 请求或操作 DOM,就会引发严重的 Bug。
三、 Commit 阶段 (提交阶段)
核心任务:消费 Render 阶段生成的 effectList,执行 DOM 操作,并触发对应的生命周期和 Hook 副作用。
特点:一旦开始,不可中断,必须同步执行完,以保证 UI 的一致性。
Commit 阶段在源码中被细分为三个子阶段:
1. Before Mutation (DOM 变更前)
此时真实的 DOM 还未被修改。
- 类组件:触发
getSnapshotBeforeUpdate生命周期(此时可以获取到更新前的真实 DOM 状态,如滚动条位置)。 - 函数组件:异步调度
useEffect(注意:这里只是调度,并不会立即执行)。
2. Mutation (执行 DOM 变更)
根据 effectList 上的 flags,执行真正的 DOM 增删改操作。
- 执行完毕后,React 会执行
root.current = finishedWork,完成双缓存树的切换(workInProgress 树变成 current 树)。 - 类组件:触发
componentWillUnmount。 - 函数组件:同步执行上一次
useLayoutEffect的销毁函数(destructor)。
3. Layout (DOM 变更后)
此时真实的 DOM 已经更新完毕,且双缓存树已经完成切换。
- 类组件:触发
componentDidMount或componentDidUpdate。 - 函数组件:同步执行
useLayoutEffect的回调函数。
四、 核心对比:useEffect vs useLayoutEffect
这是 Commit 阶段最常考的衍生问题。
| 特性 | useLayoutEffect | useEffect |
|---|---|---|
| 执行时机 | Commit 的 Layout 阶段同步执行(DOM 已更新,但在浏览器绘制前) | Commit 完成后异步执行(浏览器绘制完成后) |
| 是否阻塞绘制 | 是,会阻塞浏览器绘制 | 否,不会阻塞绘制 |
| 适用场景 | 需要在绘制前读取/修改 DOM(如测量元素尺寸、避免闪烁) | 数据请求、订阅、日志打印等绝大多数副作用 |
最佳实践:默认优先使用
useEffect;只有当副作用会导致视觉闪烁(即先渲染了初始状态,又瞬间被 effect 修改成了新状态)时,才考虑使用useLayoutEffect。
🎯 面试极简速记卡片
- 三阶段:Scheduler(排优先级)→ Render(可中断,算变更、标 flags、建 effectList)→ Commit(不可中断,改 DOM、跑生命周期/副作用)。
- Render 核心:
beginWork(递:diff + 标记)→completeWork(归:建 DOM + 收集 effectList 链表)。 - 调度器原理:不用
setTimeout因为有 4ms 最小延迟限制,不用requestIdleCallback因为兼容性与帧率不稳。最终采用MessageChannel实现无延迟的宏任务。 - 时间切片:每处理一个 Fiber 用
shouldYield()判断 5ms 切片是否用尽,用尽则主动让出主线程。 will废弃原因:Render 可中断重做 → 导致生命周期可能被多次调用 → 引发不可预期的副作用。- 副作用时机:
useLayoutEffect(DOM 更新后、浏览器绘制前,同步阻塞)vsuseEffect(浏览器绘制后,异步不阻塞)。