虚拟 DOM 与 Diff 算法
浏览器的 DOM 操作代价不均——回流的成本远高于合成,而频繁的 DOM 操作又会因为读写交替引发强制同步布局。如果让开发者手动管理每次数据变化后应该更新哪些 DOM 节点,代码会变得极其复杂且容易出错。虚拟 DOM(Virtual DOM)就是在 DOM 之上加了一层 JavaScript 对象的抽象,开发者只关心数据变化后 UI 应该长什么样,框架通过 diff 算法比较新旧虚拟 DOM 树的差异,再以最小的代价将这些差异应用到真实 DOM 上。
diff 的核心约束
两个 DOM 树的完整差异计算是树编辑距离问题,即使使用动态规划算法,时间复杂度也是
只比较同一层级的节点,不做跨层级的移动。如果一个节点在旧树中是某个父节点的子节点,在新树中不可能变成另一个父节点的子节点——如果真的发生了跨层级移动,旧节点会被销毁、新节点会被创建,而不是"移动"。这个假设将复杂度从
降到了 。 同一层级的子节点通过唯一的 key 来识别身份,而不是依赖节点在列表中的位置。key 使得框架知道一个节点是"移动了"还是"被替换了",从而尽量复用已有的 DOM 节点而非销毁重建。
这两个假设是理解 diff 算法所有工程实践的出发点。
Vue 2 的双端 Diff
Vue 2 采用双端比较策略。算法维护四个指针:旧子节点的头尾(oldStartIdx、oldEndIdx)和新子节点的头尾(newStartIdx、newEndIdx)。每一轮比较中,依次尝试以下几种命中:
- 旧头 vs 新头:两者 key 相同,直接复用,两个头指针各向后移动。
- 旧尾 vs 新尾:两者 key 相同,直接复用,两个尾指针各向前移动。
- 旧头 vs 新尾:key 相同,说明这个节点从头部移到了尾部,需要移动真实 DOM,旧头指针后移、新尾指针前移。
- 旧尾 vs 新头:key 相同,说明这个节点从尾部移到了头部,需要移动真实 DOM,旧尾指针前移、新头指针后移。
如果四种情况都没命中,说明发生了非头尾的移动。此时用当前新头节点的 key 在旧子节点列表中查找,如果找到了,就移动对应的旧 DOM 节点到正确的位置;如果没找到,说明是新创建的节点,直接插入。当旧子节点先遍历完,说明新子节点中有多余的节点需要创建;当新子节点先遍历完,说明有旧节点需要被卸载。
整个过程最多遍历每个节点一次,时间复杂度为
旧子节点: [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 复用而"跑到"另一个输入框上。
<!-- 不推荐:用 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 是一个高级优化手段,应该在性能分析确认有瓶颈后再使用,过早优化会增加代码复杂度而收益不明显。