虚拟 DOM 与 Diff 算法
问题
什么是虚拟 DOM?React 的 Diff 算法是如何工作的?key 的作用是什么?
答案
虚拟 DOM(Virtual DOM) 是用 JavaScript 对象来描述真实 DOM 结构的一种技术。React 通过 Diff 算法比较新旧虚拟 DOM 的差异,然后只更新变化的部分到真实 DOM。
什么是虚拟 DOM?
真实 DOM vs 虚拟 DOM
// 真实 DOM
const element = document.createElement('div');
element.className = 'container';
element.appendChild(document.createTextNode('Hello'));
// 虚拟 DOM(React Element)
const vdom = {
type: 'div',
props: {
className: 'container',
children: 'Hello'
}
};
React Element 结构
// JSX
const element = (
<div className="container">
<h1>Title</h1>
<p>Content</p>
</div>
);
// 编译后(React.createElement)
const element = React.createElement(
'div',
{ className: 'container' },
React.createElement('h1', null, 'Title'),
React.createElement('p', null, 'Content')
);
// 生成的虚拟 DOM 对象
const vdom = {
$$typeof: Symbol.for('react.element'),
type: 'div',
key: null,
ref: null,
props: {
className: 'container',
children: [
{ type: 'h1', props: { children: 'Title' } },
{ type: 'p', props: { children: 'Content' } }
]
}
};
虚拟 DOM 的优势
| 优势 | 说明 |
|---|---|
| 减少 DOM 操作 | 批量更新,最小化 DOM 操作次数 |
| 跨平台 | 虚拟 DOM 可以渲染到不同平台(Web、Native、服务端) |
| 声明式编程 | 开发者只需描述 UI 状态,不需手动操作 DOM |
| 可预测性 | 相同状态总是生成相同的 UI |
虚拟 DOM 并不一定比直接操作 DOM 快。它的价值在于:
- 提供了一种高效的更新策略
- 让开发者专注于声明式编程
- 使跨平台渲染成为可能
Diff 算法
传统 Diff 的问题
传统的树 Diff 算法时间复杂度是 ,对于大型应用来说不可接受。
React 通过三个策略将复杂度降低到 :
React Diff 的三个策略
策略一:Tree Diff(树比较)
只比较同层级节点,不进行跨层级比较:
如果节点跨层级移动(如上图 C 从 A 的子节点变成 B 的子节点),React 不会移动它,而是删除旧节点 + 创建新节点。
尽量保持 DOM 结构稳定,避免跨层级移动节点。
策略二:Component Diff(组件比较)
// 旧组件
<ComponentA />
// 新组件
<ComponentB />
// 如果 ComponentA !== ComponentB
// React 会:
// 1. 卸载 ComponentA 及其所有子节点
// 2. 创建 ComponentB 及其所有子节点
| 情况 | React 行为 |
|---|---|
| 相同类型组件 | 继续比较子节点(递归 Diff) |
| 不同类型组件 | 直接替换,不再比较子节点 |
策略三:Element Diff(元素比较)
对于同一层级的子元素,React 使用 key 来标识和追踪节点:
没有 key 的情况
// 旧列表
<ul>
<li>A</li>
<li>B</li>
<li>C</li>
</ul>
// 新列表(在开头插入 D)
<ul>
<li>D</li> // 认为是修改 A → D
<li>A</li> // 认为是修改 B → A
<li>B</li> // 认为是修改 C → B
<li>C</li> // 认为是新增
</ul>
// 结果:4 次 DOM 操作
有 key 的情况
// 旧列表
<ul>
<li key="a">A</li>
<li key="b">B</li>
<li key="c">C</li>
</ul>
// 新列表(在开头插入 D)
<ul>
<li key="d">D</li> // 新增 D
<li key="a">A</li> // 复用
<li key="b">B</li> // 复用
<li key="c">C</li> // 复用
</ul>
// 结果:1 次 DOM 操作(插入 D)
key 的作用
为什么需要 key?
key 的最佳实践
// ✅ 正确:使用唯一且稳定的 id
{items.map(item => (
<ListItem key={item.id} data={item} />
))}
// ❌ 错误:使用 index 作为 key
{items.map((item, index) => (
<ListItem key={index} data={item} /> // 不推荐!
))}
// ❌ 错误:使用随机数作为 key
{items.map(item => (
<ListItem key={Math.random()} data={item} /> // 每次都重新创建!
))}
为什么不应该用 index 作为 key?
// 初始列表
const items = ['A', 'B', 'C'];
// 渲染结果
<li key={0}>A</li>
<li key={1}>B</li>
<li key={2}>C</li>
// 删除 B 后
const items = ['A', 'C'];
// 渲染结果
<li key={0}>A</li> // key=0, 内容从 A 变成 A ✓
<li key={1}>C</li> // key=1, 内容从 B 变成 C ✗ (错误复用)
// 问题:如果 ListItem 有内部状态,状态会错乱
| 场景 | 使用 index 作为 key |
|---|---|
| 列表是静态的,不会增删改 | ✅ 可以 |
| 列表会重新排序 | ❌ 不要 |
| 列表会在中间插入或删除 | ❌ 不要 |
| 列表项有内部状态(如输入框) | ❌ 不要 |
Diff 算法详细流程
单节点 Diff
// 单节点 Diff 简化实现
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement
): Fiber {
const key = element.key;
let child = currentFirstChild;
while (child !== null) {
if (child.key === key) {
// key 相同
if (child.type === element.type) {
// type 也相同,复用
deleteRemainingChildren(returnFiber, child.sibling);
const existing = useFiber(child, element.props);
return existing;
}
// type 不同,删除所有旧节点
deleteRemainingChildren(returnFiber, child);
break;
} else {
// key 不同,删除当前节点
deleteChild(returnFiber, child);
}
child = child.sibling;
}
// 创建新节点
const created = createFiberFromElement(element);
return created;
}
多节点 Diff
多节点 Diff 是最复杂的情况,React 的处理分为两轮遍历:
第一轮遍历
// 第一轮:从左往右遍历,尽可能复用节点
let i = 0;
let lastPlacedIndex = 0;
let newIdx = 0;
let nextOldFiber = null;
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
const newChild = newChildren[newIdx];
if (oldFiber.key !== newChild.key) {
// key 不同,跳出第一轮遍历
break;
}
if (oldFiber.type === newChild.type) {
// 可复用
// 更新节点...
} else {
// key 相同但 type 不同
// 删除旧节点,创建新节点
}
oldFiber = oldFiber.sibling;
}
第二轮遍历
// 第二轮:处理剩余节点
// 情况 1:新节点遍历完,删除剩余旧节点
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
return;
}
// 情况 2:旧节点遍历完,新增剩余新节点
if (oldFiber === null) {
for (; newIdx < newChildren.length; newIdx++) {
createChild(returnFiber, newChildren[newIdx]);
}
return;
}
// 情况 3:都没遍历完,建立 Map 查找可复用节点
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
for (; newIdx < newChildren.length; newIdx++) {
const newChild = newChildren[newIdx];
const matchedFiber = existingChildren.get(
newChild.key ?? newIdx
);
if (matchedFiber) {
// 找到可复用节点
// 判断是否需要移动...
} else {
// 没找到,创建新节点
}
}
节点移动的判断
// lastPlacedIndex:最后一个不需要移动的旧节点位置
let lastPlacedIndex = 0;
// 遍历新节点
for (const [newIndex, fiber] of newFibers) {
const oldIndex = fiber.oldIndex;
if (oldIndex < lastPlacedIndex) {
// 需要移动(向右移动)
markMove(fiber);
} else {
// 不需要移动
lastPlacedIndex = oldIndex;
}
}
// 示例
// 旧:A B C D(index: 0 1 2 3)
// 新:A C B D
// 遍历新节点:
// A: oldIndex=0, lastPlacedIndex=0, 不移动, lastPlacedIndex=0
// C: oldIndex=2, lastPlacedIndex=0, 不移动, lastPlacedIndex=2
// B: oldIndex=1, lastPlacedIndex=2, 1<2 需要移动
// D: oldIndex=3, lastPlacedIndex=2, 不移动, lastPlacedIndex=3
// 结果:只需要移动 B
常见面试问题
Q1: 什么是虚拟 DOM?有什么优势?
答案:
虚拟 DOM 是用 JavaScript 对象描述真实 DOM 结构的技术。
优势:
| 优势 | 说明 |
|---|---|
| 减少 DOM 操作 | 通过 Diff 算法,只更新变化的部分 |
| 批量更新 | 多次状态变化合并为一次 DOM 更新 |
| 跨平台 | 可以渲染到 Web、Native、服务端等 |
| 声明式编程 | 开发者只需描述 UI 应该是什么样子 |
注意:虚拟 DOM 并不一定比直接操作 DOM 快,它的核心价值是提供了一种高效且可维护的更新策略。
Q2: React Diff 算法的策略是什么?时间复杂度是多少?
答案:
React Diff 采用三个策略将时间复杂度从 降低到 :
| 策略 | 内容 |
|---|---|
| Tree Diff | 只比较同层级节点,跨层级移动视为删除+新建 |
| Component Diff | 相同类型组件继续比较,不同类型直接替换 |
| Element Diff | 使用 key 标识节点,相同 key 复用节点 |
Q3: key 的作用是什么?为什么不能用 index?
答案:
key 的作用:
- 帮助 React 识别和追踪列表中的每个元素
- 在列表变化时复用已有节点,减少 DOM 操作
- 确保组件状态正确关联到对应的数据
不能用 index 的原因:
// 原始列表
items = [{id: 1, name: 'A'}, {id: 2, name: 'B'}]
// index 作为 key:0 → A, 1 → B
// 删除 A 后
items = [{id: 2, name: 'B'}]
// index 作为 key:0 → B
// React 认为:key=0 的节点从 A 变成了 B(错误复用)
// 如果组件有内部状态,状态会错乱!
正确做法:使用唯一且稳定的 id 作为 key。
Q4: React 是如何判断节点需要移动的?
答案:
React 使用 lastPlacedIndex 算法判断节点是否需要移动:
// 规则:如果旧节点的 index < lastPlacedIndex,需要移动
// 示例:旧 ABCD → 新 DABC
// 遍历新节点:
// D: oldIndex=3, lastPlacedIndex=0, 3>0 不移动, lastPlacedIndex=3
// A: oldIndex=0, lastPlacedIndex=3, 0<3 需要移动
// B: oldIndex=1, lastPlacedIndex=3, 1<3 需要移动
// C: oldIndex=2, lastPlacedIndex=3, 2<3 需要移动
// 结果:D 不动,ABC 依次移动到 D 后面
// 实际 DOM 操作:移动 A、B、C
尽量避免将节点从后面移动到前面(如上例),这会导致较多的 DOM 操作。
Q5: 虚拟 DOM 一定比真实 DOM 快吗?
答案:
不一定。虚拟 DOM 有额外的开销:
- 创建虚拟 DOM 对象
- Diff 比较算法
- 将差异应用到真实 DOM
对于简单的、明确的 DOM 操作,直接操作 DOM 更快:
// 直接操作 DOM(更快)
element.textContent = 'new text';
// 通过 React(有额外开销)
setState({ text: 'new text' });
// → 创建新虚拟 DOM
// → Diff 比较
// → 更新真实 DOM
虚拟 DOM 的价值不在于绝对速度,而在于:
- 提供了一种可预测的更新策略
- 让开发者专注于声明式编程
- 在复杂场景下保持良好的性能
Q6: React 的 Diff 算法有哪些优化策略?时间复杂度是多少?
答案:
传统的树 Diff 算法需要对两棵树做完全比较,时间复杂度为 (比较 + 编辑 ),对于一个有 1000 个节点的组件树,需要 10 亿次比较,完全不可接受。
React 通过三个假设将复杂度降低到 :
| 假设 | 策略 | 效果 |
|---|---|---|
| DOM 节点跨层级移动极少 | Tree Diff:只比较同层级节点 | 将树比较降为逐层比较 |
| 不同类型的组件生成不同的树 | Component Diff:类型不同直接替换整棵子树 | 跳过不必要的子树比较 |
| 同层级节点可以通过 key 唯一标识 | Element Diff:通过 key 匹配可复用节点 | 精确识别节点移动、减少 DOM 操作 |
// Diff 的核心流程
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any
): Fiber | null {
// 1. 单节点 Diff
if (typeof newChild === 'object' && newChild !== null) {
if (!Array.isArray(newChild)) {
return reconcileSingleElement(returnFiber, currentFirstChild, newChild);
}
// 2. 多节点 Diff
return reconcileChildrenArray(returnFiber, currentFirstChild, newChild);
}
// 3. 文本节点 Diff
if (typeof newChild === 'string' || typeof newChild === 'number') {
return reconcileSingleTextNode(returnFiber, currentFirstChild, newChild);
}
// 4. 没有新节点,删除所有旧节点
return deleteRemainingChildren(returnFiber, currentFirstChild);
}
传统算法需要递归比较所有节点对 + 计算最小编辑距离。React 的三个假设牺牲了极端情况下的"最优解",换来了绝大多数场景下的线性性能。实践证明这三个假设在真实应用中几乎总是成立的。
Q7: key 为什么不能用 index?什么场景下可以用?
答案:
用 index 作为 key 的核心问题:当列表发生增删或排序时,index 与数据的对应关系会变化,导致 React 错误地复用节点,引发状态错乱和不必要的 DOM 更新。
典型 Bug 示例:
import { useState } from 'react';
interface Todo {
id: number;
text: string;
}
function TodoList(): JSX.Element {
const [todos, setTodos] = useState<Todo[]>([
{ id: 1, text: '学习 React' },
{ id: 2, text: '学习 TypeScript' },
{ id: 3, text: '刷算法题' },
]);
const removeTodo = (id: number): void => {
setTodos(todos.filter(todo => todo.id !== id));
};
return (
<ul>
{todos.map((todo, index) => (
// 错误:使用 index 作为 key
<li key={index}>
<input type="checkbox" />
<span>{todo.text}</span>
<button onClick={() => removeTodo(todo.id)}>删除</button>
</li>
))}
</ul>
);
}
// 场景:勾选第一个 checkbox("学习 React"),然后删除它
// 期望:checkbox 状态消失
// 实际:第二项 "学习 TypeScript" 变成勾选状态!
//
// 原因:
// 删除前:key=0 → "学习 React"(✓), key=1 → "学习 TypeScript", key=2 → "刷算法题"
// 删除后:key=0 → "学习 TypeScript", key=1 → "刷算法题"
// React 认为 key=0 的节点还在,只是文本变了,checkbox 状态被错误保留
index 和 id 的对比:
| 操作 | index 作为 key | id 作为 key |
|---|---|---|
| 在头部插入 | 所有节点都认为"内容变了",全部更新 | 只插入一个新节点 |
| 删除中间项 | 被删项后面的所有节点都"更新" | 只删除一个节点 |
| 排序 | 所有节点内容"变化",全部重渲染 | 只移动 DOM 位置 |
| 有内部状态 | 状态错乱(如上例) | 状态正确关联 |
可以使用 index 的场景:
// 以下条件同时满足时可以用 index:
// 1. 列表数据是静态的,不会增删排序
// 2. 列表项没有内部状态(无输入框、checkbox 等)
// 3. 列表项没有唯一 id
// 例如:纯展示的静态导航菜单
const navItems = ['首页', '关于', '联系我们'];
function Nav(): JSX.Element {
return (
<nav>
{navItems.map((item, index) => (
<a key={index} href="#">{item}</a> // 这种场景可以
))}
</nav>
);
}
回答时要强调:绝大多数情况下都应该使用唯一且稳定的 id,只有在确认列表完全静态且无状态时才能用 index。如果没有 id,可以用数据内容生成 hash 或在数据层添加 id。
Q8: React 的虚拟 DOM 一定比直接操作 DOM 快吗?
答案:
不一定。虚拟 DOM 本身有额外的运行时开销,在某些场景下直接操作 DOM 反而更快。
虚拟 DOM 的额外开销:
// 每次状态更新,虚拟 DOM 需要经历以下步骤:
// 1. 调用 render/函数组件 → 创建新的虚拟 DOM 树(JavaScript 对象)
// 2. Diff 比较 → 遍历新旧两棵虚拟 DOM 树找差异
// 3. 生成补丁 → 收集需要更新的 DOM 操作
// 4. 批量更新 → 将补丁应用到真实 DOM
// 而直接操作 DOM:
document.getElementById('counter')!.textContent = String(count);
// 只有一步,没有中间开销
性能对比:
| 场景 | 虚拟 DOM | 直接操作 DOM |
|---|---|---|
| 简单的单个 DOM 更新 | 较慢(多了 diff 开销) | 更快 |
| 大量散布的 DOM 更新 | 更快(批量更新,减少重排) | 较慢(频繁触发重排重绘) |
| 列表增删排序 | 更快(精确计算最小 DOM 操作) | 手动优化可能更快 |
| 复杂交互应用 | 开发效率高,性能可接受 | 手动维护成本极高 |
虚拟 DOM 的真正价值:
// 1. 批量更新:多次 setState 合并为一次 DOM 操作
function handleClick(): void {
setCount(c => c + 1); // 不会立即更新 DOM
setName('new name'); // 不会立即更新 DOM
setList([...list, item]); // 不会立即更新 DOM
// React 会合并这三次更新,只触发一次 DOM 操作
}
// 2. 跨平台:同一套代码渲染到不同平台
// React DOM → 浏览器
// React Native → iOS/Android
// React Three Fiber → WebGL/3D
// React PDF → PDF 文档
// 3. 声明式编程:开发者只描述"UI 应该长什么样"
function Counter({ count }: { count: number }): JSX.Element {
// 不需要手动操作 DOM,不需要关心"如何更新"
return <div className={count > 10 ? 'warning' : 'normal'}>{count}</div>;
}
Svelte 的对比:
Svelte 是一个无虚拟 DOM 的框架,它在编译阶段就确定了数据变化时需要更新哪些 DOM 节点,运行时直接操作 DOM:
// Svelte 编译后的代码(简化)
// 编译器在构建时就知道 count 变化时只需要更新 t1 这个文本节点
function update(changed: number): void {
if (changed & 1) { // count 变了
t1.data = String(count); // 直接更新 DOM,没有 diff
}
}
// 对比 React:运行时需要创建虚拟 DOM → diff → 更新 DOM
| 对比维度 | React(虚拟 DOM) | Svelte(无虚拟 DOM) |
|---|---|---|
| 更新策略 | 运行时 diff | 编译时确定 |
| 运行时大小 | 较大(~40KB) | 极小(~2KB) |
| 简单更新性能 | 有 diff 开销 | 更快(直接操作 DOM) |
| 复杂更新性能 | 批量优化好 | 需要编译器足够聪明 |
| 生态和灵活性 | 极其丰富 | 相对较少 |
虚拟 DOM 的核心价值不是性能,而是:
- 声明式编程模型带来的开发效率提升
- 跨平台渲染能力(React Native、SSR 等)
- 在复杂应用中提供可预测的、足够好的性能
- 批量更新机制自动优化频繁的状态变更
面试时不要说"虚拟 DOM 比真实 DOM 快",正确的说法是"虚拟 DOM 提供了一种在保持声明式编程的同时,实现高效 DOM 更新的方案"。