读懂 Vue 双端 Diff 算法:从源码到原理,一篇彻底搞懂

4 阅读6分钟

前置知识:patchChildren 做了什么?

在看 Diff 之前,先搞清楚一个入口函数 patchChildren。页面更新的时候,Vue 需要对比"旧的子节点"和"新的子节点",但子节点的类型不一样,处理方式也完全不同:

function patchChildren(n1, n2, container) {
  if (typeof n2.children === 'string') {
    // 新子节点是纯文本 → 直接覆盖
  } else if (Array.isArray(n2.children)) {
    // 新子节点是一组元素数组(比如一堆 <li>)
    // 走 key diff 核心逻辑
    patchKeyedChildren(n1, n2, container)
  } else {
    // 空子节点等其它情况
  }
}

简单说就一句话:文本直接覆盖,列表走 Diff。我们重点聊的就是 patchKeyedChildren 这个函数——Vue 双端 Diff 算法的全部秘密都在里面。

四个指针:双端 Diff 的核心武器

进入 patchKeyedChildren,第一件事就是设置"四个指针":

function patchKeyedChildren(n1, n2, container) {
  const oldChildren = n1.children   // 旧的子节点数组
  const newChildren = n2.children   // 新的子节点数组

  // 四个索引指针
  let oldStartIdx = 0
  let oldEndIdx = oldChildren.length - 1
  let newStartIdx = 0
  let newEndIdx = newChildren.length - 1

  // 指针对应的实际虚拟节点
  let oldStartVNode = oldChildren[oldStartIdx]
  let oldEndVNode = oldChildren[oldEndIdx]
  let newStartVNode = newChildren[newStartIdx]
  let newEndVNode = newChildren[newEndIdx]
}

这四个指针分别指向新旧列表的头部和尾部。你可以想象成两队人面对面站着,从两头开始互相认人:

指针含义移动方向
oldStartIdx旧列表最左边→ 向右
oldEndIdx旧列表最右边← 向左
newStartIdx新列表最左边→ 向右
newEndIdx新列表最右边← 向左

后续所有比对逻辑,本质上就是这四个指针不断收缩、往中间夹的过程。

While 循环:双端比对的主战场

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
  // 比对逻辑...
}

循环条件很好理解:只要新旧列表都没比对完,就一直从两头往中间夹。每次成功匹配一组节点,就收缩对应指针,缩小下一轮的比对范围。

接下来看循环内部的五种情况。

情况一:旧头 = 新头(原地更新)

if (oldStartVNode.key === newStartVNode.key) {
  patch(oldStartVNode, newStartVNode, container)
  oldStartVNode = oldChildren[++oldStartIdx]
  newStartVNode = newChildren[++newStartIdx]
}

场景:原来排在第一个的元素,更新后还是第一个。

比如旧列表 [A, B, C],新列表 [A, D, E],A 的位置没变,只需要 patch 更新一下内容,然后两个头指针都往后走一位就行。

情况二:旧尾 = 新尾(原地更新)

else if (oldEndVNode.key === newEndVNode.key) {
  patch(oldEndVNode, newEndVNode, container)
  oldEndVNode = oldChildren[--oldEndIdx]
  newEndVNode = newChildren[--newEndIdx]
}

跟情况一类似,只是方向相反。旧列表最后一个元素在新列表里还是最后一个,位置不用动,两个尾指针都往前缩一位。

情况三:旧头 = 新尾(头部元素挪到末尾)

else if (oldStartVNode.key === newEndVNode.key) {
  patch(oldStartVNode, newEndVNode, container)
  insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling)
  oldStartVNode = oldChildren[++oldStartIdx]
  newEndVNode = newChildren[--newEndIdx]
}

这个就有点意思了。原本排在最前面的元素,现在要跑到最后面

举个具体例子:

旧列表:[A, B, C]
新列表:[B, C, A]

A 从头挪到了尾巴。insert 的第三个参数是 oldEndVNode.el.nextSibling,也就是把 A 插到旧尾元素的后面——等价于放到列表末尾。然后旧头指针右移、新尾指针左移。

情况四:旧尾 = 新头(尾部元素挪到开头)

else if (oldEndVNode.key === newStartVNode.key) {
  patch(oldEndVNode, newStartVNode, container)
  insert(oldEndVNode.el, container, oldStartVNode.el)
  oldEndVNode = oldChildren[--oldEndIdx]
  newStartVNode = newChildren[++newStartIdx]
}

跟情况三反过来:原本在最后面的元素,现在要变成最前面

