2082 字
10 分钟
diff
2026-02-19
2026-02-19
统计加载中...

diff#

一、背景与目标#

在虚拟 DOM diff 算法中,处理有 key 的子节点时,如何高效地移动、插入和删除 DOM 元素,是提升性能的关键。本节代码实现了 Vue3 快速 Diff 算法的核心部分,主要解决:

  • 如何识别哪些节点需要移动
  • 如何高效地移动节点
  • 如何插入/删除节点

设计动机: 传统的 diff 算法复杂度高,性能低。Vue3 通过分段对比、key 索引、最长递增子序列等优化手段,极大提升了性能,减少了 DOM 操作次数。


二、整体流程概览#

主要分为以下几步,每一步的设计目的如下:

  1. 处理前置相同节点:快速跳过头部无需变化的节点,减少后续 diff 范围。
  2. 处理后置相同节点:快速跳过尾部无需变化的节点,进一步缩小 diff 范围。
  3. 处理新增/删除节点:识别只剩新节点或只剩旧节点的情况,直接插入或卸载。
  4. 处理需要移动的节点:对中间乱序部分,精准判断哪些节点需要 patch、插入、卸载或移动。

三、详细步骤与代码说明#

1. 处理前置相同节点#

原理与目的

  • 从头开始,依次比较新旧节点的 key,遇到不同为止。
  • 这样可以快速跳过两端未变化的部分,提升 diff 性能。

关键变量说明

  • j:头部指针,指向当前比较的节点索引。
  • oldVNodenewVNode:当前比较的旧/新节点。
let j = 0;
let oldVNode = oldChildren[j];
let newVNode = newChildren[j];
while (oldVNode.key === newVNode.key) {
patch(oldVNode, newVNode, container); // 复用和更新节点
j++;
oldVNode = oldChildren[j];
newVNode = newChildren[j];
}

2. 处理后置相同节点#

原理与目的

  • 从尾部开始,依次比较新旧节点的 key,遇到不同为止。
  • 跳过尾部未变化的部分,进一步缩小 diff 范围。

关键变量说明

  • oldEndnewEnd:分别指向旧/新子节点的末尾索引。
let oldEnd = oldChildren.length - 1;
let newEnd = newChildren.length - 1;
oldVNode = oldChildren[oldEnd];
newVNode = newChildren[newEnd];
while (oldVNode.key === newVNode.key) {
patch(oldVNode, newVNode, container);
oldEnd--;
newEnd--;
oldVNode = oldChildren[oldEnd];
newVNode = newChildren[newEnd];
}

3. 处理新增/删除节点#

判断条件与设计动机

  • 如果 j > oldEnd,说明旧节点已全部处理完,剩下的新节点全部是新增,直接插入。
  • 如果 j > newEnd,说明新节点已全部处理完,剩下的旧节点全部是多余的,直接卸载。

关键变量说明

  • anchorIndexanchor:用于确定插入新节点的锚点位置。
if (j > oldEnd && j <= newEnd) {
// 新节点剩余,插入
const anchorIndex = newEnd + 1;
const anchor = anchorIndex < newChildren.length ? newChildren[anchorIndex].el : null;
while (j <= newEnd) {
patch(null, newChildren[j++], container, anchor); // 挂载新节点
}
} else if (j > newEnd && j <= oldEnd) {
// 旧节点剩余,卸载
while (j <= oldEnd) {
unmount(oldChildren[j++]); // 卸载多余节点
}
}

4. 处理需要移动的节点(中间乱序区间)#

4.1 构建 key 索引表#

原理与目的

  • 为了能 O(1) 查找新节点 key 在新 children 中的位置,构建 key 到索引的映射。

关键变量说明

  • keyIndex:对象,key 为新节点的 key,值为其在 newChildren 中的索引。
  • newStartnewEnd:中间区间的起止索引。
const keyIndex = {};
for (let i = newStart; i <= newEnd; i++) {
keyIndex[newChildren[i].key] = i;
}

4.2 构建 sources 数组#

原理与目的

  • sources 数组长度等于新 children 中未处理的节点数。
  • 记录新 children 区间内每个节点在旧 children 中的索引,未找到则为 -1。
  • 后续用于判断哪些节点需要插入、哪些需要移动。

关键变量说明

  • count:新 children 中未处理节点的数量。
  • sources:记录新节点在旧节点中的索引。
const count = newEnd - j + 1;
const sources = new Array(count).fill(-1);

4.3 遍历旧节点,更新/卸载#

原理与目的

  • 遍历旧 children 区间,查找每个旧节点在新 children 中的位置。
  • 找到则 patch 并记录索引,未找到则卸载。
  • 同时判断是否有节点需要移动。

