虚拟 DOM 与 DOM Diff 的原理
原文从 Vue/React vnode 的结构、创建方式、VDOM 的优缺点、Tree Diff、组件/元素 Diff 和
key讲起。本文保留原有的面试学习路线,修正损坏的对象字面量、key默认值、性能绝对结论和“Vue 横向比较存在 bug”等表述,并补充 Vue 2 与 Vue 3 的真实更新策略。
一、虚拟 DOM 是什么
虚拟 DOM 是用 JavaScript 数据结构描述 UI 树的一种方式。一个简化 vnode 可以包含:
- 节点类型或标签名;
- props/attrs/class/style;
- 事件处理器或事件描述;
- 子节点;
key、组件类型和 renderer 内部信息。
Vue 2 的教学模型
Vue 2 的真实 VNode 字段比下面更多,但可以先这样理解:
const vue2VNode = {
tag: 'div',
data: {
class: 'red',
on: {
click: () => console.log('clicked'),
},
},
children: [
{ tag: 'span', text: 'hello' },
],
key: undefined,
}
Vue 3 的教学模型
const vue3VNode = {
type: 'div',
props: {
class: 'red',
onClick: () => console.log('clicked'),
},
children: [
'hello',
],
key: null,
patchFlag: 0,
}
Vue 2 和 Vue 3 的 vnode 字段不能混用。Vue 3 的 patchFlag、shapeFlag、dynamicChildren 等是编译器和运行时使用的内部信息,业务代码不应手动依赖这些字段。

二、如何创建虚拟 DOM
1. Vue 模板
Vue 的模板不是浏览器原生 HTML 字符串。构建时,Vue 编译器会把模板转换为 render function:
<template>
<button class="primary" @click="count++">
{{ count }}
</button>
</template>
概念上会生成类似:
import { createElementBlock, toDisplayString } from 'vue'
function render(_ctx) {
return createElementBlock(
'button',
{
class: 'primary',
onClick: () => _ctx.count++,
},
toDisplayString(_ctx.count),
)
}
真实生成代码会带有 block、patch flag、缓存和辅助函数,具体结果取决于 Vue 版本、编译模式和模板结构。

2. Vue render function 与 h
Vue 3 手写 render function 通常使用 h:
import { h } from 'vue'
export default {
props: {
title: String,
},
setup(props) {
return () => h('h1', { class: 'title' }, props.title)
},
}
Vue 2 则常见:
export default {
render(h) {
return h('h1', { class: 'title' }, this.title)
},
}
Vue 2 的 h 参数和 Vue 3 h 的 vnode 结构不同,迁移时不能只把 render(h) 中的字段照搬过去。
3. React 的 createElement 与 JSX
React 的 JSX 概念上会变成:
const element = React.createElement(
'div',
{ className: 'red' },
React.createElement('span', null, 'hello'),
)
JSX 是创建描述对象的语法方式,不等于 vnode 本身;Vue 同样可以使用 JSX 插件,但模板和 render function 已经足够生成 vnode。


三、VDOM 的优点与边界
1. 减少不必要的更新
如果更新前后只有一个文本节点发生变化,renderer 可以只修改对应文本,而不必重新创建整棵 DOM 树:
old vnode → <p>old</p>
new vnode → <p>new</p>
结果:修改 p 的文本节点
但“VDOM 把 1000 次 DOM 操作合并成一次”不是通用事实。框架可能批量调度组件更新,也可能最终提交多次属性、文本和节点操作;是否合并、合并到什么程度由 renderer 和具体更新决定。
2. 提供统一的渲染抽象
同一套 vnode/组件思想可以通过不同 renderer 映射到 DOM、服务端字符串、原生平台或自定义目标。跨平台不是 vnode 自动变成 iOS/Android,而是需要目标平台对应的 renderer 或编译适配层。
3. 不是天然更快
VDOM 增加了创建 vnode、比较和运行时调度成本。对简单且已知的局部变化,直接操作 DOM 可能更快;对复杂声明式 UI,VDOM 提供统一的组件和更新模型。Vue 3 还使用静态提升、patch flags、block tree 和响应式更新范围减少运行时工作。
因此,不能把“操作 DOM 必然慢”“VDOM 必然快”或固定的 Vue/React 秒数作为结论。大规模列表还要结合分页、虚拟列表和实际性能分析。
四、DOM Diff 的基本逻辑
Diff 发生在 vnode 或渲染树层面,最终由 patch/renderer 把必要变化提交到真实目标。可以把它抽象为:
const patches = diff(oldVNode, newVNode)
applyPatches(rootElement, patches)
现代 Vue/React 内部未必生成这样的公开补丁数组;它们可能边比较边调用 host operations。补丁模型适合解释原理。
一个简化补丁可能是:
[
{ type: 'INSERT', vnode: newVNode },
{ type: 'TEXT', text: 'new text' },
{ type: 'PROPS', props: { class: 'active' } },
]
1. Tree Diff
一般会从根开始按层级比较:
- 同一位置的节点类型不同:卸载旧节点,挂载新节点;
- 类型相同:继续比较 props、文本和 children;
- 子节点通常只在同一父节点下比较,不会为任意两棵树寻找全局最优跨层级匹配;
- 组件、Fragment、Teleport、Suspense 和文本节点会走不同 renderer 分支。

