深入理解 React 核心:Fiber 机制与 Diff 算法
前言
React 作为现代前端开发的主流框架,其高效性能背后离不开两大核心机制的支撑:Fiber 架构和Diff 算法。本文将从底层原理出发,带你彻底理解这两者是怎么一回事,以及它们是如何协同工作的。
阅读本文你将收获:
- 理解 Virtual DOM 的设计初衷
- 彻底搞懂 Fiber 架构为什么会出现、解决了什么问题
- 掌握 Diff 算法的三层策略与核心逻辑
- 理解事件循环与 React 调度的关系
一、一切从 Virtual DOM 说起
1.1 什么是 Virtual DOM?
JSX 经过 Babel 转译后会变成 React.createElement 调用:
// 你写的是这样
const App = (
<div className="container">
<h1>Hello</h1>
</div>
);
// Babel 转译后
const App = React.createElement(
'div',
{ className: 'container' },
React.createElement('h1', null, 'Hello')
);
createElement 最终返回的是一个普通的 JavaScript 对象,也就是 Virtual DOM 节点:
{
type: 'div',
props: {
className: 'container',
children: [
{
type: 'h1',
props: {
children: [
{
type: 'TEXT_ELEMENT',
props: { nodeValue: 'Hello', children: [] }
}
]
}
}
]
}
}
1.2 为什么需要 Virtual DOM?
核心原因只有一句话:操作真实 DOM 太贵了。
真实 DOM 节点背后关联着大量属性,每一次修改都可能触发浏览器的重绘和重排。而 Virtual DOM 只是一个轻量级的 JS 对象,创建和对比的代价极低。
整个工作流程:
你改数据(setState)
→ React 生成新的 VDOM 对象(JS 对象,成本极低)
→ React 拿新旧 VDOM 对比,只更新变化的节点(Diff 算法)
→ React 将差异映射到真实 DOM 上(批量更新,减少重绘重排)
开发者只需关注数据逻辑,DOM 的增删改查,React 帮你全包了。
二、为什么会有 Fiber?—— Stack Reconciler 的危机
2.1 最初的递归方案
React 早期的协调器(Reconciler)被称为 Stack Reconciler,核心逻辑非常简单:对 Virtual DOM 树做 递归遍历,一边对比一边更新。
function reconcile(parentDOM, oldVDOM, newVDOM) {
if (oldVDOM == null) {
// 新增节点
parentDOM.appendChild(createDOM(newVDOM));
} else if (newVDOM == null) {
// 删除节点
parentDOM.removeChild(oldVDOM.dom);
} else if (oldVDOM.type !== newVDOM.type) {
// 替换节点
parentDOM.replaceChild(createDOM(newVDOM), oldVDOM.dom);
} else {
// 递归对比子节点...
}
}
2.2 递归的致命缺陷
这种递归方案有两大问题:
| 问题 | 影响 |
|---|---|
| 不可中断 | 一旦开始 reconciling,必须递归完整棵树才能停下 |
| 长期占用主线程 | JS 是单线程的,一直占用主线程,用户交互、动画、滚动全部被阻塞 |
想象这样一个场景:一个复杂的 SPA 页面,VDOM 树有几千个节点。当你触发一次 setState,React 需要递归遍历这几千个节点做对比——这个过程可能需要 几十甚至上百毫秒。在这期间:
- 用户点击按钮 → 卡死无响应
- 页面动画、滚动 → 掉帧、卡顿
- 输入框打字 → 延迟感明显
这就是 Stack Reconciler 的不可中断性 带来的性能瓶颈。
2.3 浏览器的渲染机制回顾
要理解 Fiber 的解决方案,先回顾一下浏览器主线程要做多少事:
浏览器主线程任务清单:
├── 解析 HTML → 生成 DOM 树
├── 计算 CSS → 生成 CSSOM 树
├── 合并 → 生成 Render Tree
├── 布局(Layout)→ 计算节点位置和尺寸
├── 绘制(Paint)→ 将像素画到屏幕上
├── 执行 JavaScript
└── 响应各类事件(点击、滚动、输入...)
所有任务都在同一个主线程上排队执行。JS 执行太久,后面的渲染任务就得一直等着 → 用户看到的就是卡顿。
三、Fiber 机制:可中断的渲染
3.1 核心思想
Fiber 架构的核心思想只有八个字:任务分片、可中断恢复。
与其一口气遍历完整棵 VDOM 树,不如把整棵树拆成一个个小的 工作单元(Unit of Work),每次只处理一个单元,处理完就把主线程控制权交还给浏览器。如果浏览器有空,就继续处理下一个单元;如果浏览器要处理更高优先级的事情(如用户点击),就先中断。
// 伪代码:Fiber 核心循环
let nextUnitOfWork = null;
function workLoop(deadline) {
// 当前帧还有空闲时间吗?
while (nextUnitOfWork && deadline.timeRemaining() > 0) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
if (nextUnitOfWork) {
// 还有工作没做完,下一帧继续
requestIdleCallback(workLoop);
} else {
// 所有工作完成,提交更新到 DOM
commitRoot();
}
}
requestIdleCallback(workLoop);
requestIdleCallback 是浏览器提供的 API,会在浏览器空闲时调用我们传入的回调。React 利用这个机制实现了时间切片(Time Slicing)。
3.2 Fiber 节点:全新的工作单元
在 Fiber 架构中,每个组件对应一个 Fiber 节点,它是一个增强版的 VDOM 节点:
// Fiber 节点结构(简化版)
{
// ========== 节点基础信息 ==========
type: 'div', // 节点类型(DOM 标签 / 函数组件 / 类组件)
props: { ... }, // 节点属性,含 children
// ========== 关联信息 ==========
dom: domNode, // 对应的真实 DOM
// ========== 树结构链接 ==========
return: parentFiber, // 父节点
child: firstChildFiber, // 第一个子节点
sibling: nextSibling, // 下一个兄弟节点
// ========== 副作用 ==========
effectTag: 'PLACEMENT | UPDATE | DELETION', // 标记操作类型
// ========== 调度相关 ==========
alternate: oldFiber, // 指向上一次渲染的 Fiber(双缓存机制)
}
关键在于那三个指针 child、sibling、return——它们把一棵树形结构变成了一个 链表结构,使得遍历可以被"暂停"和"恢复"。
div (父)
/ | \
/ | \
h1 -- p -- span (兄弟链:h1.sibling → p.sibling → span)
/ |
text text
遍历顺序(深度优先):
div → h1 → text → p → text → span → 回到 div → 结束
遍历规则:
- 如果有子节点 → 进入
child - 如果没有子节点 → 找
sibling - 如果没有子节点也没有下一个兄弟 → 回到
return(叔叔节点)
这种链表遍历方式的好处是:任何时候停下来,nextUnitOfWork 指针都明确指向下一个要处理的节点,恢复时直接从指针位置继续。
3.3 双缓存机制(Double Buffering)
Fiber 架构的另一大巧思:画完再替换。
工作过程:
┌─────────────────────────────────┐
│ current Fiber Tree(屏幕上正在显示的)│
└─────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ workInProgress Fiber Tree(正在构建的新树)│
│ - 每个节点通过 alternate 指回旧树对应节点 │
│ - 新旧对比,标记 effectTag(增/删/改) │
│ - 构建完成后一次性 commit │
└─────────────────────────────────────────┘
↓
commitRoot() → 一次性地更新 DOM
这保证了:
- 构建过程不影响当前显示:用户始终看到的是
current树 - 原子性:要么全部更新,要么不更新(不会有半成品状态)
3.4 可中断 ≠ 随便中断
Fiber 的 commit 阶段是不可中断的,因为 DOM 更新必须确保一致性。整个流程分为两个阶段:
| 阶段 | 是否可中断 | 做什么 |
|---|---|---|
| Render / Reconciliation | ✅ 可中断 | 遍历 Fiber 树,新旧对比,标记 effectTag |
| Commit | ❌ 不可中断 | 将标记的变更一次性应用到真实 DOM |
四、Diff 算法:如何聪明地对比新旧 VDOM?
Diff 算法的目标是 找出新旧两棵 VDOM 树之间的最小差异,然后用最小的代价更新 DOM。
4.1 理论基础
两棵树的完全对比是一个 O(n³) 的问题——如果暴力对比,1000 个节点就是 10 亿次操作,根本不可行。
React 基于前端实际场景做了三个关键假设,将复杂度降到了 O(n):
| 假设 | 策略 |
|---|---|
| 不同类型的元素会产生不同的树 | Tree Diff:跨层级移动极少,如果类型不同直接销毁重建 |
通过 key 标识节点是否稳定 | Element Diff:通过 key 复用节点,避免销毁重建 |
| 同类型元素结构相似 | Component Diff:同类型组件复用,不同类型直接替换 |
4.2 Tree Diff:按层比较
React 不做跨层级的节点比较,只比较同一层级的节点:
旧树: 新树:
div div
├── A → ├── B (A → B:类型不同,删除A,创建B)
└── B └── C (B → C:类型不同,删除B,创建C)
如果 A 整个子树被移到另一个位置?
React 的处理:
旧位置:删除 A 及其整棵子树
新位置:重新创建 A 及其整棵子树
为什么不跨层对比?因为实际开发中,跨层级的 DOM 移动极少。为了降低复杂度 90% 以上,这个取舍非常值得。
4.3 Component Diff:同类型复用,不同类型重建
// 类型不同 → 直接重建
<div>
<MyComponent /> → <OtherComponent />
</div>
// 结果:卸载 MyComponent,挂载 OtherComponent
// 类型相同 → 复用,只更新 props
<div>
<MyComponent title="A" /> → <MyComponent title="B" />
</div>
// 结果:组件实例保留,只触发 componentWillReceiveProps → render
4.4 Element Diff:同层级子节点对比(核心难点)
这是 Diff 算法中最精妙的部分,也是最容易出问题的部分。
无 key 时:
旧:A B C D
新:B A D C
React 逐位对比:
位置0:A vs B → 类型不同 → 删除A,插入B
位置1:B vs A → 类型不同 → 删除B,插入A
位置2:C vs D → 类型不同 → 删除C,插入D
位置3:D vs C → 类型不同 → 删除D,插入C
结果:4次删除 + 4次插入 = 8次DOM操作
明明只是换了顺序,却是全量重建 😱
有 key 时:
旧:{key:'A'} {key:'B'} {key:'C'} {key:'D'}
新:{key:'B'} {key:'A'} {key:'D'} {key:'C'}
React 用 key 匹配:
1. 遍历新数组,在旧数组中找到相同 key 的节点
2. key:'B' 在旧数组位置1 → 只需移动到位置0
3. key:'A' 在旧数组位置0 → 只需移动到位置1
4. key:'D' 在旧数组位置2 → 只需移动到位置2
5. key:'C' 在旧数组位置3 → 只需移动到位置3
结果:4次移动操作,0次销毁重建!
这就是为什么 React 一直强调不要用 index 做 key:
// ❌ 错误:用 index 做 key
{list.map((item, index) => <Item key={index} data={item} />)}
// 当你在头部插入一条数据时:
// 旧:key:0=A, key:1=B, key:2=C
// 新:key:0=D, key:1=A, key:2=B, key:3=C
//
// React 对比:
// key:0: A vs D → 类型不同,销毁重建(其实只是位置变了)
// key:1: B vs A → 类型不同,销毁重建
// key:2: C vs B → 类型不同,销毁重建
// key:3: - vs C → 新增
// 本来只是插入一条数据,变成了全部销毁重建!
// ✅ 正确:用唯一 id 做 key
{list.map(item => <Item key={item.id} data={item} />)}
4.5 Diff 算法的整体流程
beginWork(fiber):
1. 比较 fiber.type 是否与旧节点相同
├── 类型不同 → 标记 DELETION + PLACEMENT(不走 diff)
└── 类型相同 → 进入 diff
├── 是 DOM 元素 → diffProps(比较属性)
│ ├── 新增 / 变化的属性 → UPDATE
│ └── 删除的属性 → 移除
└── 是组件 → 渲染组件,得到新的子元素
└── reconcileChildren(fiber, newChildren)
├── 遍历 newChildren
├── 与 oldChildren 通过 key 匹配
├── 标记 PLACEMENT / DELETION / UPDATE
└── 构建新的 Fiber 子链表
五、Fiber + Diff:天作之合
让我们串起来看一次 setState 的完整旅程:
1. setState 触发
↓
2. 创建 Update 对象,放入该 Fiber 的 updateQueue
↓
3. 从触发更新的 Fiber 开始,向上找到 root
↓
4. 从 root 开始进入 Render 阶段(可中断)
│
├── beginWork:遍历每个 Fiber 节点
│ ├── 对比新旧 props → Diff Props
│ ├── 对比新旧子节点 → Diff Children
│ └── 生成 effectTag 标记(PLACEMENT / UPDATE / DELETION)
│
└── completeWork:构建 effectList(副作用链表)
│
5. Commit 阶段(不可中断)
├── before mutation:执行 getSnapshotBeforeUpdate
├── mutation:根据 effectTag 操作真实 DOM
└── layout:执行 componentDidMount / componentDidUpdate / useEffect
↓
6. 用户看到更新后的页面
核心关系总结:
Fiber(架构) + Diff(算法) = Reconciliation(协调)
Fiber 回答了"什么时候做":分片、调度、可中断
Diff 回答了"做什么":找出最小的变更集
协调器把它们融合在一起,实现了高效、不卡顿的 UI 更新
六、总结
| 概念 | 核心作用 | 一句话概括 |
|---|---|---|
| Virtual DOM | JS 对象模拟 DOM | 让 DOM 操作变便宜 |
| Fiber 架构 | 时间切片 + 链表遍历 | 让渲染过程可中断、可恢复、可调度 |
| Diff 算法 | Tree + Component + Element 三层策略 | 用 O(n) 的代价找出最小更新集 |
| 事件循环 | 宏任务 / 微任务机制 | Fiber 利用空闲时间执行低优任务 |
理解 React 就理解了现代前端框架的设计哲学:
在有限的资源(单线程)下,通过精巧的架构设计(Fiber)和算法优化(Diff),实现流畅的用户体验。React 不追求一蹴而就,而是积少成多——每一帧只做一点点,但最终汇聚成完整、流畅的 UI。