Skip to main content

React 更新的三个阶段

对于 React 的底层架构(Fiber),面试官最爱考察的就是更新的三个阶段以及相关的衍生问题。本篇将源码的复杂逻辑剥离,提炼出最适合面试回答的核心知识点。

宏观总结:三阶段概览

在 Fiber 架构下,React 的更新过程可以严谨地划分为三个核心阶段:

  1. Scheduler (调度阶段):给任务分配优先级,统筹任务调度。
  2. Render (协调阶段):更新组件内部状态,通过 Diff 计算找出 DOM 的变更。(可中断)
  3. 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 已经更新完毕,且双缓存树已经完成切换。

  • 类组件:触发 componentDidMountcomponentDidUpdate
  • 函数组件:同步执行 useLayoutEffect 的回调函数。

四、 核心对比:useEffect vs useLayoutEffect

这是 Commit 阶段最常考的衍生问题。

特性useLayoutEffectuseEffect
执行时机Commit 的 Layout 阶段同步执行(DOM 已更新,但在浏览器绘制前)Commit 完成后异步执行(浏览器绘制完成后)
是否阻塞绘制,会阻塞浏览器绘制,不会阻塞绘制
适用场景需要在绘制前读取/修改 DOM(如测量元素尺寸、避免闪烁)数据请求、订阅、日志打印等绝大多数副作用

最佳实践:默认优先使用 useEffect;只有当副作用会导致视觉闪烁(即先渲染了初始状态,又瞬间被 effect 修改成了新状态)时,才考虑使用 useLayoutEffect


🎯 面试极简速记卡片

  1. 三阶段:Scheduler(排优先级)→ Render(可中断,算变更、标 flags、建 effectList)→ Commit(不可中断,改 DOM、跑生命周期/副作用)。
  2. Render 核心beginWork(递:diff + 标记)→ completeWork(归:建 DOM + 收集 effectList 链表)。
  3. 调度器原理:不用 setTimeout 因为有 4ms 最小延迟限制,不用 requestIdleCallback 因为兼容性与帧率不稳。最终采用 MessageChannel 实现无延迟的宏任务。
  4. 时间切片:每处理一个 Fiber 用 shouldYield() 判断 5ms 切片是否用尽,用尽则主动让出主线程。
  5. will 废弃原因:Render 可中断重做 → 导致生命周期可能被多次调用 → 引发不可预期的副作用。
  6. 副作用时机useLayoutEffect(DOM 更新后、浏览器绘制前,同步阻塞)vs useEffect(浏览器绘制后,异步不阻塞)。