人人都会AI编程

Vue 2 双端对比算法:首尾指针移动逻辑

更新时间:2026-07-11

在 Vue 2 的虚拟 DOM Diff 过程中,当新旧两组子节点都是多个节点时,Vue 采用的是一种双端对比(双端比较) 策略。它的核心思想是:同时从新旧节点列表的头尾两侧向中间收缩进行比对,尽可能复用已有的 DOM 节点,减少不必要的 DOM 创建、移动和删除操作。

为什么需要双端对比

最简单的 Diff 方式是按索引顺序逐个比较,但这种方式对节点位置移动的情况处理不佳。例如,将列表的第一个节点移动到末尾时,按索引比较会导致几乎所有节点的对应关系都发生错位,从而引发大量的 DOM 更新。双端对比能更智能地识别出“节点只是移动了位置”而非“完全替换”,从而用最小的 DOM 操作代价完成更新。

四指针与四节点

算法初始化时,会设置四个指针:

  • oldStartIdx(旧头):指向旧子节点数组的起始位置
  • oldEndIdx(旧尾):指向旧子节点数组的末尾位置
  • newStartIdx(新头):指向新子节点数组的起始位置
  • newEndIdx(新尾):指向新子节点数组的末尾位置

对应的四个节点分别是:

  • oldStartVNode:旧头节点
  • oldEndVNode:旧尾节点
  • newStartVNode:新头节点
  • newEndVNode:新尾节点

形象地看,这就像新旧两个队伍分别从两头排出“代表”进行配对。

对比的四种尝试

每一轮比对,Vue 2 会依次尝试以下四种匹配策略,一旦某一种匹配成功,就进入下一轮:

  1. 旧头 vs 新头

比较 oldStartVNodenewStartVNode。如果它们是同一个节点(key 相同且标签相同),则认为这两个节点可以原地复用,将旧头节点 patch 更新后,旧头指针和新头指针都向右移动一位(oldStartIdx++, newStartIdx++)。

  1. 旧尾 vs 新尾

比较 oldEndVNodenewEndVNode。如果匹配,则两个节点原地复用,旧尾指针和新尾指针都向左移动一位(oldEndIdx--, newEndIdx--)。

  1. 旧头 vs 新尾

比较 oldStartVNodenewEndVNode。如果匹配,说明旧的头节点被移动到了新列表的末尾。此时需要将旧头节点对应的真实 DOM 移动到当前旧尾节点的后面,并更新节点。然后旧头指针右移,新尾指针左移(oldStartIdx++, newEndIdx--)。

  1. 旧尾 vs 新头

比较 oldEndVNodenewStartVNode。如果匹配,说明旧的尾节点被移动到了新列表的开头。此时需要将旧尾节点对应的真实 DOM 移动到当前旧头节点的前面,并更新节点。然后旧尾指针左移,新头指针右移(oldEndIdx--, newStartIdx++)。

这四种尝试顺序设计得很巧妙:先处理不发生位置移动的头部和尾部,再处理需要移动的两端交叉情况,充分利用了列表更新中“头部或尾部稳定”的常见特点。

兜底策略:Map 查询

如果上面四种尝试全部失败,说明新头节点无法与旧双端节点直接匹配。此时 Vue 2 会将旧未处理区域的节点按 key 生成一个 Map(或遍历数组),用新头节点的 key 去查找是否有匹配的旧节点。

  • 找到了:将该旧节点对应的 DOM 移动到旧头位置前面,并 patch。然后将该旧节点在原位置置为 undefined(避免后续重复处理),新头指针右移。
  • 没找到:说明这是一个全新的节点,直接创建一个新的 DOM 节点并插入到旧头位置前面,新头指针右移。

循环结束后的收尾工作

oldStartIdx > oldEndIdx(旧节点先遍历完)或 newStartIdx > newEndIdx(新节点先遍历完)时,循环终止。剩下的工作分为两种情况:

  • 旧节点已耗尽,新节点还有剩余:说明这些是新增加的节点。需要将 newStartIdxnewEndIdx 之间的所有新节点逐一创建并插入到恰当的位置(旧头节点之前)。
  • 新节点已耗尽,旧节点还有剩余:说明这些旧节点在新的列表中已不存在,需要批量删除 oldStartIdxoldEndIdx 之间的旧节点对应的真实 DOM。

一个简单的例子

假设旧子节点列表为 [A, B, C, D],新子节点列表变为 [D, A, B, C](将最后的 D 移动到了最前面)。

| 步骤 | 比较动作 | 结果 | 指针变化 |
|------|----------------|------------------------------------------------|------------------------------|
| 1 | 旧头(A) vs 新头(D) | 不匹配 | |
| 2 | 旧尾(D) vs 新尾(C) | 不匹配 | |
| 3 | 旧头(A) vs 新尾(C) | 不匹配 | |
| 4 | 旧尾(D) vs 新头(D) | ✅ 匹配(旧尾移动到新头) | oldEndIdx 左移,newStartIdx 右移 |
| 5 | 旧头(A) vs 新头(A) | ✅ 匹配(不移动) | 双头右移 |
| 6 | 旧头(B) vs 新头(B) | ✅ 匹配(不移动) | 双头右移 |
| 7 | 旧头(C) vs 新头(C) | ✅ 匹配(不移动) | 双头右移,循环结束 |

最终整个更新只进行了一次 D 节点的 DOM 移动操作,A、B、C 全部原地复用,效率极高。

双端对比的优势与局限

优势

  • 对列表顺序的简单变动(如头部增加、尾部删除、甚至是整体反转)能极好地复用节点,避免全量重新创建。
  • 相比逐个索引比较,移动节点的频次大幅度降低。

局限

  • 对于复杂的乱序(如 A,B,C,D → B,D,A,C),双端对比可能退化为大量 Map 查找和移动,但依然能保证正确性。
  • 算法实现相对复杂,但 Vue 2 的成熟实现已经非常稳定。

与 Vue 3 Diff 的关系

Vue 3 优化了 Diff 算法,引入了最长递增子序列来进一步减少节点的移动操作(见后续章节)。但理解 Vue 2 的双端对比,是掌握 Vue 响应式更新底层逻辑的重要铺垫,也是面试中高频考点。它体现了一种经典的“双指针”思想,用较低的时间复杂度(O(n))解决了新旧节点列表的比对问题。