一、技术难点:为什么"算最小改动"这么难
Diff 要解决的问题可以一句话说清:给定新旧两棵节点树,找出把旧树变成新树所需要的最少操作序列(增、删、移)。难就难在这个"最少"上。
难点一:理论最优解,浏览器跑不动
把两棵树的差异化归为经典的树编辑距离问题(Tree Edit Distance),学界有成熟的 Zhang-Shasha 算法,但它需要比较任意两棵子树,复杂度 O(n³)(三棵树节点规模的乘积级)。一个 1000 节点的列表,意味着十亿级比较。在 UI 里,diff 必须在 16ms 的一帧内完成,O(n³) 从第一秒就被判死刑。
所以工业界的 diff 全部是用精度换速度的启发式算法,牺牲"全局最优",换到 O(n)。
难点二:key 就是身份,可太多人拿 index 当 key
虚拟 DOM 是"数据的投影"。要让 diff 知道"新树里的这个节点,就是旧树里那个节点",需要一个稳定且唯一的标识------这就是 key。
问题出在:很多人图省事写 :key="index"。索引不是身份,它只是"位置"。一旦列表发生头部插入、中间删除、排序,同一个 index 指向的元素完全换了个对象。diff 按 index 认为"位置没变、复用",于是把旧元素的 DOM 和状态(输入框内容、动画、组件内部 state)错误地贴到了新数据上------这就是"用 index 做 key,删除中间项后输入框串位"的经典 bug。
难点三:单端 diff 对"头部插入"天生低效
最朴素的 list diff 是单端比较:两个指针都从左边出发,逐个比 key,相同就复用,直到不同就停下来,把新列表剩下的整体插入。React 的协调器就是一次从左到右的扫描。
这个策略处理"尾部追加"很高效,但它对顺序整体位移(把最后一项挪到最前、或者整个列表反转)几乎无力:所有节点在它眼里都是"位置变了",只能一个个移动。
难点四:能复用,但怎么保证"移动次数最少"
就算借助 key 把复用关系都算清楚了,还有一个更硬的子问题:假设新旧两个列表里有 5 个节点可以复用,它们在新列表里顺序被打乱了,到底谁该移动、谁该原地不动,才能让 move 操作最少?
这个问题的答案不在"能不能复用",而在"哪些复用的节点相对顺序本来就没变"。把它想清楚,才真正理解了 diff。
二、完整解法:Vue3 的 keyed diff 与最长递增子序列
2.1 三个启发式假设,把 O(n³) 压到 O(n)
React 和 Vue 都建立在同一套假设上:
- 只做同层比较,不跨层级移动节点(跨层一律视为"删除 + 重建");
- 节点类型(tag)不同直接丢弃整棵子树重建,不做深层对比;
- 用 key 标识同一节点,key 相同且类型相同才复用。
三条假设的共同作用是:把"任意两棵子树的匹配"砍成"同一层内两个列表的匹配",问题立刻降成一维线性问题。
2.2 把 key 用对,是 diff 高效的前提
| 写法 | 结果 |
|---|---|
:key="item.id" |
稳定身份,插入/删除/排序都能正确复用,DOM 与状态不串位 |
:key="index" |
位置当身份,重排时复用错位,状态串位、动画错乱 |
| 完全不写 key | 退化成"按下标隐式比较",等价于 index |
判断标准一句话:这个 key 能不能在整个列表生命周期里稳定地指向同一个数据实体。能,才写;不能,宁可换一个稳定字段。
2.3 手写 Vue3 patchKeyedChildren:五步走完一次列表更新
Vue3 的 keyed diff 分五步,前四步是"廉价快路径",第五步才是真正的算法核心。下面这版可以用 Node 直接跑,输出与 Vue 的真实行为一致:
javascript
// mini-diff.mjs ------ Vue3 patchKeyedChildren 的最小可运行实现
// 用一个极简容器模拟真实 DOM 的 insertBefore / removeChild 语义
class El {
constructor(tag, key, label) {
this.tag = tag; this.key = key; this.label = label; this.children = [];
}
insertBefore(node, anchor) {
const i = this.children.indexOf(node);
if (i > -1) this.children.splice(i, 1); // 已在树中 => 这是一次 move
if (anchor == null) { this.children.push(node); return; }
const ai = this.children.indexOf(anchor);
this.children.splice(ai === -1 ? this.children.length : ai, 0, node);
}
removeChild(node) {
const i = this.children.indexOf(node);
if (i > -1) this.children.splice(i, 1);
}
snapshot() { return this.children.map(c => c.label); }
}
const ops = [];
function h(label, key = label) { return { key, label, el: new El('li', key, label) }; }
function patchChildren(container, c1, c2) {
const l2 = c2.length;
let i = 0, e1 = c1.length - 1, e2 = l2 - 1;
// 1) 从头同步:前缀相同,原地复用
while (i <= e1 && i <= e2 && c1[i].key === c2[i].key) {
ops.push(`patch(head) ${c2[i].label}`); c2[i].el = c1[i].el; i++;
}
// 2) 从尾同步:后缀相同,原地复用
while (i <= e1 && i <= e2 && c1[e1].key === c2[e2].key) {
ops.push(`patch(tail) ${c2[e2].label}`); c2[e2].el = c1[e1].el; e1--; e2--;
}
if (i > e1) {
// 3) 旧序列先耗尽 => 剩下全是新增,锚点取下一个已存在的节点
const anchor = e2 + 1 < l2 ? c2[e2 + 1].el : null;
for (let j = i; j <= e2; j++) { ops.push(`mount ${c2[j].label}`); container.insertBefore(c2[j].el, anchor); }
} else if (i > e2) {
// 4) 新序列先耗尽 => 旧序列剩下的全部删除
for (let j = i; j <= e1; j++) { ops.push(`unmount ${c1[j].label}`); container.removeChild(c1[j].el); }
} else {
// 5) 未知序列:靠 key 映射 + 最长递增子序列求最少移动
const s1 = i, s2 = i;
const keyToNewIndex = new Map();
for (let j = s2; j <= e2; j++) keyToNewIndex.set(c2[j].key, j);
const toBePatched = e2 - s2 + 1;
const newIndexToOldIndex = new Array(toBePatched).fill(0); // 0 = 新增
let moved = false, maxIndexSoFar = 0;
// 5.1 遍历旧序列:能复用的打标记,复用不到的删除
for (let j = s1; j <= e1; j++) {
const newIndex = keyToNewIndex.get(c1[j].key);
if (newIndex === undefined) {
ops.push(`unmount ${c1[j].label}`); container.removeChild(c1[j].el);
} else {
newIndexToOldIndex[newIndex - s2] = j + 1; // +1 让 0 空出来表示"新增"
if (newIndex >= maxIndexSoFar) maxIndexSoFar = newIndex; else moved = true;
ops.push(`patch ${c2[newIndex].label}`); c2[newIndex].el = c1[j].el;
}
}
// 5.2 LIS:相对顺序不变的最大集合,就是"不必移动"的节点
const seq = moved ? getSequence(newIndexToOldIndex) : [];
let k = seq.length - 1;
// 5.3 从后往前处理,保证 anchor 一定已经就位
for (let j = toBePatched - 1; j >= 0; j--) {
const nextIndex = s2 + j;
const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : null;
if (newIndexToOldIndex[j] === 0) {
ops.push(`mount ${c2[nextIndex].label}`); container.insertBefore(c2[nextIndex].el, anchor);
} else if (moved) {
if (k < 0 || j !== seq[k]) { ops.push(`move ${c2[nextIndex].label}`); container.insertBefore(c2[nextIndex].el, anchor); }
else k--;
}
}
}
}
// 求最长递增子序列(返回下标),0 视为"新增"跳过。O(n log n) 的贪心 + 二分 + 回溯
function getSequence(arr) {
const p = arr.slice(); const result = [0];
let i, j, u, v, c;
for (i = 0; i < arr.length; i++) {
const arrI = arr[i];
if (arrI !== 0) {
j = result[result.length - 1];
if (arr[j] < arrI) { p[i] = j; result.push(i); continue; }
u = 0; v = result.length - 1;
while (u < v) { c = (u + v) >> 1; if (arr[result[c]] < arrI) u = c + 1; else v = c; }
if (arrI < arr[result[u]]) { if (u > 0) p[i] = result[u - 1]; result[u] = i; }
}
}
u = result.length; v = result[u - 1];
while (u-- > 0) { result[u] = v; v = p[v]; }
return result;
}
// ---------------- 验证 ----------------
const container = new El('ul', 'root', 'root');
const c1 = ['a', 'b', 'c', 'd', 'e', 'f', 'g'].map(k => h(k.toUpperCase(), k));
container.children = c1.map(n => n.el);
const c2 = ['a', 'b', 'e', 'c', 'd', 'h', 'f', 'g'].map(k => h(k.toUpperCase(), k));
patchChildren(container, c1, c2);
console.log('操作序列:'); ops.forEach((o, n) => console.log(` ${n + 1}. ${o}`));
console.log('结果:', container.snapshot().join(','));
console.log('期望:', c2.map(n => n.label).join(','));
运行结果:
markdown
操作序列:
1. patch(head) A
2. patch(head) B
3. patch(tail) G
4. patch(tail) F
5. patch C
6. patch D
7. patch E
8. mount H
9. move E
结果: A,B,E,C,D,H,F,G
期望: A,B,E,C,D,H,F,G
从 [a,b,c,d,e,f,g] 变成 [a,b,e,c,d,h,f,g],算法只做了 1 次 mount(挂 H)+ 1 次 move(挪 E),其余节点原地复用。这就是"最小改动"。
2.4 LIS 为什么等于"最少移动次数"
看 5.1 里填出来的 newIndexToOldIndex 数组:它按新列表的顺序 ,记录了每个复用节点在旧列表里的下标。这个数组的物理含义是"新顺序下,各节点旧位置的排列"。
关键洞察:如果这个数组是单调递增的,说明这些节点在新旧列表里的先后顺序完全一致,一个都不用动。 只有当出现"后面的节点旧位置比前面的小"(逆序)时,才说明顺序被打乱,需要移动。
于是"要移动哪些节点"就转化成"要删掉数组中哪些元素,才能让剩下的是递增序列"。要让移动次数最少,就要保留尽可能长的递增子序列------这正是 LIS(Longest Increasing Subsequence)问题。结论:
最少移动次数 = 可复用节点数 - LIS 长度
LIS 用"贪心 + 二分 + 回溯"可以在 O(n log n) 内求出(见 getSequence),比朴素的 O(n²) 动态规划更适合大列表。
2.5 React 单端 vs Vue3 两端:一次反转列表的对照
用同一个案例对比两种策略的实际操作次数,列表 [a,b,c,d,e] 旋转为 [e,a,b,c,d](把尾元素挪到头部):
| 策略 | 做法 | move 次数 |
|---|---|---|
| React 单端扫描 | 记 lastPlacedIndex,e 的旧下标 4 最大,其后 a/b/c/d 旧下标都小于 4,全部判定为"需移动" |
4 |
| Vue3 两端 + LIS | 头尾先对齐,中段算 LIS 得 [a,b,c,d](旧下标 0,1,2,3 递增),只有 e 需要移动 |
1 |
结论不是"React 差",而是:单端策略对"尾部追加"极快,但对顺序位移天然吃亏;两端 + LIS 用一点额外计算换来了移动次数的最小化。 理解这一点,你在长列表拖拽、表格排序这类场景里,就知道该给列表加稳定 key,也知道框架背后大概在做多少次 DOM 操作。
顺带一个可运行的验证:头部插入 [a,b,c,d] → [x,a,b,c,d],两端算法从尾部一次对齐四个节点,最后只 mount X 一次;这正是它优于单端策略的直观例子。
三、应用场景
- 长列表 / 表格拖拽排序:key 稳定 + LIS 让"移动一行"只产生一次 DOM insert,而不是整表重排;这是拖拽流畅度的底层保障;
<TransitionGroup>动画:移动时的 FLIP 动画依赖 diff 精确标记出"哪些节点发生了 move",LIS 给出的正是这份最少移动清单;- 虚拟滚动:可视窗口滚动时首尾节点频繁增删,两端同步 + 新增/删除快路径能让每帧的 diff 稳定在 O(可视区) 量级;
- 性能排查:列表重渲染卡顿、输入框串位、动画错乱,第一件事就是查 key 是不是用了 index。
四、总结
虚拟 DOM diff 的本质,是用启发式假设把 O(n³) 的树编辑距离降成 O(n) 的线性列表匹配 :只比同层、类型不同即重建、靠稳定 key 认身份。Vue3 的 keyed diff 用"头部同步 → 尾部同步 → 仅新增 → 仅删除 → 未知序列"五步把所有廉价情况先摘出去,只在中段留下真正的算法核心:用旧下标数组 + 最长递增子序列,算出保留相对顺序不变的最大节点集合,从而把移动次数压到最少(可复用节点数 − LIS 长度)。
三个吃透的标志:知道 key 是"身份"而非"位置"、知道 LIS 为何等价于最少移动、知道两端策略相对单端在顺序位移场景赢在哪。下一篇 Day22 从 diff 走向组件库,看一套可复用的组件体系如何在 API 设计、样式隔离与无障碍之间做权衡。
运行环境
Node.js ≥ 16(示例不依赖任何第三方库,直接 node mini-diff.mjs 即可)。