Diff算法深度剖析 - Vue.js的DOM更新魔法

列表顺序一变,Vue 怎么知道该移动哪个节点、哪些原地不动。四个指针从两端往中间收,不命中才查 key 映射表;Vue 3 换成先削两端、再对中间求最长递增子序列。

位置
第 07 篇 / 共 12 篇
预计
9 分钟

全删重建丢的不是性能,是状态

一个一千项的列表,数据变了怎么更新 DOM?最省事的做法是全部删掉重建。 性能开销先不论 —— 真正致命的是状态挂在 DOM 节点身上:输入框里没提交的文字、 当前焦点在哪、列表滚到了第几屏,节点一没,这些全都找不回来了。

所以 diff 的输出从来不是一个新界面,是一份操作清单:谁要移动、谁要删除、谁原地不动。 它靠三条原则把这份清单算出来,而这三条同时决定了它做不到什么:

  • 只比同层。 跨层级的移动不会被识别成移动 —— 一个节点从 ul 挪到另一个 ul, 在 diff 眼里是「这边删掉、那边新建」。
  • 标签不同就不再往下比。 <div> 换成 <span>,整棵子树直接重建,不管里面长得多像。
  • key 是身份证。 有 key 才能在乱序里认出「还是那个节点」,否则只能按位置猜。

四个指针从两端往中间收,碰头就结束

Vue 2 的做法是同时从新旧两个列表的头尾各起一个指针,每一轮试四种配对,命中一种就前进一步。四种都不命中,才退回去查 key 映射表。

双端比较的一次完整更新:旧列表 A B C D 变成 D A B C。前三次比较(头头、尾尾、头尾)都不是同一个节点,第四次「尾头」命中,D 的真实 DOM 被移到最前面;之后连续三次「头头相同」,A、B、C 原地复用。全程一次移动,没有新建或删除。

这个过程用文字讲总是隔一层。上面那段 21 秒的动画里,四个指针怎么走、哪一次命中、D 的 DOM 在哪一刻被移到最前,都能看见。

Vue 2 · updateChildren(简化)
// 四个指针:旧列表的头尾、新列表的头尾。它们向中间收,碰头就结束。
// 真实源码里它是 createPatchFunction 闭包中的一个函数,不属于任何类。
function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0;
9 collapsed lines
let newStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
let newEndIdx = newCh.length - 1;
let oldStartVnode = oldCh[0];
let oldEndVnode = oldCh[oldEndIdx];
let newStartVnode = newCh[0];
let newEndVnode = newCh[newEndIdx];
let oldKeyToIdx, idxInOld, vnodeToMove;
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 省略:跳过被置空的旧节点 —— 情况 5 会把用过的槽位标成 undefined
if (sameVnode(oldStartVnode, newStartVnode)) {
// ① 头头相同:原地更新,两个 start 指针右移
patchVnode(oldStartVnode, newStartVnode);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// ② 尾尾相同:原地更新,两个 end 指针左移
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// ③ 头尾相同:这个节点跑到末尾去了,把它的 DOM 移过去
patchVnode(oldStartVnode, newEndVnode);
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// ④ 尾头相同:这个节点跑到开头去了
patchVnode(oldEndVnode, newStartVnode);
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// ⑤ 四种都不命中:建一次 key → 旧索引的映射表,直接查
if (!oldKeyToIdx) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
idxInOld = oldKeyToIdx[newStartVnode.key];
if (idxInOld === undefined) {
createElm(newStartVnode, parentElm, oldStartVnode.elm);
} else {
vnodeToMove = oldCh[idxInOld];
patchVnode(vnodeToMove, newStartVnode);
oldCh[idxInOld] = undefined; // 标记这个槽位已经用掉
parentElm.insertBefore(vnodeToMove.elm, oldStartVnode.elm);
}
newStartVnode = newCh[++newStartIdx];
}
}
// 一侧先走完:剩下的要么全是新增,要么全是删除
if (oldStartIdx > oldEndIdx) {
const refElm = newCh[newEndIdx + 1] ? newCh[newEndIdx + 1].elm : null;
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx);
} else if (newStartIdx > newEndIdx) {
removeVnodes(oldCh, oldStartIdx, oldEndIdx);
}
}
18 collapsed lines
// key → 旧列表下标。只在情况 5 第一次发生时建一次,之后复用。
function createKeyToOldIdx(children, beginIdx, endIdx) {
const map = {};
for (let i = beginIdx; i <= endIdx; i++) {
const key = children[i].key;
if (key !== undefined) map[key] = i;
}
return map;
}
// 「同一个节点」的判据:key 相同,且标签、注释、data 有无、input type 都一致
function sameVnode(a, b) {
return a.key === b.key
&& a.tag === b.tag
&& a.isComment === b.isComment
&& isDef(a.data) === isDef(b.data)
&& sameInputType(a, b);
}

四种配对里,头头和尾尾是最常见的:列表尾部追加一项、开头插入一项,都会让其中一端连续命中, 一路走到底。头尾和尾头这两种存在的理由是「整体挪位」 —— 列表被反转、或者最后一项被拖到最前面, 如果只比头头和尾尾,每一步都不命中,整个列表会被当成全新的重建一遍;有了交叉比较, 这两种形态各自一次命中就能解决。

四种都不命中才落到情况 5:建一张 key → 旧下标 的映射表,拿新节点的 key 直接查。 注意这张表是懒建的 —— 只在第一次真正需要时建一次,之后整轮 diff 复用。 用过的旧槽位会被置成 undefined,所以循环开头要跳过空槽,否则同一个节点会被复用两次。

一侧先走完,剩下的就不用比了:旧列表先空说明剩下的全是新增,新列表先空说明剩下的全是删除。

key 决定 Vue 认不认得出同一个节点

