持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第25天,点击查看活动详情
上一章我们实现了双端对比的逻辑,完成了左端和右端的对比,经过双端对比的筛选后,我们就得到了新旧children中间的差异部分,接下来就是算法的核心,如何处理这些差异部分?
更新逻辑无非就是增加、删除和修改以及位置的变更,本节我们先来实现删除和结点内容的更新修改,下一节再来实现增加以及位置变更的逻辑
中间对比
中间对比主要涉及元素的删除、修改以及位置变化
删除和更新元素
原理
以上图为例,我们的思路是这样的,经过前面的左端和右端对比逻辑处理之后,现在i === 2
,e1 ===4
,e2 === 4
只要在中间部分中,找出新children
中不存在于旧children
中的元素,就可以将其删除,如果找到了则调用patch
去递归地更新结点
那么如何在新的children
数组中判断一个vnode
是否存在于旧children
数组呢?如果直接暴力使用双层for
循环遍历,那复杂度就比较高了
别忘了我们的结点是有key
的,我们可以先遍历新children
数组,建立一个映射表,以结点的key
作为key
,索引作为value
的一个映射表
建立起映射表后,我们再去遍历旧children
数组,判断每一个元素是否能够在映射表中查找到,这个查找操作的时间复杂度是O(1)
的,从而能够让复杂度降为O(n)
- 如果能够找到,则将对应的索引存在
newIndex
数组中,表示同一个结点在新children
数组中的位置,比如上图中旧children
数组中有结点c
,新children
中也有它,那么newIndex
的值就为 4,由于newIndex
存在,所以会对该节点调用patch
函数,对它的children
进行递归地更新 - 又比如结点
d
在旧children
数组中存在,但是在新children
数组中不存在,所以newIndex === undefined
,那么就意味着要将结点d
对应的元素移除,调用hostRemove
函数实现
实现的代码如下:
// 右端对比
while (i <= e1 && i <= e2) {
// ...
}
if (i > e1) {
// 新的比旧的多 -- 创建结点
if (i <= e2) {
// 确定插入位置
const nextPos = e2 + 1;
// 确定锚点 -- 在锚点之前插入新增结点
const anchor = nextPos < c2.length ? c2[nextPos].el : null;
while (i <= e2) {
patch(null, c2[i], container, parentComponent, anchor);
i++;
}
}
} else if (i > e2) {
// 旧的比新的多 -- 删除结点
while (i <= e1) {
hostRemove(c1[i].el);
i++;
}
} else {
// 中间对比
// s1 和 s2 指向新旧 children 左端第一个不相同位置
let s1 = i;
let s2 = i;
// 遍历新 children 建立 key 到 index 的映射
// 便于在旧 children 中进行查找
const keyToNewIndexMap = new Map();
for (let i = s2; i <= e2; i++) {
const nextChild = c2[i];
keyToNewIndexMap.set(nextChild.key, i);
}
// 遍历旧 children 判断结点是否也在新 children 中
// 在的话就找出在新 children 中的索引 -- newIndex
for (let i = s1; i < e1; i++) {
const prevChild = c1[i];
let newIndex;
// prevChild.key != null 包括了对 null 和 undefined 的判断
if (prevChild.key != null) {
newIndex = keyToNewIndexMap.get(prevChild.key);
} else {
// 用户未给结点设置 key 属性 -- 通过 isSameVNodeType 判断结点是否相同
for (let j = s2; j <= e2; j++) {
if (isSameVNodeType(prevChild, c2[j])) {
newIndex = j;
break;
}
}
}
if (newIndex === undefined) {
// newIndex 不存在说明 prevChild 在新 children 中已经消失 应当移除对应元素
hostRemove(prevChild.el);
} else {
// 存在则进行打补丁 递归更新 prevChild 的 children
// 由于不涉及新增 所以不需要传入锚点 anchor
patch(prevChild, c2[newIndex], container, parentComponent, null);
}
}
}
测试
测试一下是否可以删除和更新元素,创建一个和上图情况相同的环境进行测试
// ==================== Case7: 新的比旧的多 -- 中间对比进行删除和更新 ====================
const prevChildrenCase7 = [
h('p', { key: 'A' }, 'A'),
h('p', { key: 'B' }, 'B'),
h('p', { key: 'C' }, 'C'),
h('p', { key: 'D' }, 'D'),
h('p', { key: 'E' }, 'E'),
h('p', { key: 'F' }, 'F'),
h('p', { key: 'G' }, 'G'),
];
const nextChildrenCase7 = [
h('p', { key: 'A' }, 'A'),
h('p', { key: 'B' }, 'B'),
h('p', { key: 'E' }, 'new E'),
h('p', { key: 'H' }, 'H'),
h('p', { key: 'C' }, 'new C'),
h('p', { key: 'F' }, 'F'),
h('p', { key: 'G' }, 'G'),
];
export const ArrayToArrayCase7 = {
name: 'ArrayToArrayCase7',
setup() {
const toggleChildrenCase7 = ref(true);
window.toggleChildrenCase7 = toggleChildrenCase7;
return {
toggleChildrenCase7,
};
},
render() {
return this.toggleChildrenCase7
? h('div', {}, prevChildrenCase7)
: h('div', {}, nextChildrenCase7);
},
};
更新前的children
更新后的
children
可以看到确实是进行了删除和更新操作,
D
元素被删除了,而C
变成了new C
,E
也变成了new E
由于我们还没有实现添加和移动元素的逻辑,所以这里的结果并不完全和前面案例图中的结果一致,但是没关系,至少目前需要实现的功能已经实现了
优化删除逻辑
再看看下面这个场景 不难发现,其实在遍历旧
children
的时候,如果发现当前要打补丁的结点数量已经超过了新节点中应当打补丁的数量的时候,就可以把后面的结点删除了,没必要再递归给它们打补丁 比如这里我们应当给D C
这两个结点打补丁,那么在遍历旧children
,查询keyToNewIndex
表的时候就可以判断一下当前已经给几个结点打了补丁,如果已经超过了 2 个,那么没必要继续给剩下的旧children
打补丁了,直接把它们移除即可,因为打补丁是一个递归操作,进行多次没必要的递归调用就太没必要了
那么这个优化逻辑怎么实现呢?我们可以先根据s2 e2
这两个指针来得出应当给几个结点打补丁,比如上图中双端对比完毕之后,s2 === 2
,e2 === 3
,需要给e2 - s2 + 1 === 2
个结点,也就是D C
结点打补丁,这个计数就存放到一个名为toBePatched
的变量中 而遍历旧结点的时候,维护一个计数器变量patched
,每打一个补丁,就让计数器加一,当计数器大于等于toBePatched
的时候就没必要再进行遍历了
// 中间对比
// s1 和 s2 指向新旧 children 左端第一个不相同位置
let s1 = i;
let s2 = i;
+ // 统计已打补丁的节点数
+ let patched = 0;
+ // 约束最多能给几个结点打补丁
+ const toBePatched = e2 - s2 + 1;
// 遍历新 children 建立 key 到 index 的映射
// 便于在旧 children 中进行查找
const keyToNewIndexMap = new Map();
for (let i = s2; i <= e2; i++) {
const nextChild = c2[i];
keyToNewIndexMap.set(nextChild.key, i);
}
// 遍历旧 children 判断结点是否也在新 children 中
// 在的话就找出在新 children 中的索引 -- newIndex
for (let i = s1; i <= e1; i++) {
const prevChild = c1[i];
+ // base case: 判断 patched 是否已经达到最大需要打补丁数量 是的话后续结点直接移除,不需要打补丁
+ if (patched >= toBePatched) {
+ hostRemove(prevChild.el);
+ // 后续的打补丁操作不用继续了 直接进入下一层循环将后续旧结点删除
+ continue;
+ }
let newIndex;
// prevChild.key != null 包括了对 null 和 undefined 的判断
if (prevChild.key != null) {
newIndex = keyToNewIndexMap.get(prevChild.key);
} else {
// 用户未给结点设置 key 属性 -- 通过 isSameVNodeType 判断结点是否相同
for (let j = s2; j < e2; j++) {
if (isSameVNodeType(prevChild, c2[j])) {
newIndex = j;
break;
}
}
}
if (newIndex === undefined) {
// newIndex 不存在说明 prevChild 在新 children 中已经消失 应当移除对应元素
hostRemove(prevChild.el);
} else {
// 存在则进行打补丁 递归更新 prevChild 的 children
// 由于不涉及新增 所以不需要传入锚点 anchor
patch(prevChild, c2[newIndex], container, parentComponent, null);
+ patched++;
}
}
检验优化效果
看看优化过后是否有影响到已实现的删除逻辑,以上图这个情况设置一个测试场景 然后进行更新
可以看到仍然没问题,那么我们是否真的优化了呢?打个断点进入调试模式看看吧
} else {
// 存在则进行打补丁 递归更新 prevChild 的 children
// 由于不涉及新增 所以不需要传入锚点 anchor
patch(prevChild, c2[newIndex], container, parentComponent, null);
+ debugger;
patched++;
}
可以看到
toBePatched
确实和预想的一样是 2,并且当前的patched
是 0,原来的children
中,顺序是C D
,新的children
中顺序是D C
,发生了位置交换,并且元素的内容更新了,所以来到打补丁这里,则说明当前正遍历到旧children
中发生了位置交换的第一个元素C
那么patch
之后就应当让patched++
下一次循环先进入base case
判断一下是否需要继续打补丁 因为
patched <= toBePatched
,所以不会直接移除后续结点,仍然会继续判断是否需要打补丁(当前遍历的旧children
结点在新children
中仍然存在时就需要打补丁对其进行更新),所以这个判断并不会进去 当前遍历的结点是旧
children
中的D
,其key
在新children
中存在,所以会进行打补丁操作,可以看到打完补丁后D
变成了new D
(位置没和C
交换是因为还没实现交换逻辑,下一章会讲解) 之后又会增加patched
计数器 这次再进来的时候,我们的优化逻辑就起作用了,对于旧
children
来说,还有一个结点E
没有遍历,但是由于我们已经达到了最大打补丁数了,所以直接将它删了就行,没必要再对其递归打补丁了 可以看到成功进入了
if
语句,调用hostRemove
将结点E
引用的DOM
删除
至此我们的diff
算法的中间对比逻辑的删除和更新功能就完成了,下一章我们会讲解如何处理元素的移动问题