diff 算法(虚拟 DOM Reconciliation)

一、技术难点:为什么"算最小改动"这么难

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 都建立在同一套假设上:

  1. 只做同层比较,不跨层级移动节点(跨层一律视为"删除 + 重建");
  2. 节点类型(tag)不同直接丢弃整棵子树重建,不做深层对比;
  3. 用 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 即可)。

相关推荐
前端snow2 小时前
ai agent --- 文件存储
前端
Frag0ut2 小时前
Chrome四大版本获取及共存指南:Stable/Beta/Dev/Canary
前端·chrome·浏览器·dev·beta·canary·共存版
IT_陈寒3 小时前
SpringBoot自动配置坑了我一把,原来是这样绕过去的
前端·人工智能·后端
广州华水科技3 小时前
单北斗GNSS变形监测在大坝安全监测中的应用与优势
前端
zeng不错4 小时前
B站 173 个投稿活动看到眼瞎?我写了个扩展,主打一个精准打击
前端
Latchh4 小时前
前端导出的JPEG红字发糊,质量要拉到100才清楚
前端·图像处理·人工智能·计算机视觉
默_笙4 小时前
🚦 请三个 AI 考官给 RAG 打分:量化评估实战(下)
前端·javascript
Joy T4 小时前
聊天系统中五大ID的演进与实战解析
java·前端·人工智能·spring·agent入门
Seraphina366 小时前
DVWA(SQL注入-low,medium,XSS反射-low)
前端·数据库·笔记·sql·网络安全·web·xss