文章目录
- 前言
- [一、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 四指针
oldStart、oldEnd、newStart、newEnd 从两端向中间收缩,每轮依次尝试:
| 顺序 | 比较 | 匹配后 |
|---|---|---|
| 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 相同则 patch 并 i++ |
| ② 尾同步 | 从尾部起,key 相同则 patch 并 e1--、e2-- |
| ③ 仅新有 | i > e1 → 挂载中间剩余新节点 |
| ④ 仅旧有 | i > e2 → 卸载中间剩余旧节点 |
| ⑤ 乱序段 | 建 key → newIndex 表,旧节点匹配后得到 newIndexToOldIndexMap,LIS 标出不必移动的项,其余 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」混成同一类算法。
九、易混淆点归纳
- 同层比较 是工程启发式,把每层降到 O(n),不是证明意义上的全局最优。
- 双端 Diff 优化比较轮次 ;快速 Diff 优化乱序段的移动次数(LIS)。
- LIS 标的是「不用动」 ,不在序列里的才
move。 findIndex > 0是错的 ,应为!== -1。- index 当 key 在增删排序时会导致错误复用(第 35 篇)。
- 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 公式」背。