4.1 虚拟 DOM 的本质:JavaScript 对象描述 UI
虚拟 DOM(Virtual DOM)是一个用 JavaScript 对象来描述真实 DOM 结构的轻量级表示。React 使用它来抽象浏览器的渲染层,从而在内存中维护一棵 UI 描述树。
每一个虚拟 DOM 节点都是一个普通的 JS 对象,包含以下关键信息:
- type:节点类型,可以是字符串(如
'div')表示原生 DOM 元素,或者是函数/类(如App)表示组件。 - props:该节点的属性,包括样式、事件、子节点等。
- key:用于唯一标识列表中的节点,帮助 Diff 算法识别节点的增删移动。
- ref:获取真实 DOM 或组件实例的引用。
- $$typeof:内部标记,用于安全地标识 React 元素,防止 XSS 攻击。
例如,下面的 JSX:
<div className="container">
<h1>Hello</h1>
<Button type="primary">Click</Button>
</div>
会被 Babel 编译成 React.createElement 调用,最终产生类似这样的虚拟 DOM 对象:
{
type: 'div',
props: {
className: 'container',
children: [
{
type: 'h1',
props: { children: 'Hello' }
},
{
type: Button, // 组件类型是函数引用
props: { type: 'primary', children: 'Click' }
}
]
}
}
这种用纯 JS 对象描述 UI 的方式,带来了几个本质好处:
- 与平台解耦:虚拟 DOM 不直接操作浏览器 API,因此可以渲染到不同目标(DOM、Native、Canvas 等)。
- 快速创建与比较:JS 对象的操作速度远快于真实 DOM 操作,适合频繁对比更新。
- 方便的序列化与调试:虚拟 DOM 可以直接打印、序列化,便于开发调试。
关键认知:虚拟 DOM 的本质是“UI 的快照”,它让 React 能在内存中快速生成新 UI 的表示,并与上一次快照对比,最终计算出最小变更集。
4.2 虚拟 DOM 的核心价值:跨平台抽象 + 批量更新 + 减少 DOM 操作
虚拟 DOM 的价值不仅仅体现在性能优化上,它还实现了架构层面的关键目标。
跨平台抽象
因为虚拟 DOM 只是一层 JS 对象,React 可以针对不同平台编写不同的渲染器(Renderer):
- React DOM:将虚拟 DOM 渲染为浏览器 DOM。
- React Native:将虚拟 DOM 渲染为移动端原生控件。
- React Canvas/Three.js:甚至可以渲染到 Canvas 或 WebGL 中。
这套抽象使得“Learn Once, Write Anywhere”成为可能。开发者在 Web 端编写的组件逻辑与交互模式,可以几乎无缝地迁移到移动端。
批量更新(Batching)
没有虚拟 DOM 时,多次状态更新可能导致多次直接操作真实 DOM,频繁触发重排重绘。React 利用虚拟 DOM 将多次状态更新合并,一次性计算出最终需要变更的部分,然后统一提交到真实 DOM,避免不必要的渲染或布局抖动。
减少不必要的 DOM 操作
虚拟 DOM 通过 Diff 算法找到变化的部分,只更新那些真正发生变化的节点。比如一个列表中有 1000 项,仅仅改变了其中一项的文字,React 只会更新那一个文本节点,而不是重建整个列表。这比传统的手工 DOM 操作更安全、智能。
声明式自动优化
开发者不再需要手动优化 DOM 操作,只需声明最终 UI 状态,React 自动采用最优方案完成更新。对于复杂交互,这极大降低了代码复杂度和出错几率。
4.3 Diff 算法核心策略
传统两棵树的完全对比需要 O(n³) 的时间复杂度,这在大型应用面前无法接受。React 基于两个大胆假设,将 Diff 算法复杂度降低到 O(n):
- 两个不同类型的元素会产生不同的树:如果元素类型改变(如从
div变成span),React 会直接销毁旧节点并创建新节点,不再进行深入的子节点比较。 - 开发者可以通过
key属性暗示哪些子元素在不同的渲染中保持稳定:借助 key,React 可以高效地识别节点的移动、增删,而不是简单地替换。
基于这些假设,React 实现了分层比较的策略。
4.3.1 同层比较:深度优先的分层对比策略
React 不会跨层级比较节点,而是只对同一层级的节点进行对比。如果发现某一层级节点类型不同,则直接移除该节点及其所有子节点,并创建新节点,不会再往下递归比较。