关键变量说明

  • patched:已处理的节点数量,防止多余处理。
  • pos:记录遍历过程中遇到的最大新节点索引,用于判断是否乱序。
  • moved:标记是否有节点需要移动。
for (let i = oldStart; i <= oldEnd; i++) {
oldVNode = oldChildren[i];
if (patched <= count) {
const k = keyIndex[oldVNode.key]; // 新 children 中的索引
if (k !== undefined) {
newVNode = newChildren[k];
patch(oldVNode, newVNode, container);
patched++;
sources[k - newStart] = i; // 记录旧节点索引
// 判断是否需要移动节点
if (k < pos) {
moved = true;
} else {
pos = k;
}
} else {
unmount(oldVNode);
}
} else {
unmount(oldVNode);
}
}
  • 设置 moved 的条件

    • 在遍历旧节点时,记录每次找到新 children 中对应 key 的索引 k
    • 维护一个变量 pos,每次遇到更大的 k 时更新 pos
    • 如果当前 k < pos,说明当前节点在新 children 中的位置比前一个已处理节点靠前,说明出现了乱序(即有节点需要移动),此时设置 moved = true
  • 为什么要这样判断

    • 如果所有 k 都是递增的,说明新旧节点的相对顺序一致,无需移动。
    • 一旦出现 k 小于前面的 pos,说明有节点在新 children 中被插到了前面,需要移动。
    • 这样可以提前判断是否需要进入后续的 DOM 移动逻辑,避免不必要的操作。

4.4 判断是否需要移动节点#

原理与目的

  • 如果 moved 为 true,说明 sources 不是递增的,有节点需要移动。
  • 通过计算 sources 的最长递增子序列(LIS),LIS 内的节点不需要移动,其余的都需要移动。
  • 这样可以最小化 DOM 移动操作次数。

关键变量说明

  • seq:sources 的 LIS,表示无需移动的节点索引。
  • si:倒序遍历 sources 和 LIS。
  • anchor:插入/移动节点的目标位置。
if (moved) {
const seq = lis(sources);
let s = seq.length - 1;
let i = count - 1;
for (i; i >= 0; i--) {
if (sources[i] === -1) {
// 新增节点
const pos = i + newStart;
const newVNode = newChildren[pos];
const nextPos = pos + 1;
const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null;
patch(null, newVNode, container, anchor);
} else if (i !== seq[s]) {
// 需要移动的节点
const pos = i + newStart;
const newVNode = newChildren[pos];
const nextPos = pos + 1;
const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null;
insert(newVNode.el, container, anchor);
} else {
// 节点在 LIS 中,无需移动
s--;
}
}
}

四、最长递增子序列(LIS)算法#

原理与目的

  • 在 sources 数组中,LIS 表示无需移动的节点索引,其余的都需要移动。
  • 通过 LIS 算法,最小化 DOM 移动次数。

关键变量说明

  • arr:sources 数组。
  • p:前驱数组,用于回溯 LIS。
  • result:LIS 的索引序列。
function lis(arr) {
const p = arr.slice();
const result = [0];
for (let index = 0; index < arr.length; index++) {
const arrI = arr[index];
if (arrI !== -1) {
let lastIndex = result[result.length - 1];
if (arrI > arr[lastIndex]) {
p[index] = lastIndex;
result.push(index);
continue;
}
let left = 0, right = result.length - 1;
while (left < right) {
let mid = (left + right) >> 1;
if (arr[result[mid]] < arrI) {
left = mid + 1;
} else {
right = mid;
}
}
if (arrI < arr[result[left]]) {
if (left > 0) {
p[index] = result[left - 1];
}
result[left] = index;
}
}
}
let first = result.length, end = result[first - 1];
while (first-- > 0) {
result[first] = end;
end = p[end];
}
return result;
}

设计动机

  • LIS 算法复杂度 O(n log n),能高效找出最大不动子序列,极大减少 DOM 移动。
  • 只需移动非 LIS 的节点,保证最优性能。

五、总结与面试要点#

  1. 前后置节点优化:通过头尾双指针,快速跳过无需处理的节点,提升性能。
  2. sources 数组:记录新节点在旧节点中的位置,辅助判断哪些节点需要移动。
  3. 最长递增子序列:LIS 算法找出无需移动的节点,最小化 DOM 操作。
  4. 插入/卸载/移动:分别处理新增、删除、移动节点,保证最终 DOM 与新虚拟节点一致。
  5. 变量与判断条件解释:每个变量和判断条件都服务于性能最优和最小 DOM 操作的目标。

面试建议

  • 能手写核心 diff 逻辑,理解每一步的目的和设计动机。
  • 能解释为什么要用 LIS,如何减少 DOM 操作。
  • 能举例说明 sources 数组和 key 的作用。
  • 能详细说明 moved、patched、pos 等变量的意义和判断条件。