Skip to content

虚拟 DOM 与 Diff 算法

浏览器的 DOM 操作代价不均——回流的成本远高于合成,而频繁的 DOM 操作又会因为读写交替引发强制同步布局。如果让开发者手动管理每次数据变化后应该更新哪些 DOM 节点,代码会变得极其复杂且容易出错。虚拟 DOM(Virtual DOM)就是在 DOM 之上加了一层 JavaScript 对象的抽象,开发者只关心数据变化后 UI 应该长什么样,框架通过 diff 算法比较新旧虚拟 DOM 树的差异,再以最小的代价将这些差异应用到真实 DOM 上。

diff 的核心约束

两个 DOM 树的完整差异计算是树编辑距离问题,即使使用动态规划算法,时间复杂度也是 O(n3)n 是节点数),对于真实页面中动辄上千个节点的 DOM 树来说完全不可接受。所以 Vue(以及 React)对 diff 过程做了两个关键假设来降低复杂度:

  1. 只比较同一层级的节点,不做跨层级的移动。如果一个节点在旧树中是某个父节点的子节点,在新树中不可能变成另一个父节点的子节点——如果真的发生了跨层级移动,旧节点会被销毁、新节点会被创建,而不是"移动"。这个假设将复杂度从 O(n3) 降到了 O(n)

  2. 同一层级的子节点通过唯一的 key 来识别身份,而不是依赖节点在列表中的位置。key 使得框架知道一个节点是"移动了"还是"被替换了",从而尽量复用已有的 DOM 节点而非销毁重建。

这两个假设是理解 diff 算法所有工程实践的出发点。

Vue 2 的双端 Diff

Vue 2 采用双端比较策略。算法维护四个指针:旧子节点的头尾(oldStartIdx、oldEndIdx)和新子节点的头尾(newStartIdx、newEndIdx)。每一轮比较中,依次尝试以下几种命中:

  1. 旧头 vs 新头:两者 key 相同,直接复用,两个头指针各向后移动。
  2. 旧尾 vs 新尾:两者 key 相同,直接复用,两个尾指针各向前移动。
  3. 旧头 vs 新尾:key 相同,说明这个节点从头部移到了尾部,需要移动真实 DOM,旧头指针后移、新尾指针前移。
  4. 旧尾 vs 新头:key 相同,说明这个节点从尾部移到了头部,需要移动真实 DOM,旧尾指针前移、新头指针后移。

如果四种情况都没命中,说明发生了非头尾的移动。此时用当前新头节点的 key 在旧子节点列表中查找,如果找到了,就移动对应的旧 DOM 节点到正确的位置;如果没找到,说明是新创建的节点,直接插入。当旧子节点先遍历完,说明新子节点中有多余的节点需要创建;当新子节点先遍历完,说明有旧节点需要被卸载。

整个过程最多遍历每个节点一次,时间复杂度为 O(n)。双端比较的优势在于它对常见的列表操作(头部插入、尾部插入、头部删除、尾部删除、列表反转)都能高效处理,不需要额外的移动操作。

旧子节点: [A] [B] [C] [D]
新子节点: [A] [E] [B] [C] [D]

             新节点 E 插入到 B 前面

双端比较过程:
1. 旧头 A vs 新头 A → 命中,复用
2. 旧头 B vs 新头 E → 未命中
3. 旧尾 D vs 新尾 D → 命中,复用
4. 旧尾 C vs 新头 E → 未命中
5. 在旧列表中查找 key=E,未找到 → 创建新节点 E,插入到 B 前

Vue 3 的编译时优化

Vue 3 的 diff 算法在运行时策略上做了调整,但更重要的优化来自编译时——Vue 3 的编译器会在编译阶段收集大量的静态信息,让运行时 diff 可以直接跳过不需要比较的节点。

静态提升(Static Hoisting)是编译器发现模板中不会变化的节点(如纯文本、静态属性的元素),在首次渲染后将其虚拟 DOM 节点提升到渲染函数外部缓存起来,后续更新时直接复用,不再参与 diff。Patch Flags 是编译器为动态节点标记的具体变化类型——一个节点可能只有文本内容变化、只有 class 变化、只有部分 props 变化,编译器在生成渲染函数时会在虚拟 DOM 节点上附带一个标记指明"这个节点只会变化 text",运行时 diff 看到这个标记后只比较 text 属性,跳过其他所有属性的比较。Block Tree 则将模板的根节点及其直接子节点收集到一个"块"中,块的内部只包含动态子节点(静态子节点已经被提升),diff 时只需要遍历这个动态节点列表,而不是整个子树。

这三个优化组合起来的效果是:Vue 3 在大多数场景下 diff 的节点数量远小于 Vue 2,因为大量静态节点在编译阶段就被剔除了。

最长递增子序列

Vue 3 在处理列表 diff 时,不再使用 Vue 2 的双端比较,而是基于最长递增子序列(Longest Increasing Subsequence, LIS)算法来确定哪些节点不需要移动。这个算法来自经典的动态规划问题:给定一个序列,找到其中最长的递增子序列。

在 Vue 3 的列表 diff 语境下,新子节点中每个节点在旧子节点中的对应位置构成一个索引序列。这个序列的最长递增子序列就是"不需要移动"的节点集合——因为它们在新旧列表中的相对顺序没有变化。对于不在最长递增子序列中的节点,只需要逐个移动到正确的位置即可。相比于 Vue 2 的逐个节点尝试匹配,这种策略在节点顺序变化较小时需要的 DOM 移动操作更少。

旧子节点: [A] [B] [C] [D] [E]    对应索引: [0, 1, 2, 3, 4]
新子节点: [A] [C] [B] [D] [E]    旧索引序列: [0, 2, 1, 3, 4]
                                      ↑  ↑
                                 最长递增子序列: [0, 3, 4] 即 [A, D, E]

只有 B 和 C 需要移动,A、D、E 保持不动

key 与列表 Diff 的工程实践

v-for 中的 key 是 diff 算法识别节点身份的唯一依据。使用数组索引作为 key 是最常见的错误——当列表发生插入、删除或重排时,索引对应的元素可能发生变化,diff 算法无法正确识别节点的移动关系,退化为逐个对比和更新,失去了复用 DOM 节点的优势。更严重的是,如果列表项内部包含无状态组件(如输入框),索引作为 key 可能导致组件状态错乱——用户在一个输入框中输入的内容可能因为 DOM 复用而"跑到"另一个输入框上。

html
<!-- 不推荐:用 index 作为 key -->
<div v-for="(item, index) in list" :key="index">

<!-- 正确:用唯一标识作为 key -->
<div v-for="item in list" :key="item.id">

选择 key 时需要考虑稳定性和唯一性两个维度。数据库主键、UUID 等业务标识是最理想的选择,因为它们与列表项的内容一一对应,不会因为列表顺序变化而改变。如果列表数据没有唯一标识,可以在构建列表时为每项生成一个递增的 ID。需要注意的是,key 的选择应该基于"语义身份"而非"视觉位置"——一个用户对象无论出现在列表的哪个位置,它的 key 应该始终是用户的 ID。

Vue 3.2+ 提供了 v-memo 指令,可以在渲染函数层面跳过特定条件未变化的子树的 diff。这在列表渲染中有大量节点但只有少数会实际变化的场景下很有用——v-memo 相当于告诉 diff 算法"如果这些依赖没变,直接复用上一次的 DOM,不要再比较了"。但 v-memo 是一个高级优化手段,应该在性能分析确认有瓶颈后再使用,过早优化会增加代码复杂度而收益不明显。