React 虚拟 DOM 与 Diff 算法
React 虚拟 DOM 与 Diff 算法:O(n) 背后的工程智慧
引言
「虚拟 DOM 很快」是前端圈流传最广的误解之一。事实上,虚拟 DOM 相比直接操作 DOM 并不总是更快——它的真正价值在于让「声明式 UI」成为可能:开发者只管描述「UI 应该长什么样」,至于如何高效地把旧 UI 变成新 UI,全部交给 Diff 算法完成。本文将从虚拟 DOM 的数据结构说起,逐步拆解 React 的调和策略,最后给出 Diff 性能陷阱的完整清单。
一、虚拟 DOM 到底是什么
虚拟 DOM(Virtual DOM)本质上是一个描述 UI 的普通 JavaScript 对象树。它没有真实 DOM 的昂贵属性(如布局引擎、样式计算、渲染对象),创建和销毁成本极低,且完全独立于浏览器,这为跨端渲染(React Native)和服务端渲染(SSR)奠定了基础。
React Element 与 Fiber 的关系
React 中有两个容易混淆的概念:Element(渲染输出,不可变描述)与 Fiber(工作单元,可变、带副作用信息)。渲染的输入是 Element 树,中间产物是 Fiber 树,最终输出是真实 DOM。
flowchart LR
classDef vA fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
classDef vB fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
classDef vC fill:#e0f7fa,stroke:#00838f,color:#004d40
classDef vD fill:#e8f5e9,stroke:#388e3c,color:#1b5e20
A[组件渲染函数执行] --> B[产生 Element 树]
B --> C{与上次 Element 对比}
C -->|Diff| D[更新 Fiber 树]
D --> E{按标记执行 DOM 操作}
E --> F[真实 DOM 更新]
B --> G[复用 Element 描述<br/>props 不变则跳过渲染]
D --> H[双缓存切换 alternate]
click C "https://react.dev/learn/render-and-commit" "渲染与提交"
class A vA
class B vB
class C vA
class D vB
class E vC
class F vD
class G vA
class H vB
为什么需要中间层
直接操作真实 DOM 的问题在于:DOM 节点本身携带大量运行时状态(事件监听、布局缓存、子节点引用),高频创建与销毁代价惊人。虚拟 DOM 作为「廉价副本」,让 React 能够在内存中完成大部分对比工作,把昂贵的 DOM 操作压缩到最少次数,并且把多次变更批量提交。
二、虚拟 DOM 的对象结构
一个 React Element 是 createElement 的产物,包含六个核心字段:
javascript12345678const element = { $$typeof: Symbol.for("react.element"), type: "div", // 类型:'div' | 组件函数 | Fragment key: null, // 稳定标识(列表) ref: null, // DOM 引用 props: { className: "box", children: [...] }, _owner: null, // 创建者信息(开发调试) };
Element 树示例
flowchart TB
classDef eA fill:#fff3e0,stroke:#f57c00,color:#e65100
classDef eB fill:#e8f5e9,stroke:#388e3c,color:#1b5e20
classDef eC fill:#fce4ec,stroke:#c62828,color:#b71c1c
R["createElement('div', {className:'app'})"] --> A["createElement(Header, {title})"]
R --> B["createElement('main', null)"]
B --> C["createElement(List, {items})"]
B --> D["createElement('p', null, '加载中')"]
class R eA
class A eB
class B eB
class C eC
class D eC
Element 树是纯数据,可以被序列化、被复制、被缓存。React 的很多优化(如 memo 的记忆化)本质上是「跳过 Element 树的重新生成」——如果组件没有重新执行,就不会产生新的 Element,Diff 也就无从发生。
三、Diff 的三大假设
如果对任意两棵树求最小编辑距离,经典算法是 O(n³) 的时间复杂度——对真实应用完全不可行。React 通过三条启发式假设把复杂度降到 O(n):
假设一:不同类型 → 整树重建
flowchart TD
classDef dA fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
classDef dB fill:#ffebee,stroke:#c62828,color:#b71c1c
A[旧节点 type='a'] --> B{新节点 type}
B -->|'span'| C[直接卸载旧子树]
C --> D[创建全新子树]
D --> E[不保留任何内部状态]
A -->|'a' 相同| F[进入属性级 Diff]
F --> G{props 是否变化}
G -->|有变化| H[更新 DOM 属性]
G -->|无变化| I[跳过该节点]
click C "https://react.dev/learn/preserving-and-resetting-state" "状态保留与重置"
class A dA
class B dA
class C dB
class D dB
class E dB
class F dA
class G dA
class H dA
class I dA
推论:<a> 换成 <Link>、<div> 换成 <section>,React 会整树重建,子组件全部卸载重挂、内部 state 全部丢失。因此切换组件类型时要谨慎——这往往是「状态莫名丢失」的根因。
假设二:同类型 → 属性级更新
同类型的 DOM 节点,React 只比较 className、style、事件监听等属性,并做最小更新:
- 新 props 中没有的属性 →
removeAttribute; - style 逐属性对比 → 只更新变化的样式;
- 事件监听 → 通过事件委托统一管理,不重复绑定。
假设三:key 稳定 → 列表复用
列表渲染是 Diff 最复杂的场景。React 用 key 标识列表项的身份:
flowchart TB
classDef kA fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
classDef kB fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
classDef kC fill:#ffebee,stroke:#c62828,color:#b71c1c
subgraph Old[旧列表 key]
direction LR
O1[A] --> O2[B]
O2 --> O3[C]
end
subgraph New[新列表 key]
direction LR
N1[A] --> N2[C]
N2 --> N3[D]
end
subgraph DiffResult[Diff 结果]
direction LR
D1[复用 A 不变] --> D2[卸载 B]
D2 --> D3[复用 C 移动位置]
D3 --> D4[新增 D]
end
Old --> DiffResult
New --> DiffResult
click N1 "https://react.dev/learn/rendering-lists#keeping-list-items-in-order-with-key" "key 的作用"
class O1 kA
class O2 kB
class O3 kB
class N1 kA
class N2 kB
class N3 kB
class D1 kA
class D2 kC
class D3 kB
class D4 kA
key 的正确选择:使用业务唯一 ID(如 user.id);避免使用数组索引;禁止使用随机数。错误的 key 会导致组件状态错位、输入内容丢失、性能退化到 O(n²) 乃至全量重建。
四、单节点与多节点的 Diff 策略
单节点 Diff
单个节点(<Child />)的对比路径非常直接:比较 type 与 key,决定复用或重建。唯一容易忽略的是 key 在单节点上的语义——如果父级给单节点换了 key,React 同样会重建整个子树。
多节点 Diff 的三种情况
React 对数组子节点采用两轮遍历:
flowchart TD
classDef m1 fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
classDef m2 fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
classDef m3 fill:#e0f7fa,stroke:#00838f,color:#004d40
classDef m4 fill:#ffebee,stroke:#c62828,color:#b71c1c
A[第一轮遍历<br/>从头部同步比较] --> B{key 与 type 是否一致}
B -->|一致| C[复用节点 继续向前]
C --> D{遇到不同点}
D -->|是| E[第一轮结束]
B -->|不一致| E
E --> F{是否处理完毕}
F -->|旧节点还有剩余| G[第二轮遍历 从尾部反向比较]
F -->|新节点还有剩余| H[剩余旧节点全部删除]
G --> I{新旧均未耗尽}
I -->|是| J[第三阶段 key 映射表查找]
J --> K[按 key 精确复用移动]
I -->|否| L[剩余处理完毕]
click J "https://react.dev/learn/rendering-lists" "列表渲染文档"
class A m1
class B m1
class C m3
class D m1
class E m1
class F m2
class G m2
class H m3
class I m2
class J m4
class K m3
class L m3
性能启示:当列表「尾部插入」时,第一轮遍历直接全部复用,效率最高;当「头部插入」时,React 会走 key 映射表,需要建立哈希索引,成本上升。因此尽量保持列表操作以追加为主,或在业务层控制插入位置。
五、Fiber 的副作用收集与提交
Diff 只是「找出差异」,真正修改 DOM 发生在提交阶段。React 通过 flags(副作用标记) 记录每个节点的变更类型,最后统一收集、统一提交:
flowchart LR
classDef fA fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
classDef fB fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
classDef fC fill:#e0f7fa,stroke:#00838f,color:#004d40
A[Placement 插入] --> E[收集到<br/>effectList]
B[Update 更新] --> E
C[Deletion 删除] --> E
D[Passive 被动副作用] --> E
E --> F[completeWork 阶段<br/>子树汇总]
F --> G[commitMutationEffects<br/>执行 DOM 变更]
G --> H[layoutEffects<br/>同步副作用]
click G "https://react.dev/learn/render-and-commit" "commit 阶段"
class A fA
class B fB
class C fC
class D fA
class E fB
class F fB
class G fC
class H fC
批处理:React 18 的 Automatic Batching 会把同一事件循环内的多次 setState 合并为一次 Diff + 一次提交,避免中间态的重复渲染。这是「React 不总是每次 setState 都重新渲染」的机制基础。
六、Diff 性能陷阱完整清单
常见错误及其后果
| 写法 | 问题 | 后果 | 正确做法 |
|---|---|---|---|
key={index} 且列表会变 |
key 错位 | 状态错乱、全量重建 | 使用业务 ID |
key={Math.random()} |
key 每次不同 | 每次全量卸载重挂 | 稳定 ID |
组件返回新函数 () => fn() |
props 引用变化 | memo 失效 | useCallback |
内联对象 style={{...}} |
引用变化 | memo 失效 | 模块级常量 |
| 组件类型动态切换 | 触发整树重建 | 状态丢失 | 保持类型稳定 |
| 渲染期间直接操作 DOM | 破坏声明式 | 并发下错乱 | 用 Effects/ref |
如何用 Profiler 定位 Diff 开销
flowchart TD
classDef pA fill:#e3f2fd,stroke:#1976d2,color:#0d47a1
classDef pB fill:#f3e5f5,stroke:#8e24aa,color:#4a148c
classDef pC fill:#e8f5e9,stroke:#388e3c,color:#1b5e20
A[打开 React DevTools Profiler] --> B[记录一次交互]
B --> C[查看火焰图]
C --> D{哪个组件耗时最长}
D -->|渲染耗时高| E[检查是否应 memo]
E --> F{子组件是否频繁重渲染}
F -->|是| G[useCallback/useMemo 稳定引用]
F -->|否| H[拆分组件 隔离状态]
D -->|提交数量多| I[检查批处理与优先级]
D -->|DOM 操作多| J[检查 key 稳定性与结构]
click B "https://react.dev/learn/you-might-not-need-an-effect" "你可能不需要 Effect"
class A pA
class B pA
class C pB
class D pB
class E pC
class F pC
class G pC
class H pC
class I pB
class J pB
七、总结
虚拟 DOM 与 Diff 算法的核心价值,不是「比直接操作 DOM 快」,而是:
- 声明式范式:开发者描述目标状态,React 负责转变;
- O(n) 复杂度:通过三条启发式假设,把指数级对比降到线性;
- 批量提交:DOM 操作压缩到最少,且原子化执行;
- 跨端能力:虚拟树独立于平台,同一套逻辑渲染到 Web / Native / Canvas;
- 可测试性:纯数据树可以在 Node 环境中做快照测试(如 jest 的 snapshot)。
对日常开发而言,记住一句话就够:让 key 稳定、让引用稳定、让组件类型稳定,Diff 的性能就处于最优状态;配合 Profiler 观察实际数据,胜过一切理论优化。
下一篇我们将深入 Fiber 的调度与优先级机制,敬请期待。
