人人都会AI编程

5.3 Diff 算法核心策略

更新时间:2026-07-11

虚拟 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 本身,再对比它的子节点 pspan,再对比 p 的子节点。如果发现 p 标签在新树中变成了 section,Vue 会直接销毁旧节点并创建新节点,而不是尝试“将 p 转换成 section”。这种“同层比较 + 标签不同直接替换”的策略,极大减少了不必要的比对。

Vue 2 双端对比算法

在同层子节点(比如一个 v-for 列表)对比时,Vue 2 使用了经典的双端对比策略。维护四个指针:

  • oldStartIdx:旧子节点数组的开始位置
  • oldEndIdx:旧子节点数组的结束位置
  • newStartIdx:新子节点数组的开始位置
  • newEndIdx:新子节点数组的结束位置

每轮循环会尝试以下四种比较:

  1. 旧头 vs 新头(节点相同,位置不变)
  2. 旧尾 vs 新尾(节点相同,位置不变)
  3. 旧头 vs 新尾(节点相同,意味着旧节点被移动到了末尾)
  4. 旧尾 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 的双端比较策略:

  1. 预处理:从前向后和从后向前,找出两端相同的不移动节点,缩小对比范围。
  2. 构建新节点 key 到旧节点索引的映射:快速定位可复用节点。
  3. 计算最长递增子序列(LIS):对剩余需要处理的节点,Vue 3 使用贪心 + 二分查找算法,算出最长的、不需要移动的旧节点索引序列
  4. 根据 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 将编译时和运行时结合的策略,使得大多数日常开发无需额外优化就能获得很好的渲染速度。