旧列表:[A, B, C]
新列表:[C, A, B]

C 从尾巴跑到了开头。insert 把 C 的真实 DOM 插到旧头元素前面,直接"置顶"。旧尾指针左移,新头指针右移。

情况五:兜底逻辑(中间查找)

上面四种双端快速匹配全部失败,说明当前新头节点藏在旧列表中间某个位置,只能老老实实遍历查找:

else {
  // 在旧子节点中,根据 key 查找当前新头节点
  const idxInOld = oldChildren.findIndex(
    node => node.key === newStartVNode.key
  )

  if (idxInOld > 0) {
    // 找到了 → 复用旧 DOM
    const vnodeToMove = oldChildren[idxInOld]
    patch(vnodeToMove, newStartVNode, container)
    insert(vnodeToMove.el, container, oldStartVNode.el)
    // 标记已移走,防止重复处理
    oldChildren[idxInOld] = undefined
  } else {
    // 找不到 → 全新节点,直接创建
    patch(null, newStartVNode, container, oldStartVNode.el)
  }
  newStartVNode = newChildren[++newStartIdx]
}

这里有两个分支:

  • idxInOld > 0:在旧列表中间找到了可复用的节点。patch 更新内容,insert 移到头部,然后把旧数组对应位置设为 undefined——这一步很关键,后面循环再遇到这个位置就会跳过,避免重复操作。
  • idxInOld === -1:旧列表里压根没有这个 key,说明是全新节点。patch 的第一个参数传 null,Vue 就知道没有旧节点可复用,直接创建新 DOM 挂载上去。

这里有个容易踩的坑:idxInOld > 0 而不是 idxInOld >= 0。因为下标为 0 的情况其实已经被"旧头 = 新头"覆盖了,如果走到这里 idxInOld 还是 0,说明有问题。

容错处理:跳过已处理的节点

在循环的最开头,有两个容易被忽略的判断:

if (!oldStartVNode) {
  oldStartVNode = oldChildren[++oldStartIdx]
} else if (!oldEndVNode) {
  oldEndVNode = oldChildren[--oldEndIdx]
}

还记得前面兜底逻辑里 oldChildren[idxInOld] = undefined 这行吗?被移走的节点在旧数组里变成了 undefined。当循环继续,指针移到这个位置时,发现节点是空的,就直接跳过、收缩指针。

如果不做这个容错,后面拿 undefined.key 就直接报错了。这个细节面试的时候经常被问到。

循环结束:收尾工作

while 循环跑完后,只可能出现两种情况:

旧节点走完了,新节点还有剩 → 批量新增

if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
  for (let i = newStartIdx; i <= newEndIdx; i++) {
    patch(null, newChildren[i], container, oldStartVNode.el)
  }
}

旧列表全部处理完了,新列表还多出来几个节点。遍历剩余的新节点,patch(null, ...) 批量创建挂载。

举个栗子:旧列表 [A, B],新列表 [A, B, C, D]。A 和 B 在循环里处理完了,C 和 D 就在这里批量创建。

新节点走完了,旧节点还有剩 → 批量删除

else if (newEndIdx < newStartIdx && oldStartIdx <= oldEndIdx) {
  for (let i = oldStartIdx; i <= oldEndIdx; i++) {
    unmount(oldChildren[i])
  }
}

新列表全部处理完了,旧列表还剩几个没用的。遍历剩余的旧节点,调用 unmount 批量卸载。

比如旧列表 [A, B, C, D],新列表 [A, B]。C 和 D 在新列表里不存在了,需要从 DOM 上移除。

记忆口诀:旧走完新剩了 -> 加节点;新走完旧剩了 -> 删节点。

写在最后

说实话,第一次看这段源码的时候我也是一脸懵。但拆开来看,其实逻辑很清晰:

  1. 四个指针从两头往中间夹
  2. 五种匹配按优先级依次尝试
  3. 能复用就复用patch 更新内容,insert 移动位置
  4. 不能复用就新建patch(null, ...) 创建新 DOM
  5. 循环结束后查漏补缺,该加的加、该删的删

核心思想就一个:尽量少操作 DOM。毕竟 DOM 操作是前端性能优化的老生常谈了,Vue 在框架层面已经帮你做了最大程度的优化。

建议有兴趣的同学直接去翻 Vue 源码里的 packages/runtime-core/src/vnode.ts,对照着这篇文章看,印象会更深。有问题的话评论区见~


本文参考文献:《Vue.js 设计与实现》---霍春阳