虚拟 DOM 的 Diff 算法,本质上是在回答一个问题:两颗 VNode 树之间,如何用最小的操作次数把旧的变成新的? 精确对比两颗树的差异,理论上最坏时间复杂度是 O(n³),这在实际页面中完全不可接受。Vue 的 Diff 算法通过一系列策略,将复杂度压到 O(n),同时保证渲染结果正确。
同层比较:不跨层级对比
前端 UI 很少出现“一个节点从页面顶部忽然移动到底部”的跨层级移动,DOM 结构的变化绝大多数发生在同一层级:例如列表新增一条、某个 div 的文本变了、一个子组件属性更新。Vue 的 Diff 就利用这个特性,只对同层级的 VNode 进行对比,不跨层递归。
旧 VNode 树 新 VNode 树
div div
/ \ / \
p span 对比 p span
/ / \
text text strong
对比流程是深度优先、逐层比较:先对比 div 本身,再对比它的子节点 p 和 span,再对比 p 的子节点。如果发现 p 标签在新树中变成了 section,Vue 会直接销毁旧节点并创建新节点,而不是尝试“将 p 转换成 section”。这种“同层比较 + 标签不同直接替换”的策略,极大减少了不必要的比对。
Vue 2 双端对比算法
在同层子节点(比如一个 v-for 列表)对比时,Vue 2 使用了经典的双端对比策略。维护四个指针:
oldStartIdx:旧子节点数组的开始位置oldEndIdx:旧子节点数组的结束位置newStartIdx:新子节点数组的开始位置newEndIdx:新子节点数组的结束位置
每轮循环会尝试以下四种比较:
- 旧头 vs 新头(节点相同,位置不变)
- 旧尾 vs 新尾(节点相同,位置不变)
- 旧头 vs 新尾(节点相同,意味着旧节点被移动到了末尾)
- 旧尾 vs 新头(节点相同,意味着旧节点被移动到了开头)
如果四种都没匹配上,就拿着新头的 key 去旧数组中查找,根据结果判断是新增、删除还是移动。
双端对比的优势:对于常见的列表头部/尾部增删、顺序微调,可以在一次循环内找到大部分可复用节点,减少移动和创建。但对于中间乱序的复杂移动,它在最坏情况下仍需多次循环。
Vue 3 Diff 优化:从编译阶段下手
Vue 3 对 Diff 的优化,不再只依赖运行时算法,而是将大量工作提前到编译阶段完成。核心增强有三点:
1. 静态提升(Static Hoisting)
编译时,模板中的静态节点(无任何动态绑定)会被提升到渲染函数外部,只创建一次 VNode,后续更新时直接复用,完全跳过 Diff。
比如模板:
<div>
<span>静态文本</span>
<p>{{ msg }}</p>
</div>
会被编译成类似下面的渲染函数逻辑(伪代码):
const _hoisted_1 = /* 静态span的VNode */
render() {
return h('div', [
_hoisted_1, // 直接复用,不参与对比
h('p', null, msg) // 只对比这个动态节点
])
}
2. PatchFlags(动态标记)
每个动态节点都会被编译器打上一个 PatchFlag,这个标志精确记录了节点可能发生变化的属性类型。
文本动态:1
class动态:2
style动态:4
props动态:8
...
运行时 Diff 时,Vue 会先检查 VNode 上的 patchFlag。如果标志位只包含“文本变化”,那么就只比较文本,完全跳过 class、style 等其他属性的对比。这比“全属性 diff”高效得多,尤其在大型组件树中,大量节点都有各自的动态属性时,精确跳过无用功。
3. Block 树(动态节点收集)
传统 Diff 需要递归遍历整棵 VNode 树来找出动态节点。Vue 3 引入 Block 的概念:每个 Block 是一个节点收集器,它会将自己子树中的所有动态节点(也就是带 PatchFlag 的节点)收集到一个扁平数组中。
render() {
return (openBlock(), createBlock('div', null, [
_hoisted_1,
h('p', null, msg), // 动态节点
h('span', { class: cls }, null) // 动态节点
]))
}
更新时,Diff 不再遍历整棵 VNode 树,而是直接遍历 Block 的动态节点数组,跳过所有静态内容和静态子树。这种扁平化的对比路径,在复杂页面中能显著减少遍历开销。
列表 Diff 的最长递增子序列处理
当使用 v-for 渲染列表时,即使有动态标记和 Block 树,依然需要处理列表节点的移动、新增和删除。Vue 3 在列表对比中采用了不同于 Vue 2 的双端比较策略:
- 预处理:从前向后和从后向前,找出两端相同的不移动节点,缩小对比范围。
- 构建新节点 key 到旧节点索引的映射:快速定位可复用节点。
- 计算最长递增子序列(LIS):对剩余需要处理的节点,Vue 3 使用贪心 + 二分查找算法,算出最长的、不需要移动的旧节点索引序列。
- 根据 LIS 决定移动操作:在倒序遍历新节点时,如果当前旧节点索引不在最长递增子序列中,就执行移动 DOM 操作;如果在序列中,说明它在正确的位置,不需要移动。
直观理解:假设旧节点列表为 [A, B, C, D],新节点列表为 [A, C, B, D]。通过对比,可复用节点位置映射为旧索引 [0, 2, 1, 3]。这组索引的最长递增子序列是 [0, 1, 3](对应节点 A、C、D),只有索引为 2 的节点 B 不在这个序列中,需要被移动。Vue 就会精确地只移动 B 到正确位置,而不是把 C 和 D 也挪一遍。
性能收益:相比 Vue 2 的双端对比在某些复杂移动场景下产生多次节点位移,Vue 3 的最长递增子序列算法能够最小化 DOM 移动次数,对大数据列表的更新效率有明显提升。
实战视角的总结
对于开发者而言,你不需要直接调用 Diff 算法,但理解它的策略能帮你写出更高效的模板:
- 静态内容尽量不放在循环中:静态节点会被自动提升,但如果静态节点包裹在循环里,每次迭代都会重新创建,Vue 无法将其提升。
- 给列表节点稳定的 key:别用随机数或索引(尤其列表会增删时),否则 Vue 无法正确复用节点,导致不必要的销毁和创建,失去 Diff 优化的收益。
- 避免不必要的大面积结构变化:同层比较的设计意味着如果你整个替换一个父容器的结构,其下所有子节点都会销毁重建。尽量用显示/隐藏(v-show)或细粒度的子节点更新。
Diff 算法是 Vue 渲染性能的基石,而 Vue 3 将编译时和运行时结合的策略,使得大多数日常开发无需额外优化就能获得很好的渲染速度。