跳到主要内容

虚拟 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 并不一定比直接操作 DOM 快。它的价值在于:

  1. 提供了一种高效的更新策略
  2. 让开发者专注于声明式编程
  3. 使跨平台渲染成为可能

Diff 算法

传统 Diff 的问题

传统的树 Diff 算法时间复杂度是 O(n3)O(n^3),对于大型应用来说不可接受。

React 通过三个策略将复杂度降低到 O(n)O(n)

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 采用三个策略将时间复杂度从 O(n3)O(n^3) 降低到 O(n)O(n)

策略内容
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 有额外的开销:

  1. 创建虚拟 DOM 对象
  2. Diff 比较算法
  3. 将差异应用到真实 DOM

对于简单的、明确的 DOM 操作,直接操作 DOM 更快:

// 直接操作 DOM(更快)
element.textContent = 'new text';

// 通过 React(有额外开销)
setState({ text: 'new text' });
// → 创建新虚拟 DOM
// → Diff 比较
// → 更新真实 DOM

虚拟 DOM 的价值不在于绝对速度,而在于:

  1. 提供了一种可预测的更新策略
  2. 让开发者专注于声明式编程
  3. 复杂场景下保持良好的性能

Q6: React 的 Diff 算法有哪些优化策略?时间复杂度是多少?

答案

传统的树 Diff 算法需要对两棵树做完全比较,时间复杂度为 O(n3)O(n^3)(比较 O(n2)O(n^2) + 编辑 O(n)O(n)),对于一个有 1000 个节点的组件树,需要 10 亿次比较,完全不可接受。

React 通过三个假设将复杂度降低到 O(n)O(n)

假设策略效果
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);
}
O(n3)O(n^3)O(n)O(n) 的关键

传统算法需要递归比较所有节点对 + 计算最小编辑距离。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 作为 keyid 作为 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 的核心价值不是性能,而是:

  1. 声明式编程模型带来的开发效率提升
  2. 跨平台渲染能力(React Native、SSR 等)
  3. 复杂应用中提供可预测的、足够好的性能
  4. 批量更新机制自动优化频繁的状态变更

面试时不要说"虚拟 DOM 比真实 DOM 快",正确的说法是"虚拟 DOM 提供了一种在保持声明式编程的同时,实现高效 DOM 更新的方案"。

相关链接