虚拟 DOM、Diff 算法与 Fiber
一、虚拟 DOM 是什么?
一句话:用 JS 对象来描述真实 DOM 的结构,先在内存里算好差异,再最小化更新真实 DOM。
真实 DOM vs 虚拟 DOM
// 真实 DOM(浏览器里的)
<div class="box">
<h1>标题</h1>
<p>内容</p>
</div>
// 虚拟 DOM(JS 对象)
{
type: 'div',
props: {
className: 'box',
children: [
{ type: 'h1', props: { children: '标题' } },
{ type: 'p', props: { children: '内容' } }
]
}
}
为什么要用虚拟 DOM?
操作真实 DOM 很慢(涉及浏览器重排重绘),而操作 JS 对象很快。
数据变化
↓
生成新的虚拟 DOM 树
↓
新旧虚拟 DOM 对比(Diff)
↓
找出最小差异
↓
只更新变化的真实 DOM(Patch)
| 方式 | 做法 | 性能 |
|---|---|---|
| 直接操作 DOM | 数据一变就全量更新 DOM | 慢 |
| 虚拟 DOM | 先算差异,只更新变化的部分 | 快(大多数场景) |
注意:虚拟 DOM 不是"比直接操作 DOM 快",而是在大量更新时,通过批量 + 最小化更新来优化性能。极简场景下,直接操作 DOM 反而更快。
二、Diff 算法
React 用 Diff 算法对比新旧虚拟 DOM 树,找出需要更新的部分。
三个策略(把 O(n³) 降到 O(n))
| 策略 | 说明 |
|---|---|
| 同层比较 | 只比较同一层级的节点,不跨层级比较 |
| 类型判断 | 节点类型不同 → 直接销毁旧树,创建新树 |
| Key 标识 | 同类型的列表元素用 key 来标识,精准匹配 |
策略一:同层比较
旧树: 新树:
A A
/ \ / \
B C B D ← 只比较同层:发现 C→D,替换
| |
D E
React 只会比较 A-A、B-B、C-D... 不会跨层去比较。如果把节点从一棵子树移到另一棵,React 会销毁+重建,而不是移动。
策略二:类型判断
// 旧 新
<div> <span>
<Counter/> <Counter/>
</div> </span>
// div → span:类型不同 → 整个销毁旧树(包括 Counter),重建新树
// Counter 的 state 会丢失!
策略三:Key 的作用(列表 Diff)
// 没有 key:插入一项,React 不知道哪个是新的,可能全部更新
// 旧:[A, B, C]
// 新:[A, X, B, C]
// React:A不变,B→X(错),C→B(错),新增C(错)—— 大量无效更新
// 有 key:React 能精准识别
// 旧:[A:1, B:2, C:3]
// 新:[A:1, X:4, B:2, C:3]
// React:A不变,新增X,B不变,C不变 —— 只做一次插入 ✅
Key 的最佳实践:
// ❌ 用 index 做 key(增删排序时出问题)
list.map((item, i) => <li key={i}>{item.name}</li>)
// ❌ 用随机数做 key(每次渲染都变,等于没加)
list.map(item => <li key={Math.random()}>{item.name}</li>)
// ✅ 用唯一且稳定的 id
list.map(item => <li key={item.id}>{item.name}</li>)
三、Fiber 架构
旧架构的问题(React 15)
React 15 使用递归遍历虚拟 DOM 树(Stack Reconciler):
开始 Diff → 递归遍历整棵树 → 全部算完 → 更新 DOM
↑ 这个过程不能中断!
问题:如果组件树很大,递归遍历耗时超过 16ms(一帧),浏览器来不及渲染 → 页面卡顿。
Fiber 是什么?(React 16+)
一句话:把大任务拆成小任务,每个小任务做完看看有没有更重要的事(比如用户输入),有就先去做,没有就继续。
旧(Stack):一口气干完 ████████████████████████ 卡了!
新(Fiber):分段干 ██ 空 ██ 空 ██ 空 ████ 不卡!
↑ ↑ ↑ 检查有没有更高优先级的任务
Fiber 的核心思想
| 概念 | 说明 |
|---|---|
| 可中断 | 渲染过程可以暂停,让出主线程给浏览器 |
| 可恢复 | 暂停后可以从断点继续,不用从头开始 |
| 优先级调度 | 高优先级(用户输入)优先处理,低优先级(数据请求后的渲染)延后 |
| 增量渲染 | 一帧只做一部分工作,分多帧完成 |
Fiber 节点结构
每个组件/元素对应一个 Fiber 节点,通过链表关联:
App (Fiber)
↓ child
Header (Fiber) → sibling → Main (Fiber) → sibling → Footer (Fiber)
↓ child ↓ child
Logo (Fiber) Content (Fiber)
↑ return ↑ return
Header Main
| 指针 | 指向 |
|---|---|
child |
第一个子节点 |
sibling |
下一个兄弟节点 |
return |
父节点 |
遍历顺序:深度优先 — child → sibling → return。因为是链表,可以随时暂停,记住当前位置,之后继续。
Fiber 的两个阶段
| 阶段 | 名称 | 特点 |
|---|---|---|
| Render 阶段 | 协调(Reconciliation) | 计算差异,可中断,不操作 DOM |
| Commit 阶段 | 提交 | 把差异应用到真实 DOM,不可中断,同步执行 |
Render 阶段(可中断) Commit 阶段(同步)
━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━
遍历 Fiber 树 更新真实 DOM
对比新旧,标记变化 执行生命周期/useEffect
可以暂停、恢复 一口气做完,不暂停
四、双缓冲机制(Double Buffering)
React 同时维护两棵 Fiber 树:
| 树 | 作用 |
|---|---|
| current 树 | 当前屏幕上显示的 UI |
| workInProgress 树 | 内存中正在构建的新树 |
current 树(屏幕上) workInProgress 树(内存中)
App App'
/ \ / \
Header Main Header Main'
|
Content'(有更新)
构建完成后 → workInProgress 变成新的 current(指针切换,瞬间完成)
好处:构建过程中用户看到的始终是完整的旧 UI,不会出现"半成品"。跟显卡双缓冲一个道理。
五、优先级模型(Lanes)
React 18 用 Lane 模型 给任务分优先级,高优先级可以打断低优先级:
| 优先级 | 场景 | 例子 |
|---|---|---|
| 同步(最高) | 用户直接交互 | 打字、点击 |
| 连续输入 | 持续交互 | 拖拽、滚动 |
| 普通 | 数据更新 | 请求回来后 setState |
| 过渡 | 不紧急的更新 | useTransition 包裹的更新 |
| 空闲(最低) | 可延后 | offscreen 预渲染 |
核心思想:用户能感知的操作(输入、点击)必须立即响应,数据渲染可以稍等。
六、高频面试题
Q1:虚拟 DOM 一定比真实 DOM 快吗?
不一定。虚拟 DOM 有创建 JS 对象 + Diff 对比的开销。在以下场景,直接操作 DOM 可能更快:
- 极简单的 UI(一两个元素)
- 已知确切的 DOM 操作(不需要 Diff)
虚拟 DOM 的优势在于:在复杂应用中,自动帮你找到最小更新范围,开发者不用手动管理 DOM 更新。
Q2:key 为什么不能用 index?
当列表会增删或排序时,index 会变化,React 的 Diff 会把元素搞混:
旧:[A:0, B:1, C:2] 删除A后
新:[B:0, C:1] key=0 的 A 和 key=0 的 B 对比 → React 认为 A 变成了 B → 错误复用
用唯一 id 做 key 就不会有这个问题。
Q3:Fiber 和之前的区别?
| 对比 | Stack Reconciler (React 15) | Fiber Reconciler (React 16+) |
|---|---|---|
| 数据结构 | 递归调用栈 | Fiber 链表 |
| 是否可中断 | 不可中断 | 可中断可恢复 |
| 调度 | 同步,一次性完成 | 按优先级分时间片 |
| 大组件树 | 可能卡顿 | 不卡顿 |
Q4:React 的渲染流程?
setState / props 变化
↓
触发调度(Scheduler)→ 按优先级安排任务
↓
Render 阶段 → 遍历 Fiber 树,Diff 对比,标记需要更新的节点
↓(可中断)
Commit 阶段 → 把标记的更新同步应用到真实 DOM
↓
浏览器绘制
Q5:什么是双缓冲?为什么需要?
React 在内存中构建 workInProgress 树,完成后一次性替换 current 树(切换指针)。好处是用户始终看到完整 UI,不会看到渲染到一半的中间状态。
Q6:React 怎么决定哪个更新先执行?
通过 Lane 模型。每个更新会被分配一个 Lane(优先级),Scheduler 按优先级调度。用户输入是最高优先级,useTransition 包裹的更新是低优先级,可以被高优先级打断。