Diff算法深度剖析 - Vue.js的DOM更新魔法
列表顺序一变,Vue 怎么知道该移动哪个节点、哪些原地不动。四个指针从两端往中间收,不命中才查 key 映射表;Vue 3 换成先削两端、再对中间求最长递增子序列。
全删重建丢的不是性能,是状态
一个一千项的列表,数据变了怎么更新 DOM?最省事的做法是全部删掉重建。 性能开销先不论 —— 真正致命的是状态挂在 DOM 节点身上:输入框里没提交的文字、 当前焦点在哪、列表滚到了第几屏,节点一没,这些全都找不回来了。
所以 diff 的输出从来不是一个新界面,是一份操作清单:谁要移动、谁要删除、谁原地不动。 它靠三条原则把这份清单算出来,而这三条同时决定了它做不到什么:
- 只比同层。 跨层级的移动不会被识别成移动 —— 一个节点从
ul挪到另一个ul, 在 diff 眼里是「这边删掉、那边新建」。 - 标签不同就不再往下比。
<div>换成<span>,整棵子树直接重建,不管里面长得多像。 - key 是身份证。 有 key 才能在乱序里认出「还是那个节点」,否则只能按位置猜。
四个指针从两端往中间收,碰头就结束
Vue 2 的做法是同时从新旧两个列表的头尾各起一个指针,每一轮试四种配对,命中一种就前进一步。四种都不命中,才退回去查 key 映射表。
这个过程用文字讲总是隔一层。上面那段 21 秒的动画里,四个指针怎么走、哪一次命中、D 的 DOM 在哪一刻被移到最前,都能看见。
// 四个指针:旧列表的头尾、新列表的头尾。它们向中间收,碰头就结束。// 真实源码里它是 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 只能按位置复用,输入框里的内容会跟着位置走,而不是跟着数据走。
key 映射表就是核心循环里的情况 5 —— createKeyToOldIdx 把旧列表扫一遍建成 key → 下标,之后每个新节点一次哈希查找就能找到它的旧身。这张表只在第一次落到情况 5 时建,整轮 diff 复用同一份。
Vue 3 换了一条路:先削两端,再对中间求最长递增子序列
双端比较是「命中就移动」,一次决定一个节点。Vue 3 反过来:先算出最多能有多少个节点不用动, 再只移动剩下的。那个「最多不用动的集合」就是最长递增子序列 —— 旧下标按新顺序排好之后, 递增的那一段说明它们的相对位置本来就是对的,不必碰。
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 换了一条路:先从两端做预处理,把头尾相同的部分削掉,
再对中间乱序的一段求最长递增子序列 —— 留在序列里的节点不动,其余的才移动。
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 机制深度解析(上篇)。