2. Component Diff
组件节点比较的重点是组件类型和 key:
旧:<Dialog :open="true" />
新:<Dialog :open="false" />
组件类型相同,通常复用组件实例并更新 props/slots;组件类型或 key 不同,则会创建新的组件实例并卸载旧实例。组件内部随后由自己的渲染 effect 产生子树更新。
3. Element Diff
原生元素节点比较标签和 key:
旧:<div class="old">text</div>
新:<div class="new">text2</div>
类型相同,更新 class 和文本;
旧:<div>...</div>
新:<p>...</p>
类型不同,通常直接替换根节点及其子树。真实 renderer 还会处理事件、DOM property、指令、transition 和 SVG namespace。

五、为什么需要 key
1. 没有 key 时的按位置复用
旧列表:
[1, 2, 3]
新列表:
[1, 3]
对于没有 key 的列表,renderer 可以按位置复用:第一个位置继续复用,第二个位置把原来的 2 更新为 3,最后移除多余节点。这对纯文本列表可能没有问题,但如果每行有输入框、组件实例或本地状态,就可能让状态跟着位置而不是跟着业务项走。
这通常不是“Vue 横向比较存在 bug”,而是 unkeyed list 明确选择的按位置复用策略。开发者需要根据列表是否重排、是否保留子组件状态,选择是否提供 key。
2. 有稳定 key 时
<li v-for="item in items" :key="item.id">
<input v-model="item.value" />
</li>
旧数据:
[
{ id: 1, value: 'one' },
{ id: 2, value: 'two' },
{ id: 3, value: 'three' },
]
删除 id 为 2 的项后,renderer 可以识别 id 为 3 的 vnode 仍然是原来的节点,保留正确的组件实例和 DOM 状态。


3. index 不是“绝对不可以”但通常不合适
原文说“默认 key 就是 index,绝对不可以用 index”,需要改得更准确:
- Vue 的 vnode key 不会自动为所有
v-for生成业务意义上的 index key; - 没有 key 时,列表会走 unkeyed/按位置的更新路径;
- 使用
:key="index"是显式把位置当作身份; - 如果列表只追加、只截取、不排序、不删除中间项,并且子项没有需要保留的状态,index 风险较低;
- 可插入、删除、排序或包含表单/组件状态的列表,应使用稳定且唯一的业务 id。


六、Vue 2 与 Vue 3 的 Diff 差异
| 方面 | Vue 2 | Vue 3 |
|---|---|---|
| 子节点策略 | updateChildren 双端比较、key 映射 | patchKeyedChildren,头尾同步、key 映射和移动优化 |
| 编译信息 | 运行时优化较少 | patch flags、静态提升、block tree |
| Fragment | 组件模板通常需要单根 | 原生支持多根节点 |
| vnode 字段 | tag/data/children/elm 等 | type/props/shapeFlag/patchFlag 等 |
| 更新范围 | 组件渲染 watcher 触发更新 | 组件渲染 effect + scheduler |
Vue 3 keyed children 在复杂移动场景会建立新旧索引映射,并使用最长递增子序列尽量减少移动。它仍然是启发式算法,不保证任何输入都得到理论上的最少 DOM 操作。
七、把 Diff 过程串起来
一次典型更新可以抽象成:
响应式数据变化
↓
组件更新任务进入 scheduler
↓
执行 render,产生 new vnode
↓
比较 old vnode 与 new vnode
↓
匹配同类型/同 key 节点
↓
更新 props、文本、子节点或移动列表项
↓
调用 renderer 提交真实 DOM 操作
这也解释了为什么“Diff 只在 VDOM 上发生”并不意味着真实 DOM 不重要:Diff 的结果最终仍要经过浏览器 DOM 的属性更新、节点插入、删除、移动、布局和绘制。
总结
- 虚拟 DOM 是描述 UI 的 JavaScript 数据结构,Vue 和 React 的内部字段并不相同;
- 模板、JSX 和 render function 都可以生成 vnode,但它们是创建方式,不是 vnode 本身;
- Diff 通常遵循同层比较、类型不同替换、类型相同继续 patch 的原则;
- 无 key 的列表常按位置复用,稳定 key 用于表达节点身份和保留组件/DOM 状态;
- index key 不是任何场景都错误,但在可重排、可删除和有内部状态的列表中风险较大;
- Vue 2 主要使用双端 Diff,Vue 3 还结合编译器优化、动态子树和 keyed children 移动优化;
- VDOM 没有固定的性能保证,应该用真实场景和性能工具验证。
参考资料
- Vue 3:Rendering Mechanism
- Vue 3:列表渲染与 key
- Vue 3:Render Functions & JSX
- Vue 3:key API
- Vue 2:列表渲染——维护状态
- Vue 2 patch 源码
- Vue 3 renderer 源码
原文作者:凯小默。原文关于 vnode、模板/JSX、VDOM 优缺点、Tree Diff、组件/元素 Diff、key 和 patch 的内容予以保留;错误的 VNode 对象语法、空图片引用、固定性能数据、Vue 横向 Diff bug 和“index 绝对禁止”等绝对表述已修正。