无论是vue或者react,diff的核心都是放在对 子节点 的diff环节中。
在vue中,子节点diff的入口函数为: patchChildren
在该函数内部,针对新节点是否有key,又分为patchUnkeyedChildren(处理不具备key的子节点),patchKeyedChildren(处理携带key的子节点);
i.e. 新节点中有10个子节点,10个子节点都没有key,则使用patchUnkeyedChildren函数。10个子节点中只要有一个子节点有key则使用patchKeyedChildren方法。
下面是对 patchKeyedChildren方法的解析
本文仅关注该函数的处理流程,代码有做删减处理
/**
* @param c1 旧子节点数组
* @param c2 新子节点数组
*/
function patchKeyedChildren(c1, c2, container, parentAnchor) {
let i = 0;
const l2 = c2.length;
let e1 = c1.length - 1; // 旧节点数组最后一项下标
let e2 = l2 - 1; // 新节点数组最后一项下标
// (a b) c
// (a b) d e
// 1、开始位置比较
while(i <= e1 && i <= e2) {
const n1 = c1[i];
const n2 = c2[i];
if (isSameVNodeType(n1, n2)) {
// 类型相同
// patch(n1, n2, container, null)
} else {
// 类型不同
break;
}
i++;
}
// b e (c d)
// a (c d)
// 2、结尾位置比较
while(i <= e1 && i <= e2) {
const n1 = c1[e1];
const n2 = c2[e2];
if (isSameVNodeType(n1, n2)) {
// 相同
// patch(n1, n2, container, null)
} else {
// 不同
break;
}
e1--;
e2--;
}
// 3、理想情况
// 3-1、旧节点处理完毕,新节点有剩余
// (a b)
// (a b) c
if (i > e1) {
if (i <= e2) {
const anchor = e2 + 1 < l2 ? c2[e2 + 1].el : parentAnchor
while(i <= e2) {
const n2 = c2[i]
// 新增节点
// patch(null, n2, container, anchor)
i++
}
}
}
// 3-2、新节点处理完毕,旧节点有剩余
// (a b) c
// (a b)
else if (i > e2) {
while(i <= e1) {
const n1 = c1[i];
// 卸载
// unmount(n1)
i++;
}
}
// 4、非理想情况
// a b [c e d] f g
// a b [d c e h] f g
// i = 2; e1 = 4; e2 = 5;
else {
// 将之前的处理进度进行存储
const s1 = i;
const s2 = i;
// 构建 剩余新节点 key: index Map映射关系
const keyToNewIndexMap = new Map();
for(i = s2; i <= e2; i++) {
const n2 = c2[i];
if (n2.key !== null) {
// 存在key
keyToNewIndexMap.set(n2.key, i);
}
}
let patched = 0; // 新节点已处理个数
const toBePatched = e2 - s2 + 1; // 剩余的新节点数量
let moved = false; // 需要移动标识
let maxNewIndexSoFar = 0; // 遍历过程中 遇到的 最大的新节点下标,用于判断是否需要移动
// 新节点在 旧节点数组中的位置
const newIndexToOldIndexMap = new Array(toBePatched).fill(0);
let j;
for(i = s1; i <= e1; i++) {
const n1 = c1[i];
if (patched >= toBePatched) {
// toBePatched 是剩余需要处理的新节点的个数,当patched >= toBePatched时 意味着 剩余新节点都处理完毕
// 因为当前处于 旧节点数组的 循环中, 存在一种可能, 剩余的旧节点 多余 剩余新节点的个数, 意味着有些旧节点需要被卸载掉
// 卸载无用的旧节点
// unmount(n1)
continue
}
let newIndex; // 当前旧节点在新节点数组中的位置
if (n1.key !== null) {
// 旧节点 有key
newIndex = keyToNewIndexMap.get(n1.key);
} else {
// 旧节点 无key
for(j = s2; i <= e2; j++) {
if (newIndexToOldIndexMap[j - s2] === 0 && isSameVNodeType(n1, c2[j])) {
// newIndexToOldIndexMap[j - s2] === 0 的作用是 当其 !== 0 时 意味着在之前的遍历中对应位置已经建立映射关系,本次遍历便可跳过去比较后续节点
newIndex = j;
break;
}
}
}
if(newIndex === undefined) {
// 未找到 当前 旧节点 在 新节点中映射位置,该旧节点 无法复用
// 卸载旧节点
// unmount(n1)
} else {
// 建立 新旧节点位置映射关系
newIndexToOldIndexMap[newIndex - s2] = i + 1;
// 判断是否需要移动
if (newIndex >= maxNewIndexSoFar) {
//
maxNewIndexSoFar = newIndex
} else {
// 需要移动
moved = true;
}
// 将新旧节点 进行递归patch
// patch(n1, c2[newIndex], container, null)
// 处理进度递增
patched++;
}
}
// 经历过上面这一步后,所有的旧节点都已经被处理完毕 (该复用的复用,该卸载的卸载)
// 接下来遍历剩余的新节点,将所有新节点都进行处理
// 剩余新节点处理 有两种可能
// 一种是 纯粹的新增节点
// 另一种是 经过复用得到的新节点 其位置或许需要移动(moved标识意味着上面的处理 是否遇到了需要移动的节点, 该种情况处理的是 patched++上方调用的patch()函数产生的新节点
// 如果moved = false; 即无需移动则 上方调用的patch()函数生成的新节点其位置已然满足需要,否则就需要进行移动)
// 接下来 就是 经典的 最长递增子序列
const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : [];
j = increasingNewIndexSequence.length - 1
for(i = toBePatched - 1; i >= 0; i--) {
const n2Index = s2 + i;
const n2 = c2[n2Index];
if (newIndexToOldIndexMap[i] === 0) {
// 纯粹的新增节点
// patch(null, n2, container, anchor)
} else if (moved) {
// 需要移动
if (j < 0 || i !== increasingNewIndexSequence[j]) {
// 当前节点移动
// move(n2, container, anchor)
} else {
// 当前节点不需要移动
j--;
}
}
}
}
}
伪代码验证:
// 旧:a b [c d e] f g
// 新:a b [d e c h] f g
// 前置比较:a b 复用
// 后置比较:f g 复用
// 进入非理想情况
// newIndexToOldIndexMap = [3, 4, 2, 0]
// increasingNewIndexSequence = [0, 1]
// 从后向前遍历: newIndexToOldIndexMap
// i = 3; newIndexToOldIndexMap[i] = 0; - 新增节点 nextChild = h; anchor = f; 将h 挂载在 f节点前
// 旧: a b [c d e] h f g
// i = 2; j = 1; increasingNewIndexSequence[j] = 1; nextChild = c; anchor = h;
// 将 c 移动到 h 前
// 旧: a b [d e c] h f g
// i = 1; j = 1; increasingNewIndexSequence[j] = 1; increasingNewIndexSequence[j] = i;
// j--; j = 0;
// i = 0; j = 0; increasingNewIndexSequence[j] = 0; increasingNewIndexSequence[j] = i;
// j--; j = -1;
// 最终 新 旧 节点达成一致
总结:
-
前置比较:从前向后
依次比较对应新旧节点是否能复用,找出能复用的节点 -
后置比较:从后向前
依次比较对应新旧节点是否能复用,找出能复用的节点 -
理想情况:
- 旧节点
全复用,新节点有剩余- 将剩余新节点挂载 - 新节点
全被处理完毕,旧节点有剩余- 将剩余旧节点卸载
- 旧节点
-
非理想情况
-
构建
keyToNewIndexMap; 方便通过旧节点的key快速找到新节点的位置 -
通过剩余未处理新节点数量,构建
newIndexToOldIndexMap;通过getSequence计算最长递增子序列,最长递增子序列的目的就是寻求对dom进行最小操作(通过最少次对旧dom的操作达成新旧子节点次序一致) -
开启对剩余旧节点的遍历
- 当前旧节点
有key,通过keyToNewIndexMap获取对应新节点所处位置newIndex - 当前旧节点
无key,在剩余新节点中依次比对找到一个满足newIndexToOldIndexMap[j - s2] === 0 && isSameVNodeType(n1, c2[j])的新节点下标newIndex - 根据 上述获取的
newIndex,更新newIndexToOldIndexMap对应位置,构建剩余新旧子节点映射关系
- 当前旧节点
-
根据是否需要移动
moved = true,计算increasingNewIndexSequence -
反向遍历剩余的新节点newIndexToOldIndexMap对应位置的旧节点位置如果为0,代表当前新子节点需要mounted- 否则 根据
moved = true根据increasingNewIndexSequence中对应的下标,同当前遍历下标进行比对是否相同,不同则需要移动, 针对 j < 0 的情况,为increasingNewIndexSequence长度为0时(i.e. 没有最长递增子序列)此种情况
-
经过上述步骤 - 最终达成 新、旧子节点次序一致
-
-- End --