深入浅出虚拟 DOM 和 Diff 算法:Vue 2 与 Vue 3 的区别
原文从虚拟 DOM、Vue 2 的
patch/updateChildren、key,一直讲到 Vue 3 的 patch flag、静态提升和最长递增子序列。本文尽量保留原有章节和示例,修复抓取导致的代码拼接、错误的复杂度和过于绝对的性能结论,并补充 Vue 3.5 当前渲染机制。
一、认识虚拟 DOM
虚拟 DOM(Virtual DOM,VDOM)是用 JavaScript 数据结构表达 UI 的一种方式。例如:
const vnode = {
type: 'div',
props: {
id: 'app',
class: 'container',
},
children: [
{
type: 'h1',
children: '沐华',
},
],
}
不同框架的 vnode 字段并不完全相同:
- Vue 2 常见字段是
tag、data、children、text、elm、key; - Vue 3 常见字段是
type、props、children、key、patchFlag、shapeFlag等; - 上面的对象只是教学模型,不是可以直接交给 Vue renderer 的完整 vnode。
运行时 renderer 可以遍历 vnode 并创建真实 DOM,这一步叫 mount。当有旧 vnode 和新 vnode 时,renderer 可以比较它们,再把必要的差异应用到真实 DOM,这一步叫 patch,也常被称为 diff 或 reconciliation。

虚拟 DOM 的价值不是“天然更快”
原文把“DOM 节点属性很多”和“虚拟 DOM 可以提升性能”联系起来,这个方向有一定道理,但不能推出“VDOM 一定比手写 DOM 快”。更准确的说法是:
- 直接 DOM 操作可能很快,也可能因为分散、重复、布局读取等原因难以维护;
- VDOM 为声明式 UI 提供了统一的中间表示,使框架可以集中处理创建、更新、卸载、组件、指令和事件;
- Diff 可以避免一些不必要的 DOM 操作,但构造 vnode、遍历和比较本身也有成本;
- Vue 3 还依靠模板编译、静态提升、patch flags、block tree 和批量调度减少运行时工作;
- 真正的快慢要结合组件规模、模板结构、更新频率和实际 DOM/布局成本测量。
因此,不应把“VDOM 会自动只更新一个节点”或“VDOM 永远比原生 DOM 快”当成结论。
二、Vue 的渲染更新流程
Vue 模板到 DOM 的高层过程可以概括为:
template
↓ 编译
render function
↓ 执行,读取响应式依赖
new vnode tree
↓ renderer
mount / patch
↓
real DOM
数据变化时,组件的渲染 effect 重新执行:
响应式数据变化
↓
组件更新任务进入调度队列
↓
重新执行 render
↓
得到 newVnode
↓
patch(oldVnode, newVnode)
↓
只提交必要的 DOM/组件更新
首次渲染时没有旧 vnode 可以比较,主要是创建和挂载;更新时才需要对比前后 vnode。Vue 2 中通常一个组件对应一个渲染 watcher;Vue 3 中是组件渲染 effect,具体调度和 renderer 实现有所不同。
三、Diff 的基本原则
Diff 是一种更新策略,不是一个唯一标准算法。Vue 2、Vue 3、React 和 Snabbdom 都有相似思想,但实现细节和优化目标不同。
1. 只比较同层级节点
一般不会为了寻找“最相似的树”而跨层级移动节点。比较通常发生在同一个父节点的子节点列表中:

只比较同层级可以把一个复杂的树匹配问题降为多个局部列表问题。代价是:如果节点从一层移动到另一层,通常会被视为删除旧节点并创建新节点,而不是跨层级搬运。
2. 节点类型不同通常直接替换
旧:<div>...</div>
新:<p>...</p>
当同一位置的 vnode 类型不兼容时,继续对比它们的子树通常没有意义,renderer 会卸载旧节点并挂载新节点:

“类型”在不同框架中可能包含 HTML 标签、组件类型、注释、文本和 Fragment 等,不只是比较字符串标签名。
3. 相同节点继续比较属性和子节点
Vue 2 的 sameVnode 不仅比较 key 和标签,还会考虑注释、数据是否存在、异步组件工厂、input 类型等条件。Vue 3 则使用 vnode type、key、shape 等信息判断 patch 路径。
一个教学版的判断函数可以写成:
function isSameVNode(oldVNode, newVNode) {
return oldVNode.type === newVNode.type
&& oldVNode.key === newVNode.key
}
这不是 Vue 的完整实现,但能表达核心:key 不是单独决定一切的字段,类型和 key 要共同表达节点身份。
四、key 的作用
假设旧列表为:
[A, B, C, D]
新列表在 B 和 C 之间插入 X:
[A, B, X, C, D]

