用了两三年 React,我一直对"虚拟 DOM 更快"这个说法半信半疑。直到有一次优化一个长列表卡顿问题,才真正逼着自己把这套底层逻辑摸清楚。这篇是我的学习笔记,试图用具体例子把"为什么"和"怎么做"说清楚,而不是把概念堆在一起。
一、为什么需要虚拟 DOM?
先从"直接操作真实 DOM 有什么问题"聊起。
真实 DOM 操作慢在哪?
上一篇聊浏览器渲染时提到过,每次修改 DOM,浏览器都要重跑一遍渲染流水线:
修改 DOM → 重新计算样式 → Layout(重排)→ Paint(重绘)→ Composite
这个流水线本身没问题,问题在于频率。如果你有一个复杂页面,状态变化触发了 100 次 DOM 修改,流水线就要跑 100 次。每次都是真实的浏览器渲染工作,代价不低。
那"每次重新渲染整个页面"呢?
你可能会想:干脆每次状态变化,把整个页面 innerHTML 全部重写,不就省事了?
理论上是"最简单"的方案,但问题是:
- 慢:重建整个 DOM 树,触发全量 Layout + Paint,比局部更新慢得多
- 丢失用户状态:用户正在输入的文本框内容会被清空、滚动位置跳回顶部、当前 focus 的元素失焦——体验直接崩掉
虚拟 DOM 要解决的,正是这两个问题之间的矛盾:既不想每次手动挑出要更新的 DOM 节点,又不想粗暴地全量重建。
二、虚拟 DOM 是什么?
虚拟 DOM(Virtual DOM)本质上就是用普通 JS 对象来描述 DOM 结构。
操作真实 DOM 慢,但操作 JS 对象快得多(快几百倍)。所以 React 的思路是:先在内存里用 JS 对象"演练"要做的改动,算出最小改动集,再一次性更新到真实 DOM。
来看一个具体的对应关系:
<!-- 真实 DOM -->
<div class="card">
<h1>标题</h1>
<p>描述内容</p>
</div>
// 对应的虚拟 DOM(JS 对象)
{
type: 'div',
props: { className: 'card' },
children: [
{
type: 'h1',
props: {},
children: ['标题']
},
{
type: 'p',
props: {},
children: ['描述内容']
}
]
}
React 的 JSX 语法,本质上就是在写这样的对象描述,只是换了一套更好看的语法糖。
// 你写的 JSX
const element = (
<div className="card">
<h1>标题</h1>
<p>描述内容</p>
</div>
);
// Babel 编译后,等价于
const element = React.createElement(
'div',
{ className: 'card' },
React.createElement('h1', null, '标题'),
React.createElement('p', null, '描述内容')
);
三、虚拟 DOM 的工作流程
有了虚拟 DOM,React 的渲染流程变成了这样:
状态变化(setState / useState)
↓
生成新的虚拟 DOM 树
↓
与上一次的旧虚拟 DOM 树做 Diff(对比)
↓
找出差异部分(patch)
↓
只把差异更新到真实 DOM
核心价值只有一句话:最小化真实 DOM 操作次数。
举个例子——一个有 1000 个节点的页面,某次状态变化只影响了其中 3 个节点。
| 方案 | 真实 DOM 操作次数 |
|---|---|
| 全量重建 | 1000 次 |
| 手动精准更新 | 3 次(但需要你自己写逻辑) |
| 虚拟 DOM + Diff | 3 次(自动计算) |
虚拟 DOM 让你享受到了"手动精准更新"的性能,但不需要你自己写那些繁琐的 DOM 操作逻辑。
四、Diff 算法:如何高效比较两棵树?
现在问题来了:比较两棵树,算出最小改动,怎么做?
理论最优解有多慢?
计算机科学中,对比两棵树的最优算法复杂度是 O(n³) 。
100 个节点?10⁶ = 100 万次计算。 1000 个节点?10⁹ = 10 亿次计算。
每次状态更新都跑 10 亿次操作,页面直接冻住。这个路走不通。
React 的解法:三个假设,换来 O(n)
React 选择了一个工程上的妥协:基于三个在实际开发中几乎总是成立的假设,把复杂度降到 O(n)。
假设 1:不同类型的节点,直接替换
如果一个节点从 <div> 变成了 <p>,React 不会试图比较它们的内部差异——直接销毁整棵旧树,重建新树。
// 旧的虚拟 DOM
<div>
<input value="用户输入的内容" />
<span>子元素</span>
</div>
// 新的虚拟 DOM(根节点类型变了)
<p>
<input value="用户输入的内容" />
<span>子元素</span>
</p>
这种情况下,React 会:
- 卸载整个
<div>及其所有子节点(包括input里用户输入的内容) - 重新挂载整个
<p>树
所以如果你的根节点类型频繁切换,会造成不必要的子组件销毁重建。这个假设告诉我们:组件的根节点类型,能稳定就稳定。
假设 2:只比较同层节点,不跨层级
React 的 Diff 是逐层对比的,不会尝试找跨层移动的节点。
旧树 新树
A A
/ \ / \
B C → B C
/ \ \
D E E
如果你把节点 D 从 B 的子节点移动到了 C 的子节点下,React 看到的是:
- B 层:少了 D → 删除 D
- C 层:多了 D → 新建 D
它不会识别出"这是同一个节点在移动",而是执行一次删除 + 一次创建。
这意味着:跨层级移动 DOM 节点,在 React 里代价比你想象的高。在实际组件设计中,尽量避免通过条件渲染在不同层级之间"搬运"同一个组件。
假设 3:用 key 识别列表节点
这是三个假设里和日常开发最紧密的一个。
当对比一组子节点(列表)时,如果没有 key,React 只能按顺序逐一对比:
// 旧列表
<ul>
<li>张三</li> // 位置 0
<li>李四</li> // 位置 1
<li>王五</li> // 位置 2
</ul>
// 在开头插入"赵六"后的新列表
<ul>
<li>赵六</li> // 位置 0
<li>张三</li> // 位置 1
<li>李四</li> // 位置 2
<li>王五</li> // 位置 3
</ul>
没有 key,React 按位置对比:位置 0 内容变了(张三→赵六)→ 更新;位置 1 内容变了 → 更新;位置 2 内容变了 → 更新;位置 3 是新增 → 新建。改了 4 个节点,实际上只是新增了 1 个。
有了 key,React 能识别出哪些节点是"同一个",从而准确复用:
<ul>
<li key="zhaoliu">赵六</li> // 新增
<li key="zhangsan">张三</li> // 复用,不更新
<li key="lisi">李四</li> // 复用,不更新
<li key="wangwu">王五</li> // 复用,不更新
</ul>
只做 1 次插入操作,剩下三个节点直接复用。
五、为什么不能用 index 做 key?
这是 React 开发中最经典的"坑"之一,我觉得有必要把例子说完整。
场景:删除列表项
初始列表 [张三, 李四, 王五],用 index 做 key:
// 初始状态
<ul>
<li key={0}>张三</li>
<li key={1}>李四</li>
<li key={2}>王五</li>
</ul>
现在删除张三,列表变成 [李四, 王五]:
// 删除后
<ul>
<li key={0}>李四</li> // key=0,内容从"张三"变成了"李四"
<li key={1}>王五</li> // key=1,内容从"李四"变成了"王五"
// key=2 消失 → 删除
</ul>
React 看到的是:
key=0:内容变了 → 更新key=1:内容变了 → 更新key=2:消失了 → 删除
结果:3 次 DOM 操作。但我们实际上只删了 1 个元素,只需要 1 次 DOM 操作。
改用唯一 ID 做 key:
// 初始状态
<ul>
<li key="zhangsan">张三</li>
<li key="lisi">李四</li>
<li key="wangwu">王五</li>
</ul>
// 删除后
<ul>
<li key="lisi">李四</li> // key 没变,内容没变 → 跳过
<li key="wangwu">王五</li> // key 没变,内容没变 → 跳过
// key="zhangsan" 消失 → 删除
</ul>
React 准确识别出只有"zhangsan"消失了:1 次 DOM 操作,完全正确。
更严重的 bug:输入框状态错乱
上面的例子只是性能问题,但下面这个是功能 bug。
场景:列表每一项有一个输入框,用户在第一项(张三)的输入框里填了内容,然后删除第一项。
// 每一项带输入框的组件
function ListItem({ name }) {
return (
<li>
<span>{name}</span>
<input placeholder={`备注 ${name}`} />
</li>
);
}
// 用 index 做 key
{list.map((item, index) => (
<ListItem key={index} name={item.name} />
))}
删除"张三"后,React 对 key=0 做的是更新(把 name prop 改成"李四"),而不是销毁重建。
React 复用了原来"张三"那个 DOM 节点,只更新了 name 属性——但输入框是非受控的,它的内部状态(用户输入的内容)跟着 DOM 节点走,不跟着数据走。
结果:删掉张三之后,李四的输入框里还显示着刚才给张三写的备注内容。数据删了,UI 状态还留着。
这种 bug 在测试环境容易被漏掉,到了生产环境才被用户发现,排查起来也很头疼。
结论
✅ 用数据的唯一 ID 做 key(数据库主键、UUID 等)
❌ 不用 index 做 key(除非列表永远不会增删排序)
❌ 不用随机数做 key(每次渲染都会强制重建,比没有 key 更差)
六、整体流程回顾
用户交互 / 数据请求
↓
setState / useState 触发更新
↓
React 调用 render,生成新的虚拟 DOM 树
↓
┌─────────────────────────────────────┐
│ Diff 算法(O(n)) │
│ │
│ 类型不同?→ 直接替换 │
│ 只比同层 → 不跨层 │
│ 有 key? → 精准识别复用 │
└─────────────────────────────────────┘
↓
生成最小 patch(差异集合)
↓
批量更新到真实 DOM
↓
浏览器渲染(只有变化的部分触发重排/重绘)
延伸思考
梳理完这些,我产生了几个新问题,暂时还没完全搞清楚:
- React Fiber 和虚拟 DOM 是什么关系? Fiber 架构是 React 16 引入的,它把虚拟 DOM 的 Diff 过程变成了可中断的,这对长列表渲染有什么具体影响?
- Vue 的 Diff 和 React 的 Diff 有什么区别? 听说 Vue 3 的双端对比算法在某些场景下效率更高,是什么原理?
React.memo和useMemo和虚拟 DOM 的 Diff 是什么关系? 它们是在 Diff 之前就跳过了,还是 Diff 之后的优化?
这些可能是下一篇的方向,也欢迎有研究的朋友交流。
🧠 面试常问版(核心记忆点)
5 条浓缩,面试前快速过一遍:
- 虚拟 DOM 的本质:用 JS 对象描述 DOM 结构,在内存中做 Diff,最小化真实 DOM 操作次数,解决"全量重建"导致的慢和状态丢失问题。
- Diff 算法的三个假设:① 不同类型节点直接替换;② 只对比同层节点;③ 用 key 识别列表节点。三个假设把复杂度从 O(n³) 降到 O(n)。
- key 的作用:帮助 React 识别哪些节点是"同一个",从而在列表更新时准确复用,避免不必要的 DOM 操作。
- index 做 key 的两种问题:性能问题(删除头部节点会触发全量更新)+ 功能 bug(非受控组件的状态跟 DOM 节点走,不跟数据走,导致状态错乱)。
- key 的正确选择:用数据的唯一 ID(数据库主键、UUID 等),不用 index,不用随机数。
参考资料
- React 官方文档 - Reconciliation - React Diff 算法官方说明
- React 官方文档 - Lists and Keys - key 的正确使用
- MDN - Document Object Model - DOM 基础概念