Diff 算法

文章目录

  • 前言
  • [一、Diff 在更新流程中的位置](#一、Diff 在更新流程中的位置)
  • [二、单节点 Diff(基础)](#二、单节点 Diff(基础))
  • 三、同层比较
    • [3.1 核心思想](#3.1 核心思想)
    • [3.2 为什么这样做](#3.2 为什么这样做)
  • [四、`key` 与列表 Diff](#四、key 与列表 Diff)
  • [五、双端 Diff(Vue 2 思路)](#五、双端 Diff(Vue 2 思路))
    • [5.1 四指针](#5.1 四指针)
    • [5.2 核心循环(示意)](#5.2 核心循环(示意))
  • [六、快速 Diff(Vue 3 思路)](#六、快速 Diff(Vue 3 思路))
    • [6.1 五步流程](#6.1 五步流程)
  • 七、最长递增子序列(LIS)
    • [7.1 是什么](#7.1 是什么)
    • [7.2 在 Diff 里干什么](#7.2 在 Diff 里干什么)
    • [7.3 `getSequence`(Vue 3 同款,O(n log n))](#7.3 getSequence(Vue 3 同款,O(n log n)))
  • [八、Vue 2 / Vue 3 / React 对比(适可而止)](#八、Vue 2 / Vue 3 / React 对比(适可而止))
  • 九、易混淆点归纳
  • 十、思考与练习
  • 总结

前言

第 35 篇讲了虚拟 DOM 与 key ;本篇进入 Diff 算法 ------比较新旧两棵 VNode 树,算出最小 DOM 变更 。面试常问:为何 同层比较 、Vue 2 双端 Diff 与 Vue 3 快速 Diff 有何不同、LIS 用来干什么、Fiber 算不算 Diff 改进。

口诀同层比、列表靠 key、双端四指针、快速靠 LIS。


一、Diff 在更新流程中的位置

复制代码
状态变化 → 生成新 VNode 树 → Diff(本篇)→ patch 真实 DOM

单节点比较(类型 → 属性 → 子节点)与列表比较(key + 双端/快速 Diff)是两层逻辑:先比当前节点,再比 children 数组


二、单节点 Diff(基础)

javascript 复制代码
function patchElement(oldVNode, newVNode) {
  /* 1. 标签/类型不同 → 整节点替换 */
  if (oldVNode.type !== newVNode.type) {
    replace(oldVNode, newVNode);
    return;
  }
  /* 2. 文本节点 → 只改 nodeValue */
  if (typeof newVNode.children === "string") {
    if (oldVNode.children !== newVNode.children) {
      oldVNode.el.textContent = newVNode.children;
    }
    return;
  }
  /* 3. 同类型元素 → 比 props,再比子节点(列表走 key Diff) */
  patchProps(oldVNode, newVNode);
  patchChildren(oldVNode.children, newVNode.children, oldVNode.el);
}

要点:类型不同不做细比,直接替换;列表子节点才进入下文的双端/快速 Diff。


三、同层比较

3.1 核心思想

只比较同一层级 的兄弟节点,尝试把子树整体挪到另一层(跨层移动视为「删旧 + 建新」)。

复制代码
旧树          新树
  A              A
 / \            / \
B   C    →    B   D
             |
             E

同层比:A↔A、B↔B、C↔D(替换)
不会:把 C「挪」到 E 那一层

3.2 为什么这样做

跨层完整树 Diff 同层比较(启发式)
复杂度 经典树编辑距离可达 O(n³) 每层 O(n)
工程取舍 精确但慢 假设跨层移动极少,够用

React / Vue 都采用 同层比较 + key 标识列表节点,这是性能与实现复杂度的平衡,不是数学意义上的最优解。


四、key 与列表 Diff

第 35 篇已强调:列表必须有稳定 key 。Diff 在 children 数组上靠 key 判断「复用 / 移动 / 新增 / 删除」。

场景 无 key / index 作 key 稳定 id 作 key
头部插入 后面项 index 全变,易错复用 只新增一项,其余原位复用
排序 大量无意义 DOM 操作 移动为主

以下双端、快速 Diff 均假设 children 带 key


五、双端 Diff(Vue 2 思路)

5.1 四指针

oldStartoldEndnewStartnewEnd 从两端向中间收缩,每轮依次尝试:

顺序 比较 匹配后
1 头 ↔ 头 两指针后移,patch
2 尾 ↔ 尾 两指针前移,patch
3 头 ↔ 尾 旧头节点移到尾部patch + insert
4 尾 ↔ 头 旧尾节点移到头部patch + insert
5 都不中 在旧列表中 按 key 查找 ;找到则移动,否则挂载新节点
复制代码
头头:old [A, B, ...]  new [A, B, ...]  →  ↑↑ 后移
尾尾:old [..., C, D]  new [..., C, D]  →  ↓↓ 前移
头尾:old [A, ..., D]  new [..., D, A]  →  A 移到末尾
尾头:old [A, ..., D]  new [D, A, ...]  →  D 移到开头

5.2 核心循环(示意)

javascript 复制代码
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
  if (oldStart.key === newStart.key) {
    patch(oldStart, newStart);
    oldStart = oldChildren[++oldStartIdx];
    newStart = newChildren[++newStartIdx];
  } else if (oldEnd.key === newEnd.key) {
    patch(oldEnd, newEnd);
    oldEnd = oldChildren[--oldEndIdx];
    newEnd = newChildren[--newEndIdx];
  } else if (oldStart.key === newEnd.key) {
    patch(oldStart, newEnd);
    insert(oldStart.el, container, oldEnd.el?.nextSibling);
    oldStart = oldChildren[++oldStartIdx];
    newEnd = newChildren[--newEndIdx];
  } else if (oldEnd.key === newStart.key) {
    patch(oldEnd, newStart);
    insert(oldEnd.el, container, oldStart.el);
    oldEnd = oldChildren[--oldEndIdx];
    newStart = newChildren[++newStartIdx];
  } else {
    /* 注意:findIndex 找不到返回 -1,不能用 > 0 */
    const idxInOld = oldChildren.findIndex((n) => n?.key === newStart.key);
    if (idxInOld !== -1) {
      const vnodeToMove = oldChildren[idxInOld];
      patch(vnodeToMove, newStart);
      insert(vnodeToMove.el, container, oldStart.el);
      oldChildren[idxInOld] = undefined;
    } else {
      patch(null, newStart, container, oldStart.el);
    }
    newStart = newChildren[++newStartIdx];
  }
}

/* 循环结束后:一侧有剩余 → 批量挂载或卸载 */

易错点findIndex 返回 0 表示第一个元素,条件必须写 !== -1 ,写成 > 0 会漏掉下标 0。


六、快速 Diff(Vue 3 思路)

借鉴 Inferno,在双端预处理基础上,对中间乱序段LIS 减少 DOM 移动次数。

6.1 五步流程

步骤 做什么
① 头同步 从索引 0 起,key 相同则 patchi++
② 尾同步 从尾部起,key 相同则 patche1--e2--
③ 仅新有 i > e1 → 挂载中间剩余新节点
④ 仅旧有 i > e2 → 卸载中间剩余旧节点
⑤ 乱序段 key → newIndex 表,旧节点匹配后得到 newIndexToOldIndexMapLIS 标出不必移动的项,其余 move
javascript 复制代码
/* ① ② 头尾同步(与双端思想一致,先吃掉确定相等的部分) */
while (i <= e1 && i <= e2 && c1[i].key === c2[i].key) { patch(c1[i], c2[i]); i++; }
while (i <= e1 && i <= e2 && c1[e1].key === c2[e2].key) { patch(c1[e1], c2[e2]); e1--; e2--; }

/* ③ ④ 一侧耗尽 */
if (i > e1) { /* mount 新节点 */ }
else if (i > e2) { /* unmount 旧节点 */ }

/* ⑤ 乱序:Map 匹配 + LIS + 倒序挂载/移动 */
else {
  const keyToNewIndexMap = new Map(/* c2[s2..e2] 的 key → index */);
  const newIndexToOldIndexMap = new Array(toBePatched).fill(0);
  /* 遍历旧节点填 map:0 表示新列表中新增项 */
  const increasingSeq = getSequence(newIndexToOldIndexMap);
  /* 倒序遍历:不在 LIS 中的才 move */
}

与 Vue 2 双端相比:中间段不再四轮比较 ,改为 哈希表 + LIS,移动次数更优。


七、最长递增子序列(LIS)

7.1 是什么

在数组里找最长严格递增的下标序列(不要求连续)。

javascript 复制代码
/* 数值数组示例 */
[10, 9, 2, 5, 3, 7, 101, 18]
/* 其一 LIS:2 → 3 → 7 → 18,长度 4 */

7.2 在 Diff 里干什么

乱序段匹配后,newIndexToOldIndexMap[i] 表示:新列表第 i 个位置 对应旧列表中的旧下标+1(0 表示新增)。

复制代码
旧 key 顺序:A  B  C  D  E
新 key 顺序:A  C  E  B  D

newIndexToOldIndexMap ≈ [1, 3, 5, 2, 4]  (示意)
LIS 下标序列对应「相对顺序已正确」的节点 → 不必 move
其余节点才 insert/move

直觉:LIS 越长,需要移动的 DOM 越少。

7.3 getSequence(Vue 3 同款,O(n log n))

javascript 复制代码
function getSequence(arr) {
  const p = arr.slice();
  const result = [0];

  for (let i = 0; i < arr.length; i++) {
    const arrI = arr[i];
    if (arrI === 0) continue; /* 0 表示新节点,跳过 */

    const last = result[result.length - 1];
    if (arr[last] < arrI) {
      p[i] = last;
      result.push(i);
      continue;
    }

    /* 二分找第一个 >= arrI 的位置并替换 */
    let u = 0, v = result.length - 1;
    while (u < v) {
      const 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;
    }
  }

  /* 回溯得到真实 LIS 下标 */
  let u = result.length;
  let v = result[u - 1];
  while (u-- > 0) {
    result[u] = v;
    v = p[v];
  }
  return result;
}

面试能说清用途即可 ,不必默写全文;知道 O(n log n) 与「减少 move」就够。


八、Vue 2 / Vue 3 / React 对比(适可而止)

Vue 2 Vue 3 React(协调器)
列表策略 双端四指针 快速 Diff + LIS 单端从左到右
预处理 边比边移 头尾同步 + Map lastPlacedIndex 标记
编译优化 --- PatchFlag、Block Tree 缩小 Diff 范围 ---

React Fiber :主要改的是 调度 (可中断、分片、优先级),不是 换了一套列表 Diff 公式。Diff 仍是同层 + key;Fiber 让大量 Diff/patch 不长时间阻塞主线程。口述时别把「Fiber Diff」和「Vue 快速 Diff」混成同一类算法。


九、易混淆点归纳

  1. 同层比较 是工程启发式,把每层降到 O(n),不是证明意义上的全局最优。
  2. 双端 Diff 优化比较轮次快速 Diff 优化乱序段的移动次数(LIS)。
  3. LIS 标的是「不用动」 ,不在序列里的才 move
  4. findIndex > 0 是错的 ,应为 !== -1
  5. index 当 key 在增删排序时会导致错误复用(第 35 篇)。
  6. Fiber ≠ 新 Diff 公式 ,是 渲染调度架构

十、思考与练习

1. 为什么采用同层比较?

解析:完整树编辑代价高(可达 O(n³) );UI 里跨层挪动少,同层 + key 在工程中足够快且实现清晰。

2. 双端 Diff 四种命中方式是什么?

解析:头头、尾尾、头尾、尾头 ;都不中则 key 查找新建

3. Vue 3 快速 Diff 比 Vue 2 多做了什么?

解析:头尾同步后,乱序段用 key → index Map + newIndexToOldIndexMap + LIS 决定谁需要 move

4. LIS 在 Diff 里的输入输出各是什么?

解析:输入是乱序段的 newIndexToOldIndexMap ;输出是不必移动 的新下标序列;其余倒序 move

5. 单节点 Diff 与列表 Diff 先后关系?

解析:先比 type (不同则替换),再 props ,最后 children;children 是数组且带 key 时走双端/快速 Diff。

6. Fiber 改进了 Diff 吗?

解析:没有换核心比较策略 ;改进的是 可中断调度,让大更新不长时间卡死主线程。


总结

  • 同层比较:每层 O(n),不跨层挪子树。
  • 列表靠 key:稳定标识复用与移动,忌用 index。
  • Vue 2 双端 :四指针 + 中间 key 查找;注意 findIndex !== -1
  • Vue 3 快速 :头尾同步 → Map 匹配 → LIS 减移动。
  • React Fiber:调度层能力,别当成「第三种列表 Diff 公式」背。
相关推荐
wgc2k1 小时前
Nest.js 基础-8-Hello,NestJS
开发语言·javascript·ecmascript
Larcher1 小时前
从 0 到 1:用 Bun + axios 快速搭建 LLM API 客户端
前端·javascript
子午1 小时前
基于DeepSeek的酒店客房管理系统~Python+DeepSeek智能问答+Vue3+Web网站系统
开发语言·前端·python
bkspiderx1 小时前
Boa Web服务器HTTPS支持的源码改造方案
服务器·前端·https·web服务器·boa·https支持
贺今宵1 小时前
Vue 3 + Capacitor 使用jeep-sqlite,web端使用本地sqlite数据库
前端·数据库·vue.js·sqlite·web
taocarts_bidfans1 小时前
Google Indexing API 外贸独立站主动推送收录实战开发
前端·独立站·外贸独立站·taoify
lichenyang4532 小时前
鸿蒙 Stage 模型到底是什么?一篇讲清 Ability、EntryAbility 和入口文件为什么这么设计
前端
JSMSEMI112 小时前
JSM12N60C 600V N沟道增强型功率MOSFET
开发语言·javascript·ecmascript
ihuyigui2 小时前
国际商超零售短信接口
大数据·前端·后端·架构·零售