1. 有稳定 key 时
如果每项有稳定业务 id:
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
renderer 可以识别 A、B、C、D 仍然是原来的 vnode 身份,只需创建 X,并根据需要移动或更新节点:

这不仅关系性能,也关系 DOM 状态:输入框焦点、选中状态、组件实例和过渡状态都可能依赖节点身份。
2. 不要把“不会重新渲染”说得过于绝对
有稳定 key 并不等于对应组件永远不执行更新:
- 如果该项的 props、插槽或自身响应式状态改变,它仍可能更新;
- 父组件重新渲染时,子 vnode 仍可能参与必要的 patch;
- key 的作用主要是帮助 renderer 正确匹配和复用节点,而不是承诺某个组件函数完全不运行;
- 最终是否产生 DOM 写操作,要看属性、文本和子树是否真的变化。
3. index key 的适用范围
数组下标作为 key 在只追加、只截取且列表项不排序/不插入/不删除中间元素的场景下可能没有问题。但如果列表会重排、插入、删除,index 会随着位置改变,导致旧组件状态被错误复用:
<!-- 可重排列表中不推荐 -->
<li v-for="(item, index) in items" :key="index">
<input v-model="item.name">
</li>
应优先使用稳定且唯一的业务 id:
<li v-for="item in items" :key="item.id">
<input v-model="item.name">
</li>
Vue 不会因为所有 v-for 都没有 key 就让应用完全不能运行;但需要 key 的列表、组件状态和过渡场景应遵循框架提示与 lint 规则。没有 key 时,Vue 可能采用按位置复用的策略,这在简单的纯文本列表中可以工作,但语义和状态安全性较弱。
五、Vue 2 的 patch 与 updateChildren
原文引用的是 Vue 2 源码。下面先还原核心流程,再用简化代码说明双端 Diff。真实源码还包含组件、指令、transition、服务端 hydration、异步组件和模块钩子等大量分支。
1. patch 的职责
oldVnode 不存在,vnode 存在
→ 创建并插入 vnode
vnode 不存在,oldVnode 存在
→ 卸载/删除 oldVnode
两者存在且是同一 vnode
→ patchVnode,更新属性、文本和 children
两者存在但不是同一 vnode
→ 创建新节点,插入旧节点位置,再移除旧节点
Vue 2 的 sameVnode 可在 src/core/vdom/patch.js 中查看。不能把文章中的教学实现当成可直接替换 Vue 内核的完整 patch。
2. patchVnode
同一 vnode 的更新大体是:
function patchVnode(oldVNode, newVNode) {
const el = newVNode.elm = oldVNode.elm
// 更新 class、style、attrs、DOM props、事件、指令等模块。
updateModules(oldVNode, newVNode)
if (newVNode.text !== undefined) {
if (newVNode.text !== oldVNode.text) {
el.textContent = newVNode.text
}
return
}
const oldChildren = oldVNode.children || []
const newChildren = newVNode.children || []
updateChildren(el, oldChildren, newChildren)
}
真实 Vue 2 还要区分文本 vnode、注释 vnode、组件 vnode、静态 vnode、异步占位符和 hydration;上述代码只用于建立心智模型。
3. 双端比较
Vue 2 updateChildren 的核心思路是维护旧列表和新列表的头尾指针,优先尝试四种命中:
- 旧头和新头;
- 旧尾和新尾;
- 旧头和新尾(节点向右移动);
- 旧尾和新头(节点向左移动)。

