2.1 虚拟 DOM 与 Diff 算法 本节摘要:虚拟 DOM 是真实 DOM 的轻量级内存表示,React 用它把「操作 DOM」变成「比较对象」。Diff 算法通过 Tree、Component、Element 三层比较找出差异,最小化真实 DOM 更新。本节用一个手动构建的虚拟 DOM 对象和简化 Diff 实现讲清机制,并彻底讲透 key 的作用与选择。 核心问题 阅读完本节,你应当能够: 解释为什么需要虚拟 DOM,它和真实 DOM 的关系 手工写出一个 JSX 对应的虚拟 DOM 对象 描述 Diff 算法的三层比较策略 说明 key 为什么影响性能,正确选择 key 解释「React 不做最小编辑」的含义 一、问题与直觉:操作真实 DOM 为什么贵 要理解虚拟
本节摘要:虚拟 DOM 是真实 DOM 的轻量级内存表示,React 用它把「操作 DOM」变成「比较对象」。Diff 算法通过 Tree、Component、Element 三层比较找出差异,最小化真实 DOM 更新。本节用一个手动构建的虚拟 DOM 对象和简化 Diff 实现讲清机制,并彻底讲透 key 的作用与选择。
阅读完本节,你应当能够:
要理解虚拟 DOM,先得知道真实 DOM 贵在哪。浏览器里,DOM 节点不只是 HTML 结构,它携带布局信息、样式计算、事件处理器。你改一个节点的文本,浏览器可能要重新计算布局(reflow)、重新绘制(repaint)。页面复杂时,一次 DOM 操作的成本可能波及整棵渲染树。
早期 jQuery 时代的做法是手动精细操作 DOM——「只改该改的」。但问题在于:人无法保证每次都只改该改的。界面状态一多,更新逻辑纠缠,一不小心就整片重绘。
React 的思路是反着来:既然精确操作太难,那就干脆不精确。每次状态变化,React 在内存里重新构建整棵虚拟 DOM 树,和旧树对比,算出差异,只把差异应用到真实 DOM。牺牲一部分计算(重建 + 对比),换取对真实 DOM 的最小化操作。 内存里的对象比较远比浏览器重排便宜,这笔账是划算的。
SOURCE 原文总结了虚拟 DOM 的三个特点:轻量级、易于操作、可对比。三个词对应三层含义:它是普通 JavaScript 对象,操作在内存里进行,可以方便地比较差异。
虚拟 DOM 节点就是一个普通 JavaScript 对象,通常包含三部分:
const virtualDOM = { type: 'div', props: { className: 'container', style: { color: 'red' } }, children: [ { type: 'h1', props: {}, children: ['Hello, World!'] }, { type: 'p', props: {}, children: ['这是一段内容'] }, ], };
对比一下,这段 JSX:
<div className="container" style={{ color: 'red' }}> <h1>Hello, World!</h1> <p>这是一段内容</p> </div>
会被编译成 React.createElement('div', { className: 'container', style: { color: 'red' } }, child1, child2),返回的就是上面那个对象。JSX → createElement → 虚拟 DOM 对象,这条链在第 1.3 节已经见过,现在它的终点清楚了:虚拟 DOM。
不用 JSX,直接手动拼一棵虚拟 DOM 树,能彻底祛除虚拟 DOM 的神秘感:
const virtualDOM = { type: 'div', props: { className: 'container' }, children: [ { type: 'p', props: {}, children: ['Hello'] }, { type: 'ul', props: {}, children: [ { type: 'li', props: {}, children: ['Item 1'] }, { type: 'li', props: {}, children: ['Item 2'] }, ], }, ], };
它就是一棵树:根节点 div 下有 p 和 ul,ul 下有两个 li。React 在内存里维护的就是这种结构。你操作 state、props,React 重建这棵树,然后 Diff。
最理想的方案是算出两棵树的完全最小编辑距离,但树的编辑距离算法复杂度太高,大 UI 下根本算不完。React 选择了启发式:基于三条假设,把复杂度从高次降到近乎线性。
三条假设(SOURCE 原文明确列出):
基于这三条假设,Diff 分三层:
从根节点开始逐层比较。如果根节点类型不同(div 换成 section),直接替换整棵旧树。组件类型不同同理,整个子树不要了,重建。这一层保证了「跨类型替换」的成本是 O(1)——不管子树多大,一次换掉。
如果组件类型相同,继续比较。这里有个重要细节:React 会比较 shouldComponentUpdate 的返回值。类组件里这个方法返回 false,就跳过该组件及其子树;函数组件对应 React.memo(第 3.1 节)。这个「跳过」是性能优化的关键入口。
节点类型相同则比较属性,只更新变化的属性。子节点层面,React 用 key 识别同一组子节点,判断是新增、删除、移动还是更新。
| 层级 | 比较内容 | 典型结果 |
|---|---|---|
| Tree Diff | 树结构/根类型 | 根类型不同则整树替换 |
| Component Diff | 组件类型 + shouldComponentUpdate | 同类型继续,返回 false 则跳过 |
| Element Diff | 节点属性 + key | 只更新变化的属性 |