示例:React 只比较同层节点,不会将 A 层的节点与 B 层的节点进行比较。
这种策略虽然有时会丢弃可以复用的子节点(比如整个子树类型改变但其实内部结构相似),但换来了极高的对比效率,且在实际开发中这种场景较少,利大于弊。
具体行为:
- 根节点类型相同:保留 DOM 节点,仅更新变化的属性,然后递归比较子节点。
- 根节点类型不同:彻底销毁旧节点,创建新节点。旧节点的生命周期会被触发
componentWillUnmount,新节点会执行constructor和挂载动作。
实际代码中:
// 旧树
<div>
<span>hello</span>
</div>
// 新树
<section>
<span>hello</span>
</section>
React 会发现根节点类型从 div 变成了 section,于是整个 <div> 及其子 <span> 都会被卸载,然后创建新的 <section> 和新的 <span>。尽管 <span> 内部文字相同,它也不会被复用。
4.3.2 节点类型判断:元素节点、组件节点、文本节点的 Diff 逻辑
React 区分三种节点类型,并采取不同的 Diff 策略。
1. 元素节点(Host Component)
即原生 HTML 标签,如 div、span、input。
- 类型相同:保留该 DOM 节点,仅对比和更新属性(如
className、style、事件监听器等),然后递归更新子节点。 - 类型不同:销毁并重新创建节点。
2. 组件节点(Component Element)
即自定义组件,如 <MyComponent />。组件节点的 type 是组件的函数或类引用。
- 组件类型相同:实例不会销毁,React 会更新组件实例的 props,并触发组件重新渲染,即调用
render或函数体,继续对比其返回的虚拟 DOM。 - 组件类型不同:旧组件实例被销毁,新组件被实例化,组件内部的 state 丢失。
// 旧树
<Header title="Hello" />
// 新树
<Header title="World" />
因为 Header 类型相同,实例保留,只是 props 更新了,组件内部会收到新的 props 并触发重新渲染。
3. 文本节点
当 children 为字符串或数字时,React 会将其视为文本节点。
- 对比两个文本节点时,React 直接比较内容字符串,如果不同则更新该文本节点的
textContent,非常高效。
4.3.3 列表 Diff:双端对比算法、key 的核心作用与误用后果
当对比子节点数组(如列表)时,React 需要判断哪些节点可以复用、哪些需要新增/删除/移动。默认情况下,React 使用双端对比算法(Two-Ended Diff)来最小化 DOM 操作。
双端对比算法
React 维护四个指针:新前、新后、旧前、旧后,按照以下步骤进行优化:
- 先逐一对比新旧列表的头部节点,如果 key 或类型相同,则复用并移动指针。
- 再对比尾部节点。
- 如果头尾都没有找到可复用节点,则在旧列表中查找 key 匹配的节点,进行移动。
- 最后处理新列表中剩余的新增节点和旧列表中多余的删除节点。
这套算法在大多数情况下能准确识别出节点的移动,从而避免不必要的销毁重建。
key 的核心作用
key 是给每一个虚拟 DOM 节点提供的唯一标识,帮助 React 在列表更新时判断哪些节点发生了变化。key 必须是稳定、唯一且可预测的。
正确使用 key:
{todos.map(todo => (
<TodoItem key={todo.id} text={todo.text} />
))}
使用 key 的好处:
- 如果列表某一项被移动、插入或删除,React 会依赖 key 移动现有 DOM,而不是直接销毁重建。
- 可以避免那些存在内部状态的组件(如
<input>非受控组件)因为位置变化而丢失用户输入。
误用 key 的后果
1. 使用数组索引作为 key
{todos.map((todo, index) => (
<TodoItem key={index} text={todo.text} />
))}
假设原始列表有 A、B、C 三项,索引 0、1、2。在头部插入新项后,列表变成 D、A、B、C,索引全部后移。React 对比时会认为索引0的节点就是原来的索引0节点(A),但实际上数据变成了 D,会导致复用错误,出现界面渲染混乱、状态错位等问题。
2. key 不唯一或频繁变化
例如使用 Math.random() 生成 key,每次渲染都会生成新的 key,导致所有节点被销毁重建,性能极差且丢失组件内部状态。
3. 省略 key
React 在开发环境下会抛出警告。在未提供 key 时,React 默认使用数组索引,这与误用索引 key 存在相同问题。
最佳实践:
- 使用数据源中的唯一 ID(如数据库主键)。
- 如果没有合适 ID,可以通过组合多个字段生成唯一且稳定的字符串。
- 避免使用索引作为 key,除非列表是完全静态的、不会重新排序的。
4.4 传统 Diff 与 React Diff 的性能差异
传统 Diff(深度优先递归遍历两棵树)
时间复杂度 O(n³),因为需要两两比较节点,并且确定最小转换步骤。对于含有 1000 个节点的树,传统 Diff 需要数亿次操作,根本无法在浏览器中实时完成。
React Diff
基于上文的三条假设,React 将 Diff 算法复杂度优化为 O(n)。具体优化手段:
- 同层对比:放弃跨层级节点复用,杜绝了大量无谓的比较。
- 类型决定一切:只要类型不一致,直接重建整棵子树,不再浪费时间比较子节点。
- key 标识移动:用 key 快速匹配列表节点,避免逐一比对查找。
在实际性能上,React 可以在 16ms(60fps 的一帧)内完成数千个节点的比较和更新,完全满足现代应用的需求。
但是需要注意,React Diff 是一种启发式算法,某些极端场景(例如频繁交换同级不同类型节点)可能达不到最优的 DOM 操作步骤,但这种情况在真实应用中基本不会构成性能瓶颈。
4.5 协调(Reconciliation)的完整流程
协调(Reconciliation)是 React 根据新的状态(state)或新的 props 重新生成虚拟 DOM,并与旧的虚拟 DOM 进行 Diff,并将变化应用到真实 DOM 的整个过程。在 React 16 之后,这个过程被 Fiber 架构重构,允许异步可中断的渲染。下面先以同步流程为例,说明协调的一般步骤。
步骤 1:触发更新
- 组件内部调用
setState或useState的 setter。 - 父组件重新渲染导致子组件接收到新 props。
- 根组件的
render被强制调用。
步骤 2:生成新的虚拟 DOM
React 从触发更新的组件开始,递归调用其 render 方法或函数体,生成一棵新的虚拟 DOM 树。
步骤 3:进入协调(Reconciler)
React 开始对比新旧虚拟 DOM 树:
- 从根节点开始,按照深度优先的顺序遍历。
- 对于每个节点,根据节点类型执行相应的 Diff 策略(上文所述)。
- 给每个需要变动的真实 DOM 节点打上标记,比如更新属性、替换节点、移动节点等。这些标记被收集到一个“effect list”(副作用列表)中。
步骤 4:提交(Commit)阶段
协调阶段结束后,React 拥有一个记录了所有 DOM 变更的队列。在提交阶段,React 会一次性将这些变更应用到真实 DOM,并且同步触发相关的生命周期或 Hooks,比如 useLayoutEffect、componentDidMount 等。
注意:在 React 16/18 的并发模式下,协调(Render 阶段)可以是异步、可中断的,但提交阶段始终是同步的,保证 UI 的一致性。
实际体现
class App extends React.Component {
state = { count: 0 };
render() {
return (
<div>
<p>{this.state.count}</p>
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
Increment
</button>
</div>
);
}
}
当点击按钮,setState 触发后:
- 组件重新渲染,生成新的虚拟 DOM:
<div>下面<p>的内容变成新的数字。 - React 对比新旧虚拟 DOM,发现
<div>类型相同,更新它的子节点。 - 对比子节点时,发现
<p>里的文本节点改变了,因而将<p>文本更新加入 effect list。 - 提交阶段,React 将
<p>的textContent修改为新的数字,页面更新。
整个流程对开发者透明,开发者只需关注状态的变化。
通过对虚拟 DOM 和 Diff 算法的深入理解,你可以更清楚 React 的性能特性,避免写出触发全量重建的低效代码(如滥用 key 或频繁改变节点类型),并能更自信地解决列表渲染、动画等复杂场景下的问题。