第 11 章主要讲解了 Vue3 在 更新子节点的实现原理。实现原理上使用了快速 diff,它源起于 ivi 和 inferno 这两个框架,Vue 借鉴并扩展它。
本章主要解决如下问题:
- 处理相同的前置元素
- 处理相同的后置元素
- 判断是否需要进行DOM移动操作
- 如何移动 DOM 元素
处理相同的前置元素
function patchKeyedChildren(n1, n2, container) {
const newChildren = n2.children;
const oldChildren = n1.children;
// 更新相同的前缀节点
// 索引 j 指向新旧两组子节点的开头
let j = 0;
let oldVNode = oldChildren[j];
let newVNode = newChildren[j];
// while 循环向后遍历,直到遇到拥有不同 key 值的节点为止
while (oldVNode.key === newVNode.key) {
// 调用 patch 函数更新
patch(oldVNode, newVNode, container);
j++;
oldVNode = oldChildren[j];
newVNode = newChildren[j];
}
}
处理相同的前置元素时,使用 while 循环向后遍历,让索引 i 递增,直至遇到不同的节点为止。
这里需要注意的是,需要相同 key 值得节点,要调用 patch 函数进行更新。
处理相同的后置元素
// while 循环向前遍历,直到遇到拥有不同 key 值的节点为止
while (oldVNode.key === newVNode.key) {
// 调用 patch 函数更新
patch(oldVNode, newVNode, container);
oldEnd--;
newEnd--;
oldVNode = oldChildren[oldEnd];
newVNode = newChildren[newEnd];
}
在处理完相同前置元素后,再处理相同的后置元素,使用 while 循环向前遍历,不断移动新旧节点尾指针 newEnd 和 oldEnd 直到遇到拥有不同 key 值的节点为止。
这里需要注意的是,需要相同 key 值得节点,要调用 patch 函数进行更新。
剩下的新子节点里面新增的节点或者删除的节点,我们接下来继续处理
处理新增和删除的节点
// 满足条件,则说明从 j -> newEnd 之间的节点应作为新节点插入
if (j > oldEnd && j <= newEnd) {
// 锚点的索引
const anchorIndex = newEnd + 1;
// 锚点元素
const anchor =
anchorIndex < newChildren.length ? newChildren[anchorIndex].el : null;
// 采用 while 循环,调用 patch 函数逐个挂载新增的节点
while (j <= newEnd) {
patch(null, newChildren[j++], container, anchor);
}
} else if (j > newEnd && j <= oldEnd) {
// j -> oldEnd 之间的节点应该被卸载
while (j <= oldEnd) {
unmount(oldChildren[j++]);
}
}
j > oldEnd && j <= newEnd ,代表旧的子节点已经遍历完,新的子节点还有,说明存在新增,需要使用 while 循环挂载 j 到 newEnd 之间所有的子节点。新增的子节点以下一个子节点作为锚点元素,挂载在下一个锚点元素前面,如果没有下一个锚点元素,说明是当前节点就是最后一个。
j > newEnd && j <= oldEnd ,代表新的子节点已经遍历完,旧的子节点还有,说明存在删除,需要使用 while 循环卸载 j 到 oldEnd 之间所有的子节点。
判断是否需要进行DOM移动操作和如何移动?
经过处理完相同前置元素 和 处理完相同后置元素后还有中间部分的节点无法处理,我们中间肯还存在元素无法进行处理,比如如下新旧子节点结构,在处理完前置和后置元素后中间还有一些差异的子节点需要 patch 更新的。
新子节点 旧子节点
p - 1 p - 1
----------------------------
p - 3 p - 2
p - 4 p - 3
p - 2 p - 4
p - 7 p - 6
----------------------------
p - 5 p - 5
接下来我们需要解决的问题就是这些节点
- 判断是否需要移动
- 如何移动
- 找出那些需要新增或删除的节点
定义 source 数组保存新节点在旧节点中的下标
source 数组用于存储新子节点在旧子节点中的位置索引,后续将使用它计算出一个最长递增子序列,并用于辅助完成 DOM 的移动操作。
这个 source 变量在源码中命名是 newIndexToOldIndexMap
- 新增一个 source 数组,长度为新子节点预处理之后的节点数量,初始值填充为 -1,用来存储新子节点在旧子节点中的位置索引
- 为新节点建立一个索引表,保存节点 key 与位置索引之间的映射
- 遍历旧节点,旧节点的 key 在索引表中查找旧节点在新节点的索引,保存在 k 中
- 如果存在 k,说明旧节点在新节点中找到了,调用 patch 更新,填充 source 数组,否则直接卸载。
// 构造 source 数组
const count = newEnd - j + 1; // 新的一组子节点中剩余未处理节点的数量
const source = new Array(count);
source.fill(-1);
const oldStart = j;
const newStart = j;
// 索引表
const keyIndex = {};
for (let i = newStart; i <= newEnd; i++) {
keyIndex[newChildren[i].key] = i;
}
for (let i = oldStart; i <= oldEnd; i++) {
oldVNode = oldChildren[i];
// k 是旧节点在新节点中的索引
const k = keyIndex[oldVNode.key];
if (typeof k !== 'undefined') {
newVNode = newChildren[k];
patch(oldVNode, newVNode, container);
source[k - newStart] = i;
} else {
// 没找到
unmount(oldVNode);
}
}
判断是否需要进行DOM移动操作
const count = newEnd - j + 1; // 新的一组子节点中剩余未处理节点的数量
let moved = false;
let pos = 0;
let patched = 0;
for (let i = oldStart; i <= oldEnd; i++) {
oldVNode = oldChildren[i];
if (patched < count) {
const k = keyIndex[oldVNode.key];
if (typeof k !== 'undefined') {
newVNode = newChildren[k];
patch(oldVNode, newVNode, container);
patched++;
source[k - newStart] = i;
// 判断是否需要移动
if (k < pos) {
moved = true;
} else {
pos = k;
}
} else {
// 没找到
unmount(oldVNode);
}
} else {
unmount(oldVNode);
}
}
- 定义变量 moved 标识是否需要移动,pos 表示遍历旧子节点过程中遇到的最大索引值
- 如果遍历过程遇到的索引值呈递增趋势,说明不需要节点移动,否则需要。有了 moved 标识,就知道了是否需要移动节点
- 定义变量 patched 表示已更新节点数量,如果更新节点数量大于新的一组节点需要更新的数量,说明有多余的节点,需要卸载
如何移动?
if (moved) {
const seq = getRequence(source);
// s 指向最长递增子序列的最后一个值
let s = seq.length - 1;
let i = count - 1;
for (i; i >= 0; i--) {
if (source[i] === -1) {
// 说明索引为 i 的节点是全新的节点,应该将其挂载
// 该节点在新 children 中的真实位置索引
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 {
// 当 i === seq[j] 时,说明该位置的节点不需要移动
// 并让 s 指向下一个位置
s--;
}
}
}
通过 getRequence 获取 source 数组的最长递增子序列,找到不需要移动的节点
关于最长递增子序列看下我这篇文章:Vue3 最长递增子序列详解
seq source KeyIndex 新节点 旧节点
0 ↑ 2 3:1 p - 3 p - 2
1 (s) 3 4:2 p - 4 p - 3
1 2:3 p - 2 ↑ p - 4
-1 7:4 p - 7(i) p - 6
倒序遍历节点,如果 source[i] 为 默认值 -1 ,说明该节点时新增的节点,需要挂载。
如果遍历的节点下标不是最长递增子序列 seq[s] 说明是需要移动的节点,设置锚点为上一个已经更新的节点为锚点位置,将当前节点移动到锚点前面。
如果遍历的节点下标是最长递增子序列 seq[s] 说明是不需要移动节点,s— 即可
|