Vue 3 Diff 算法中的 newIndexToOldIndexMap 详解
前言
在 Vue 3 的虚拟 DOM diff 算法中,有一个关键的数据结构 newIndexToOldIndexMap
,它在优化节点移动操作中扮演着至关重要的角色。本文将深入解析这个映射数组的工作原理、实现方式以及它如何与最长递增子序列算法配合,实现高效的 DOM 更新。
Vue3对应源码
newIndexToOldIndexMap 是什么?
newIndexToOldIndexMap
是 Vue 3 diff 算法中的一个核心数据结构,它建立了新子节点列表中的节点与旧子节点列表中对应节点的映射关系。具体来说:
- 它是一个数组,长度等于需要处理的新子节点的数量
- 数组的索引对应新子节点的相对位置
- 数组的值对应旧子节点的索引加 1,以区分索引 0 和未找到的情况
- 如果值为 0,表示这是一个全新的节点,需要创建而不是移动
构建过程
在 Vue 3 的 patchKeyedChildren
函数中,newIndexToOldIndexMap
的构建过程大致如下:
javascript
// 初始化映射数组,填充为 0,表示所有新节点默认都是需要新建的
const newIndexToOldIndexMap = new Array(toBePatched);
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0;
// 遍历旧节点,建立 key 到索引的映射
for (i = s1; i <= e1; i++) {
const prevChild = c1[i];
if (patched >= toBePatched) {
// 所有新节点都已处理,剩余旧节点可以直接删除
unmount(prevChild);
continue;
}
let newIndex;
// 查找当前旧节点在新节点列表中的位置
if (prevChild.key != null) {
newIndex = keyToNewIndexMap.get(prevChild.key);
} else {
// 对于没有 key 的节点,尝试在新节点中找到相同类型的节点
for (j = s2; j <= e2; j++) {
if (newIndexToOldIndexMap[j - s2] === 0 && isSameVNodeType(prevChild, c2[j])) {
newIndex = j;
break;
}
}
}
if (newIndex === undefined) {
// 在新节点列表中未找到对应节点,需要卸载
unmount(prevChild);
} else {
// 记录映射关系:新节点位置 -> 旧节点索引+1
newIndexToOldIndexMap[newIndex - s2] = i + 1;
// 递归更新节点
patch(prevChild, c2[newIndex], ...);
patched++;
}
}
为什么索引要加 1?
在构建 newIndexToOldIndexMap
时,存储的是旧节点的索引 + 1,而不是直接存储索引。这样设计的原因是:
- 区分未找到和索引为 0 的情况:如果直接存储索引,当旧节点索引为 0 时,无法区分这个节点是来自旧列表的第一个节点,还是一个全新的节点
- 简化后续处理 :通过这种方式,可以用简单的条件
newIndexToOldIndexMap[i] === 0
来判断是否需要创建新节点
与最长递增子序列的结合
构建完 newIndexToOldIndexMap
后,Vue 3 会使用 getSequence
函数计算这个映射数组的最长递增子序列:
javascript
// 获取最长递增子序列的索引
const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap);
getSequence
函数详细解释见Vue 3 Diff 算法中的 getSequence 源码解析
这个最长递增子序列有什么意义呢?
- 递增:表示这些节点在新旧两个列表中的相对顺序是一致的
- 最长:表示保持这些节点不动,可以最小化 DOM 移动操作
因此,在更新过程中,Vue 3 会:
- 保持最长递增子序列中的节点位置不变
- 只移动不在最长递增子序列中的节点
- 创建
newIndexToOldIndexMap
值为 0 的新节点
javascript
// 初始化 j 指向最长递增子序列的最后一个元素
let j = increasingNewIndexSequence.length - 1;
// 从后向前遍历,以便正确移动节点
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i;
const nextChild = c2[nextIndex];
// 处理挂载点
// - 如果当前节点的下一个节点存在,那么将下一个节点的 el 作为 anchor
// - 否则,将父节点的 anchor 作为当前节点的 anchor
const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : parentAnchor;
if (newIndexToOldIndexMap[i] === 0) {
// 创建新节点
patch(null, nextChild, container, anchor);
} else {
// 移动或保持不动的节点
if (j < 0 || i !== increasingNewIndexSequence[j]) {
// 不在最长递增子序列中,需要移动
move(nextChild, container, anchor);
} else {
// 在最长递增子序列中,不需要移动
j--;
}
}
}
举个栗子
为了更直观地理解,让我们通过一个例子来说明:
假设我们有以下新旧节点列表(用字母表示不同的节点,每个节点都有唯一的 key):
- 旧列表:A, B, C, D, E
- 新列表:A, C, F, D, E, B
处理步骤如下:
-
初始化
newIndexToOldIndexMap = [0, 0, 0, 0, 0]
(对应新列表中 C, F, D, E, B 这五个需要处理的节点) -
遍历旧节点列表,更新映射关系:
- 节点 A:在新列表前缀中,不涉及
newIndexToOldIndexMap
- 节点 B:对应新列表索引 5,所以
newIndexToOldIndexMap[4] = 2
(newIndex-s2=4,旧索引+1=2) - 节点 C:对应新列表索引 1,所以
newIndexToOldIndexMap[0] = 3
(旧索引+1) - 节点 D:对应新列表索引 3,所以
newIndexToOldIndexMap[2] = 4
(旧索引+1) - 节点 E:对应新列表索引 4,所以
newIndexToOldIndexMap[3] = 5
(旧索引+1)
- 节点 A:在新列表前缀中,不涉及
-
节点 F 在旧列表中不存在,所以
newIndexToOldIndexMap[1]
保持为 0 -
最终
newIndexToOldIndexMap = [3, 0, 4, 5, 2]
-
计算最长递增子序列,得到
[ 3, 4, 5]
,对应新列表中的节点 C, D, E -
在更新过程中:
- 保持节点 C, D, E 的位置不变(它们在最长递增子序列中)
- 移动节点 B 到新位置
- 创建新节点 F(
newIndexToOldIndexMap
值为 0)
总结
newIndexToOldIndexMap
的作用 :
- 建立了新旧节点列表之间的映射关系
- 通过与最长递增子序列算法的结合,实现了高效的节点移动策略
- 使用简单的数组结构,实现了复杂的 DOM 更新优化