教学版伪代码:
function updateChildren(parent, oldChildren, newChildren) {
let oldStart = 0
let oldEnd = oldChildren.length - 1
let newStart = 0
let newEnd = newChildren.length - 1
while (oldStart <= oldEnd && newStart <= newEnd) {
const oldHead = oldChildren[oldStart]
const oldTail = oldChildren[oldEnd]
const newHead = newChildren[newStart]
const newTail = newChildren[newEnd]
if (isSameVNode(oldHead, newHead)) {
patchVnode(oldHead, newHead)
oldStart++
newStart++
} else if (isSameVNode(oldTail, newTail)) {
patchVnode(oldTail, newTail)
oldEnd--
newEnd--
} else if (isSameVNode(oldHead, newTail)) {
patchVnode(oldHead, newTail)
moveBefore(parent, oldHead.elm, oldTail.elm.nextSibling)
oldStart++
newEnd--
} else if (isSameVNode(oldTail, newHead)) {
patchVnode(oldTail, newHead)
moveBefore(parent, oldTail.elm, oldHead.elm)
oldEnd--
newStart++
} else {
// 真实 Vue 2 会建立 key -> 旧索引的映射。
const oldIndex = findByKey(oldChildren, newHead.key, oldStart, oldEnd)
if (oldIndex === -1) {
insertBefore(parent, createElm(newHead), oldHead.elm)
} else {
const vnodeToMove = oldChildren[oldIndex]
if (isSameVNode(vnodeToMove, newHead)) {
patchVnode(vnodeToMove, newHead)
moveBefore(parent, vnodeToMove.elm, oldHead.elm)
oldChildren[oldIndex] = undefined
} else {
insertBefore(parent, createElm(newHead), oldHead.elm)
}
}
newStart++
}
}
// 新列表还有剩余:创建并插入。
// 旧列表还有剩余:移除旧节点。
addRemaining(newChildren, newStart, newEnd)
removeRemaining(oldChildren, oldStart, oldEnd)
}
真实实现会跳过已经移动的 undefined vnode,使用 createKeyToOldIdx,并根据 removeOnly 控制 transition-group 的移动。双端比较是启发式优化,不保证对任意输入都得到理论上的最少 DOM 操作。
六、Vue 3 的 Diff 与编译器优化
Vue 3 不是简单地“把 Vue 2 的 updateChildren 换个名字”。它结合编译器和运行时,从 vnode 形状、动态节点和子节点类型等方面减少工作。
1. Patch flags
例如:
<div :class="{ active }" />
<input :id="id" :value="value" />
<div>{{ message }}</div>
模板编译器可以在 vnode 上标注动态类型。概念上的生成结果类似:
_createElementVNode(
'div',
{ class: normalizeClass({ active: _ctx.active }) },
null,
2, // CLASS
)
_createElementVNode(
'div',
null,
toDisplayString(_ctx.message),
1, // TEXT
)
常见标记包括:
const PatchFlags = {
TEXT: 1,
CLASS: 1 << 1,
STYLE: 1 << 2,
PROPS: 1 << 3,
FULL_PROPS: 1 << 4,
HYDRATE_EVENTS: 1 << 5,
STABLE_FRAGMENT: 1 << 6,
KEYED_FRAGMENT: 1 << 7,
UNKEYED_FRAGMENT: 1 << 8,
NEED_PATCH: 1 << 9,
DYNAMIC_SLOTS: 1 << 10,
HOISTED: -1,
BAIL: -2,
}
实际源码使用位运算判断:
if (vnode.patchFlag & PatchFlags.CLASS) {
patchClass(el, oldValue, newValue)
}
原文把 patchFlag && PatchFlags.KEYED_FRAGMENT 写成普通逻辑与,这是错误的;判断位标记必须使用 patchFlag & PatchFlags.KEYED_FRAGMENT。此外,标记是编译器和运行时之间的内部契约,不建议业务代码手动构造。
2. 静态提升与 block tree
静态模板节点不会因为每次组件更新而改变。Vue 3 可以:
- 把静态 vnode 或静态属性对象提升到 render 函数外,只创建一次;
- 用 patch flag 标记动态文本、class、style 和 props;
- 以 block 记录动态后代,更新时优先遍历动态节点,而不是完整树;
- 对稳定 Fragment、keyed Fragment 和 unkeyed Fragment 走不同路径;
- 在满足条件时使用静态 HTML 字符串等更快的创建路径。
这就是 Vue 3 的 compiler-informed virtual DOM:编译器提前告诉运行时哪里可能变化,运行时因此可以跳过大量静态节点。并不是所有 render function 或完全动态模板都能得到同等优化。
3. 事件缓存的边界
原文把 Vue 3 的事件缓存描述成“所有事件都变成静态节点”,这个说法过于绝对。编译器会根据事件表达式和编译配置生成不同代码,在适合的场景可能使用 _cache 缓存内联处理器;但事件本身仍然要响应最新状态,且不是所有手写 render function 都自动获得该优化。
4. patchKeyedChildren 和最长递增子序列
Vue 3 对 keyed children 的实现通常会:
- 从头部同步相同节点;
- 从尾部同步相同节点;
- 处理新列表剩余部分的纯新增或旧列表剩余部分的纯删除;
- 建立新 key 到新索引的映射;
- 通过
newIndexToOldIndexMap记录旧节点位置; - 对仍可复用的节点求最长递增子序列(LIS);
- 逆序遍历,根据锚点执行移动、新增或删除。
例如:
旧: [a, b, c, d, e, f, g]
新: [a, b, f, c, d, e, h, g]
头部得到 [a, b],尾部得到 [g]。中间的新节点 [f, c, d, e, h] 对应旧数组位置时,Vue 的内部映射使用“旧索引 + 1”,概念上可表示为:
[f, c, d, e, h]
[6, 3, 4, 5, 0]
其中 0 表示新增节点;[3, 4, 5] 对应的 [c, d, e] 是递增子序列。它们可以尽量保持原位置,其他节点再移动或创建。

