虚拟 DOM 与 Diff 算法
问题
什么是虚拟 DOM?React 的 Diff 算法是如何工作的?key 的作用是什么?
什么是虚拟 DOM? 虚拟 DOM 就是用一个普通的 JS 对象来描述真实 DOM 长什么样:
- JSX 经
React.createElement编译成{ type, props, key, ref }这样的 ReactElement 对象。 - 它本身不操作浏览器,只是一份「UI 的描述」,渲染时再由 React 翻译成真实 DOM。
- 好处不是「比 DOM 快」,而是批量 + 最小化 DOM 操作,并且让 UI 可以跨平台(Web、Native、SSR)和声明式编写。
Diff 算法是怎么工作的?
React 把传统树 Diff 的 O(n³) 优化到 O(n),靠的是三个启发式假设:
- Tree Diff:只比同层节点,跨层级移动直接「删旧 + 建新」,不做跨层匹配。
- Component Diff:同类型组件继续递归比 props,类型不同(比如
<Button>变<Link>)整个子树销毁重建。 - Element Diff:同层一组子节点用
key来识别,能复用就复用、必要时再移动;没有 key 就只能按下标对齐,导致节点错配。
key 的作用是什么?
key 是 React 在同层兄弟节点之间做唯一标识用的:
- 有 key:React 能认出「这个节点只是位置变了」,复用 DOM 和组件状态,只做移动。
- 没 key 或用 index 做 key:列表头部插入/删除时,所有节点都被当成「内容变了」,触发大量重渲染,受控表单还会出现输入框值串到别人身上。
- 正确做法:用业务上稳定且唯一的 ID(比如
item.id)做 key,不要用 index,也不要用Math.random()。
答案
虚拟 DOM(Virtual DOM) 是用 JavaScript 对象来描述真实 DOM 结构的一种技术。React 通过 Diff 算法比较新旧虚拟 DOM 的差异,然后只更新变化的部分到真实 DOM。
为什么会有虚拟 DOM?
要理解虚拟 DOM 为什么会出现,咱们先把时光倒回 jQuery 时代,看看那时候写一个交互页面有多痛苦。
jQuery 时代的痛点:UI 状态散落在 DOM 上
假设要写一个最简单的计数器:点按钮,数字加 1,超过 10 就把数字标红。用 jQuery 是这样写的:
// jQuery 风格:手动同步 UI
let count = 0;
$('#btn').on('click', () => {
count++;
// 第 1 处:手动改文本
$('#count').text(count);
// 第 2 处:手动改样式
if (count > 10) {
$('#count').addClass('warning');
} else {
$('#count').removeClass('warning');
}
// 第 3 处:手动控制按钮
$('#reset').prop('disabled', count === 0);
});
看起来不长,但它有几个致命问题:
- UI 的真实状态既在 JS 变量
count里,又散落在 DOM 上(文本、class、disabled),随着功能变多,你得自己保证它们永远一致。 - 每多一处需要响应
count的 UI,就要手动多写一行$().xxx(),漏一行就是 bug。 - DOM 操作是一行一行执行的,浏览器有可能每次都重排重绘,性能不可控。
反过来想想:能不能让我只描述「UI 长什么样」,剩下的「怎么改 DOM」交给框架?这就是 React 的出发点。
真实 DOM 操作贵在哪?
咱们打开 Chrome 控制台跑一下:
const div = document.createElement('div');
console.log(Object.keys(div).length); // 几十个自身属性
// 加上原型链上的属性,浏览器内部一个 div 节点对象大约占 1~2 KB
一个真实 DOM 节点其实是个「巨型对象」:
- 它带着上百个属性(
offsetTop、scrollHeight、各种事件处理器、布局信息……)。 - 修改某些属性(比如
width、className)可能触发重排(reflow):浏览器要重新计算这个节点和它影响范围内的所有节点的位置和大小。 - 大量、频繁的 DOM 修改 → 浏览器疲于重排重绘 → 用户感觉到掉帧、卡顿。
虚拟 DOM 的解决思路
React 的思路其实很像「写文章先打草稿」:
- 打草稿:用一个非常轻的 JS 对象(ReactElement)在内存里描述「UI 应该长成什么样」。
- 找不同:拿新草稿和上一次的草稿做对比(Diff),找出真正变化的地方。
- 誊抄到正稿:把这些差异批量、最小化地应用到真实 DOM 上,只动该动的那几个节点。
手动操作 DOM vs 虚拟 DOM
| 维度 | 手动操作 DOM(jQuery) | 虚拟 DOM(React) |
|---|---|---|
| 开发心智 | 要时刻同步「数据 → DOM」每一处 UI | 只描述「数据 → UI」,DOM 更新交给框架 |
| 性能 | 每次操作都可能触发重排,散弹式更新 | 批量 Diff,一次性最小化更新真实 DOM |
| 跨平台 | 强依赖 document API,只能跑在浏览器里 | 描述层是普通对象,可渲染到 Native、PDF、Canvas 等 |
虚拟 DOM 不是为了「比 DOM 快」,而是把开发者从「手动同步 DOM」的泥潭里救出来,同时用 Diff 把性能控制在一个可预测的、足够好的水平。
什么是虚拟 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'
}
};
ReactElement 到底有多轻?
看上面两段代码可能没什么感觉,咱们具体掰扯一下「轻」体现在哪:
// 真实 DOM 节点
const real = document.createElement('div');
// real 自带几十个属性:id / className / style / dataset / children /
// offsetTop / offsetLeft / scrollHeight / clientWidth /
// onclick / onmouseover / addEventListener / ...
// 加上原型链上的方法,整个对象在 Chrome 里大约占用 1~2 KB
// 虚拟 DOM 节点(ReactElement)
const vnode = {
$$typeof: Symbol.for('react.element'),
type: 'div',
key: null,
ref: null,
props: { className: 'container', children: 'Hello' },
};
// 就这 5 个字段,整个对象只有几十字节
你可以这样类比:
- 真实 DOM 节点 像一辆装备齐全的真车:发动机、空调、座椅一应俱全,造一辆很贵。
- ReactElement 对象 像一张「这辆车应该长什么样」的设计草图:纸笔几秒钟就能画一张,画错了揉掉重画也无所谓。
所以即便 React 每次 render 都「整棵树重新创建一份新的 ReactElement」,成本也远低于「直接创建/修改对应数量的真实 DOM 节点」。这是虚拟 DOM 方案能跑得动的物理基础。
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 快。它的价值在于:
- 提供了一种高效的更新策略
- 让开发者专注于声明式编程
- 使跨平台渲染成为可能
React Element、Fiber、DOM 的关系
很多资料会把"虚拟 DOM"说成一整套内部结构,但在 React 里更准确地说:
| 概念 | 本质 | 作用 |
|---|---|---|
| React Element | JSX 编译后得到的普通 JS 对象 | 描述"UI 应该是什么样" |
| Fiber Node | React 内部的可变工作单元 | 记录状态、优先级、副作用和父子兄弟关系 |
| DOM Node | 浏览器里的真实节点 | 用户最终看到和交互的对象 |
一句话:虚拟 DOM 是输入蓝图,Fiber 是施工和调度系统,真实 DOM 是最终落地结果。
Diff 算法
传统 Diff 的问题
传统的树 Diff 算法时间复杂度是 ,对于大型应用来说不可接受。
React 通过三个策略将复杂度降低到 :
为什么传统 Diff 算法是 ?
这个 是「找两棵树的最小编辑距离」算法的复杂度,咱们直观拆一下它怎么来的:
- 第一个 n:旧树有 n 个节点,每个都要试着和新树的节点配对 → n 种选择。
- 第二个 n:新树也有 n 个节点参与配对 → 一共 种节点配对组合。
- 第三个 n:判断「最少要做哪些插入/删除/移动」时,还要在 n 层深度上做动态规划。
三者相乘就是 。光看公式没感觉,咱们带个具体数字进去:
| 节点数量 n | 比较次数 | 在浏览器里大概耗时 |
|---|---|---|
| 100 | 100 万 | 几十毫秒,能接受 |
| 1 000 | 10 亿 | 数秒级别,页面直接卡死 |
| 10 000 | 1 万亿 | 跑不完 |
一个稍微复杂点的中后台页面随便就能上千个节点。如果每次 setState 都要等几秒,那 React 还没火就凉了。
所以 React 不打算追求「数学上的最优解」,而是做了三个非常符合真实业务直觉的假设,主动放弃极端情况下的最优解,把复杂度直接打到 :
- 假设 1:DOM 结构跨层级移动几乎不会发生 → 不做跨层匹配。
- 假设 2:不同类型的组件几乎一定生成不同的子树 → 类型不同直接整棵换。
- 假设 3:开发者愿意通过
key告诉我「同层节点谁是谁」 → 用 key 做兄弟节点匹配。
这就是下面要讲的三个策略。可以理解为 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 的最佳实践
key 的唯一性只要求在同一层级的兄弟节点之间成立,不需要在整个应用里全局唯一。比如两个不同列表里都可以有 key="1",只要它们不是同一个数组渲染出来的兄弟节点即可。
// ✅ 正确:使用唯一且稳定的 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 |
|---|---|
| 列表是静态的,不会增删改 | ✅ 可以 |
| 列表会重新排序 | ❌ 不要 |
| 列表会在中间插入或删除 | ❌ 不要 |
| 列表项有内部状态(如输入框) | ❌ 不要 |
一个完整的端到端例子
讲了这么多概念,咱们用一个非常常见的场景把整条链路串起来——点击删除 Todo 列表中间的一项,看看从你写 JSX 到浏览器最终更新画面,React 在背后到底做了什么。
场景设定
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 remove = (id: number): void => {
setTodos(prev => prev.filter(t => t.id !== id));
};
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>
{todo.text}
<button onClick={() => remove(todo.id)}>删除</button>
</li>
))}
</ul>
);
}
一步一步发生了什么
第 1 步:JSX 编译
你写的 <li key={todo.id}>...</li> 在构建阶段被编译成 React.createElement('li', { key: todo.id }, ...),最终变成一个个轻量的 ReactElement 对象。
第 2 步:旧的 ReactElement 树
初始渲染后,React 内部存着这样一份「上一次的草稿」:
// 旧 children(简化)
[
{ type: 'li', key: 1, props: { children: ['学习 React', <button/>] } },
{ type: 'li', key: 2, props: { children: ['学习 TypeScript', <button/>] } },
{ type: 'li', key: 3, props: { children: ['刷算法题', <button/>] } },
]
第 3 步:用户点击「删除」,触发 setState
setTodos 让 todos 变成两项,React 重新执行函数组件,得到一份新的 ReactElement 树:
// 新 children
[
{ type: 'li', key: 1, props: { children: ['学习 React', <button/>] } },
{ type: 'li', key: 3, props: { children: ['刷算法题', <button/>] } },
]
第 4 步:Diff 出场,开始「找不同游戏」
React 拿着新旧两份子节点列表,按照 key 一一对照:
key=1在新旧里都有,且type相同 → 复用对应的 DOM 和 Fiber,props 没变就什么都不做。key=2在新列表里没了 → 标记删除。key=3在新旧里都有 → 复用。
React 得出结论:「这次只需要删掉中间那个 <li>,其它都不用动。」
第 5 步:Commit 阶段,派发一次 DOM 操作
React 调用一次 parent.removeChild(li2),浏览器只重排重绘了一次。
第 6 步:用户看到中间项消失
动画也好、过渡也好,因为没有多余的 DOM 节点被销毁重建,体验是丝滑的。
用时序图把整个流程画出来
你作为开发者写的就是「现在数据是这两条,UI 应该是这样」这一句声明式 JSX。
至于「我之前那 3 个 <li> 哪个能复用、哪个要删、哪个要新建、要不要移动 DOM」——这一切都是 React 自动算出来并替你做的。这就是虚拟 DOM + Diff + 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 更新 | 更快(批量更新,减少重排) | 较慢(频繁触发重排重绘) |
| 列表增删排序 | 通常更快(借助 key 识别复用和移动,但不保证全局最少 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 更新的方案"。
虚拟 DOM 与 Fiber 的关系
面试里被问完虚拟 DOM,紧接着十有八九会被追问一句:「那 Fiber 又是什么?它和虚拟 DOM 是一回事吗?」 这一节就把这层关系说清楚。
三个角色各司其职
| 角色 | 是什么 | 职责 | 类比 |
|---|---|---|---|
| ReactElement(虚拟 DOM) | 普通的、不可变的 JS 对象 | 输入:描述「UI 应该长什么样」 | 设计草图 |
| Fiber Node | React 内部的可变工作单元,构成一棵 Fiber 树 | 过程:调度任务、做 Diff、记录要做哪些副作用(增/删/改 DOM) | 工地施工队 + 调度系统 |
| 真实 DOM Node | 浏览器里真实存在的节点 | 输出:用户最终看到和交互的对象 | 盖好的房子 |
用一张图表达:
为什么需要 Fiber 这一层中间结构?
React 15 时代是用递归直接处理虚拟 DOM 树的,一旦组件树很大,Diff + 更新会同步占满主线程,期间用户的点击、输入、动画全部被卡住。
React 16 引入 Fiber 之后,整棵树被拆成一个个 Fiber 节点,每个 Fiber 都是一个可被中断、可被恢复的「工作单元」。这样做带来两个直接好处:
- 可中断:Diff 工作可以做一会就让出主线程给浏览器画一帧,避免卡顿。
- 可调度:高优先级更新(如用户输入)可以插队,低优先级更新(如远程数据)可以延后。
这正是**时间切片(Time Slicing)和并发渲染(Concurrent Rendering)**的基础。详细原理可以跳到 Fiber 架构。
一句话记住三者关系
虚拟 DOM 是给 React 看的「设计图」,Fiber 是 React 内部的「施工调度系统」,真实 DOM 是用户最终看到的「成品房子」。
面试里这样答,比单说「虚拟 DOM 快」要专业得多。