没有 key 时 Vue 按位置复用,节点 A 的 DOM 被错误地复用给了节点 C 没有 key 时 Vue 按位置复用,节点 A 的 DOM 被错误地复用给了节点 C

上面那张图说的就是这件事:没有 key 时 Vue 只能按位置复用,输入框里的内容会跟着位置走,而不是跟着数据走。

key 映射表就是核心循环里的情况 5 —— createKeyToOldIdx 把旧列表扫一遍建成 key → 下标,之后每个新节点一次哈希查找就能找到它的旧身。这张表只在第一次落到情况 5 时建,整轮 diff 复用同一份。

Vue 3 换了一条路:先削两端,再对中间求最长递增子序列

双端比较是「命中就移动」,一次决定一个节点。Vue 3 反过来:先算出最多能有多少个节点不用动, 再只移动剩下的。那个「最多不用动的集合」就是最长递增子序列 —— 旧下标按新顺序排好之后, 递增的那一段说明它们的相对位置本来就是对的,不必碰。

Vue 3 · getSequence(求最长递增子序列)
getSequence(arr) {
const p = arr.slice(); // 前驱索引数组
const result = [0]; // 结果数组,存储索引
let i, j, u, v, c;
const len = arr.length;
for (i = 0; i < len; i++) {
const arrI = arr[i];
if (arrI !== 0) {
j = result[result.length - 1];
if (arr[j] < arrI) {
// 当前值大于结果数组最后一个值,直接追加
p[i] = j;
result.push(i);
continue;
}
// 二分查找,找到第一个大于arrI的位置
u = 0;
v = result.length - 1;
while (u < v) {
c = (u + v) >> 1; // 位运算,相当于Math.floor((u + v) / 2)
if (arr[result[c]] < arrI) {
u = c + 1;
} else {
v = c;
}
}
// 如果找到的值大于arrI,则替换
if (arrI < arr[result[u]]) {
if (u > 0) {
p[i] = result[u - 1];
}
result[u] = i;
}
}
}
13 collapsed lines
// 回溯,生成正确的LIS
u = result.length;
v = result[u - 1];
while (u-- > 0) {
result[u] = v;
v = p[v];
}
return result;
}
// 演示LIS在Diff中的应用

Vue 3 的 patchKeyedChildren 换了一条路:先从两端做预处理,把头尾相同的部分削掉, 再对中间乱序的一段求最长递增子序列 —— 留在序列里的节点不动,其余的才移动。

Vue 3 · patchKeyedChildren(骨架)
function patchKeyedChildren(c1, c2, container) {
let i = 0;
let e1 = c1.length - 1;
let e2 = c2.length - 1;
// 1. 从头开始,相同的先 patch 掉
while (i <= e1 && i <= e2 && isSameVNodeType(c1[i], c2[i])) {
patch(c1[i], c2[i], container);
i++;
}
9 collapsed lines
// 2. 从尾开始,同理
while (i <= e1 && i <= e2 && isSameVNodeType(c1[e1], c2[e2])) {
patch(c1[e1], c2[e2], container);
e1--;
e2--;
}
// 3. 中间剩下的乱序段:建 key 映射,求最长递增子序列,
// 序列里的节点保持不动,其余的按新顺序插入。
const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap);
}

getSequence 分两步。第一步贪心:维护一个递增的结果数组,新元素比末尾大就追加, 否则二分查找找到第一个不小于它的位置替换掉 —— 这一步保证了结果数组的长度是对的, 但里面存的下标可能已经不是一条真实的子序列了。所以有第二步:p 数组一路记着「我被追加进去时, 前一个是谁」,最后从末尾沿着 p 回溯一遍,把真正的那条序列还原出来。

二分查找是 O(n log n) 的来源。它比双端比较的 O(n) 看着差,但两者比的不是同一件事: 双端比较对每个节点做常数次判断然后立刻决定,LIS 要先看完整段才知道谁不用动。 代价换的是移动次数 —— 乱序段越长,能保住不动的节点越多,省下的真实 DOM 操作越多。

和 Vue 2 的区别在最后一步:双端比较是「命中就移动」,逐次决定; LIS 是先算出最多能有多少个节点不用动,再只移动剩下的。前者简单,后者在乱序列表上移动次数更少。

复杂度从 O(n³) 压到 O(n),靠的是放弃求最优解

复杂度上,双端比较把最坏情况从 O(n³) 压到 O(n):每个节点最多被两端各访问一次,命中就前进,不命中才落到 key 映射表,而映射表本身只建一次。真实数字取决于列表长度和变更形态,得在自己的页面上量 —— 别信任何一篇文章里的绝对值,包括这篇。

写代码时,只有三件事真正影响 diff 的开销

落到写代码上,只有三件事真正影响 diff 的开销:

  • key 用数据本身的稳定 id,不要用数组下标 —— 下标会随插入删除整体平移,等于告诉 Vue「每一项都变了」。
  • 列表尽量从两端增删。双端比较对头尾的改动一次命中就走完,中间插入则会落到情况 5,走一遍 key 映射表。
  • 把长列表拆成组件。diff 在组件边界处停下:组件自己的 props 没变,整棵子树就不用比。

反过来说,什么时候这些都不管用:列表每一项的内容本身都变了。这时候 key 再稳定也没用 —— 节点是认出来了,但每个节点的属性和文本都要更新,diff 省下的只是创建和销毁, 省不掉属性比对。这也是为什么「加了 key 性能就会变好」这个说法是错的: key 让 Vue 认得出节点,不让节点变少。

这篇是 Vue.js 内部机制深度解析的第 7 篇。前一篇是 Vue.js 模板编译全流程详解,后一篇是 Vue.js 异步更新与 nextTick 机制深度解析(上篇)