LIS 的目标是减少不必要的移动,不代表每种输入都能得到全局最优的 DOM 操作,也不意味着所有节点都不会重新执行组件逻辑。
七、Vue 2 与 Vue 3 的主要差异
| 方面 | Vue 2 | Vue 3 |
|---|---|---|
| 渲染 watcher/effect | 组件通常对应一个渲染 Watcher | 组件渲染 effect + scheduler |
| 子节点 Diff | updateChildren 双端比较、key 映射 | patchKeyedChildren,结合编译信息和 LIS |
| 编译优化 | 主要依赖运行时 patch | patch flags、静态提升、block tree |
| 静态节点 | 更新时仍有较多运行时工作 | 可以缓存并跳过静态子树 |
| Fragment | 组件模板通常单根 | 原生支持多根节点 |
| vnode 内部字段 | tag/data/children/elm 等 | type/props/shapeFlag/patchFlag 等 |
| 事件/属性更新 | 由模块钩子处理 | 由 renderer 和 patch flags 分流 |
Vue 3 的性能提升来自多个层面,不能用固定的“更新快 1.3~2 倍、SSR 快 2~3 倍”概括。性能数据必须注明版本、浏览器、模板、数据规模和测试方式。
八、如何正确理解 Diff 性能
- Diff 不会把任意新旧树做全局最优匹配,而是使用类型、key、同层级和列表启发式;
- key 首先表达 vnode/业务身份,其次才帮助减少移动和错误复用;
- 编译器能提供优化提示时,Vue 3 可以跳过更多静态节点;
- 组件更新、子组件 props、slot、响应式 effect 和 DOM 更新是不同层次的问题;
- 只减少 DOM 操作不代表 JavaScript、网络、布局和绘制成本都减少;
- 大列表应结合稳定 key、分页/虚拟列表、轻量行组件和实际性能分析。
总结
- VDOM 是 UI 的内存表示,mount 创建 DOM,patch 根据新旧 vnode 应用更新;
- Diff 通常只比较同层级,并以类型和 key 判断节点身份;
- Vue 2 的核心是
patchVnode、双端updateChildren和 key 映射; - Vue 3 的 keyed Diff 使用头尾同步、key 映射和 LIS,同时借助 patch flags、静态提升和 block tree;
- 稳定 key 能正确复用组件实例和 DOM 状态,但不保证组件永远不更新;
- VDOM 没有天然性能保证,Vue 的优势来自编译器、运行时、调度器和 renderer 的协同。
参考资料
- Vue 3:Rendering Mechanism
- Vue 3:列表渲染与 key
- Vue 2:patch 源码
- Vue 3:renderer 源码
- Vue 3:patch flags 源码
- Vue Template Explorer
- Snabbdom
原文作者:沐华。原文关于 VDOM、同层比较、key、Vue 2 双端 Diff、Vue 3 patch flags、静态提升和 LIS 的主线予以保留;抓取的远程图片已本地化,拼接损坏的源码、错误的 10^3/10^9 绝对复杂度、无 key 必然报错、所有节点只更新一次以及固定性能倍数等说法已删除或修正。