前置知识: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 上移除。
记忆口诀:旧走完新剩了 -> 加节点;新走完旧剩了 -> 删节点。
写在最后
说实话,第一次看这段源码的时候我也是一脸懵。但拆开来看,其实逻辑很清晰:
- 四个指针从两头往中间夹
- 五种匹配按优先级依次尝试
- 能复用就复用,
patch更新内容,insert移动位置 - 不能复用就新建,
patch(null, ...)创建新 DOM - 循环结束后查漏补缺,该加的加、该删的删
核心思想就一个:尽量少操作 DOM。毕竟 DOM 操作是前端性能优化的老生常谈了,Vue 在框架层面已经帮你做了最大程度的优化。
建议有兴趣的同学直接去翻 Vue 源码里的 packages/runtime-core/src/vnode.ts,对照着这篇文章看,印象会更深。有问题的话评论区见~
本文参考文献:《Vue.js 设计与实现》---霍春阳