这张图把三层 Diff 拆成「检查顺序」来看:先看树结构,再看组件,最后看元素。每一层都有一道「剪枝」——Tree 层靠类型、Component 层靠跳过标记、Element 层靠 key,三道剪枝合起来让比较保持线性。这是对上面表格的图形化补充,mermaid 全景图看流程,这张看策略。
SOURCE 原文给了一段可运行的简化 Diff 代码。它的核心是 walk 函数:递归比较新旧节点,产出补丁对象:
function walk(oldNode, newNode, patches) { // 新节点不存在:删除 if (!newNode) return patches.push({ type: 'REMOVE' }); // 都是文本且不同:改文本 if (isString(oldNode) && isString(newNode)) { if (oldNode !== newNode) patches.push({ type: 'TEXT', text: newNode }); return; } // 类型相同:比较 props 与 children if (oldNode.type === newNode.type) { // 比较属性差异、递归子节点 } else { // 类型不同:替换 patches.push({ type: 'REPLACE', newNode }); } }
这段代码的价值不在能跑,而在于展示了 Diff 的本质:对节点做分类处理——删除、改文本、改属性、替换、递归子节点。React 真实实现复杂得多(有 Fiber、双缓存),但分类思路一致。读完这段,再看真实 Diff 就不再是一头雾水。
现在回答本章最重要的实践问题:列表渲染为什么必须给 key?
看一个场景。一个待办列表,用户在第一条前面插入新项:
旧:[A, B, C] 新:[X, A, B, C]
没有 key 时,React 按索引对比:索引 0 处 A 变 X,索引 1 处 B 变 A……结果是 React 认为「A 被改成了 X,B 被改成了 A,C 被改成了 B,新增了 C」。四次更新,还错乱了——如果列表项有输入框、有选中态,状态会错位。
有 key 时,React 识别出 X 是新增、A/B/C 只是位置后移,于是「插入一个 X」,其余不动。一次插入,其余复用。
所以 key 的作用是让 React 知道「同一个元素」是谁。没有它,React 只能靠索引猜,猜错的代价是性能与状态错乱。
| key 来源 | 是否推荐 | 原因 |
|---|---|---|
| 数据自带 id | 推荐 | 唯一且稳定 |
| 索引 index | 避免 | 增删后索引漂移 |
| 随机数 | 禁用 | 每次渲染都变,等于没 key |
⚠️ 常见坑:用 index 当 key。列表不增删时没问题,一旦在中间插入或删除,key 全乱,React 会复用错误的节点,轻则状态错位,重则渲染错误。遇到「列表项输入框的内容串位」这种灵异 bug,先查 key。
💡 关键直觉:key 只要求「在兄弟节点中唯一」,不需要全局唯一。它也不参与渲染,别在组件里读取 props.key——那是 React 内部使用的。
为了把抽象落地,走一遍一个真实的状态变化流程。
假设有个 TodoApp,状态里有一个数组。用户勾选一项,React 内部发生:
第 3 步的「列表子节点 key 相同位置变」就是上面讲的插入场景。你可以在浏览器 DevTools 里观察:勾选一项,只有那项被改动,其他 li 节点原封不动。
验证方法:F12 打开 Elements 面板,给 li 加一个 highlight 样式,然后触发列表更新。如果只有变化的项闪动,说明 key 和 Diff 都在正常工作;如果整片闪,说明哪里把整棵树重建了——通常是 key 写错或父组件状态更新连带子树重建。
把第 1.4 节的计数器接进虚拟 DOM 语境,看一次点击发生了什么。旧虚拟 DOM 里按钮的文本是「点击次数:0」,点击后新虚拟 DOM 里变成「点击次数:1」。Diff 在 Element 层发现文本子节点从「0」变成「1」,产出一条 TEXT 类型的补丁。React 拿着这条补丁,只更新按钮里那一个文本节点——其余所有节点都原样复用。
对比原生操作:如果手动写 jQuery,你要么改错节点,要么图省事重绘整块容器。React 用一次「重建加对比」的代价,换来了精确到文本节点的更新。当列表有上千项时,这个差距就是「流畅」和「卡顿」的分界。
说完收益,也该讲代价。虚拟 DOM 不是免费的:
内存开销:每次渲染都要重建整棵虚拟 DOM 树,树越大开销越大。极端场景下,虚拟 DOM 的内存分配和垃圾回收本身会成为压力。
Diff 假设不成立时退化:前面说过,Diff 是启发式的。当组件结构频繁跨类型变化、key 乱用、props 每次都是新引用时,Diff 会退化,优化红利消失。
不是所有更新都该走 Diff:有些场景下,虚拟 DOM 反而是多余的一层。比如拖拽、画布、高频图表这类需要直接操纵 DOM 的场合,绕过 React 直接操作反而更快——第 2.6 节 Refs 就是为这类「逃离声明式」的需求留的后门。
这也是为什么现代框架不再把虚拟 DOM 当卖点——Vue 的响应式、Svelte 的编译时优化都在用不同路径减少运行时开销。React 的价值不只是虚拟 DOM,而是它背后一整套成熟的心智模型与生态。看一个技术,别只看它的招牌,要看它在你的场景里是否真的划算。
一个容易被误解的点:React 的 Diff 不是「绝对最小编辑」,而是「启发式近似最小编辑」。它基于三条假设快速给出一个够好的结果,而非精确最优解。
这带来一个推论:Diff 的假设成立时效率高,假设不成立时会退化成低效更新。 比如用 index 当 key 就破坏了假设 3;父组件每次渲染都传新对象给子组件,就破坏了「相同组件产生相似树结构」的收益——子组件明明没变却被迫重渲染。
所以性能优化的本质(第 3.1 节会展开)就是让 Diff 的假设尽可能成立:props 稳定、key 正确、记忆化到位。理解了这一点,你就从「背优化技巧」升级为「理解优化为什么有效」。
这里再补一句容易误解的话:很多人以为虚拟 DOM 一定比手动操作 DOM 快。这是错觉。单次操作上,手动精细操作确实可能更快;虚拟 DOM 的优势在「你不需要费心做精细操作」——它用稳定可预期的自动过程,替代了容易出错的人工优化。它的价值是「省心 + 可预测」,而不是「绝对更快」。做技术判断时,别把「性能更好」挂在嘴边,要问清楚:在什么场景、以什么基准、省了多少心。
「React 的 Diff 为什么不做深度优先遍历?」 它恰恰是深度优先的——从根节点递归往下,先比较子树再比较子子树。但「逐层比较」不等于「逐节点全比」。三条启发式假设让深度遍历「剪枝」:同类型组件直接往下比、不同类型组件整棵替换、列表用 key 跳过重比。没有这三条假设,深度遍历会退化成 O(n³) 的完整树编辑距离计算——数据量一上去就算不完。理解这一点,「为什么 key 重要」「为什么别随便改组件类型」就都有了依据:它们都在帮 Diff 剪枝,让深度遍历保持线性。
「虚拟 DOM 和真实 DOM 什么时候同步?」 React 把「状态变化 → 重渲染 → Diff → 更新真实 DOM」包装成一次批量提交。同一个事件或 effect 里的多次 setState,React 会合并成一次渲染、一次 Diff、一次 DOM 更新——不会「改一次 state 就碰一次 DOM」。这就是「批量更新」:中间状态不会反映到界面,只有最终状态落一次。它的价值是性能(减少 DOM 操作次数),代价是「setState 后立刻读 DOM 拿到的是旧值」——所以需要等 useEffect 或 nextTick 之后才能看到新界面。面试常问的「为什么 setState 之后 console.log 还是旧值」,答案就在这里。
「props 每次都是新对象会导致什么?」 会让「同类型组件产生相似树结构」这条假设的收益失效。父组件渲染时 config={{x:1}} 这种内联对象,每次都是新引用,子组件如果被 memo 包裹,浅比较会判定「变了」,memo 白加;子组件如果不 memo,每次都会跟着重渲染。更隐蔽的是深层影响:新对象引用让 Diff 认为「这个节点变了」,虽然内容一样也要走完整比较流程。解法是 useMemo/useCallback 稳定引用,或用「状态提升」把常量对象提出去。第 3.1 节会把这层讲透,这里先记住:渲染期别造新对象,是让 Diff 高效的起点。
「为什么不建议用随机数当 key?」 随机数当 key 等于「每次渲染都给元素换身份」。第一次渲染 key 是 A,第二次变成 B,React 认为「A 元素被卸载了,B 元素是新来的」——于是旧节点被销毁重建,输入框内容丢失、选中态重置、滚动位置回跳,甚至可能引发组件重复挂载的副作用。key 的本质是「稳定身份」,随机数恰恰破坏了稳定性。用「数据自带的 id」最稳,没有 id 时用「内容生成的稳定标识」(但内容变了 key 也变,只适合内容不变的静态列表)。判断 key 好不好,只看一句话:两次渲染之间,这个元素的身份会不会变?
把本节从头串一遍,React 的一次状态更新就是这五步:
这个流程里,「重建整棵树」是计算成本,「Diff」是找差异,「补丁」是最终落地。三者的关系是:重建为了「统一」,Diff 为了「精确」,补丁为了「最小化」。理解这条链路,性能优化(第 3.1 节)、key 的选择(第 2.4 节)、memo 的作用就全串起来了——它们都是在不同环节帮这条链路省力。
下一节看组件的一生——生命周期与 Hooks。渲染机制讲完了,接下来是副作用代码该放哪、组件卸载该清理什么,这是从「组件能跑」到「组件不会漏」的关键一步,也是很多内存泄漏和重复请求 bug 的根源所在。