持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第23天,点击查看活动详情
前面我们实现了children的三种更新情况,分别是:
array变成texttext变成arraytext变成text
这三种情况都是比较好处理的,但是还有一种情况,就是array变成array
这种情况就有讲究了,要用到diff算法
如果不使用diff算法,那么一个简单的实现思路就是遍历更新前的vnode的children中的所有孩子,每遍历一个就要在更新后的vnode.children中看看是否有变化,比如是否位置变化了,是否内容变化了,是否新增了,是否删除了等等,这样的话最终的时间复杂度将会是O(n^2),是非常低效,几乎不可用的算法
而今天要讲的diff算法 -- 双端diff算法,能够把时间复杂度降低到O(n),大大提高了运行效率
首先介绍一下双端diff算法的原理
1. 双端 diff 算法
双端diff算法,顾名思义,就是从vnode.children的两端去进行对比,将左右两侧相同的vnode排除,不需要发生变化,只要找出中间发生了变化的vnode
因为很多时候我们对DOM的更新只是对中间部分的更新,对两端的更新的情况是相对少一些的,如果能够利用这个特点对其作出优化,那就能够一定程度上提高我们的children的更新效率
所以核心就是找出新旧
children的中间的发生了变化的部分,那么这部分要怎么找呢?
2. 各种情况
首先是两种基本情况,从左端对比和从右端对比
2.1 左端对比
左端对比就是需要我们找出新旧children中从左端开始的第一个不同的位置
2.2 右端对比
类似地,右端对比就是找出右端第一个不同的位置
接下来还有四种简单情况
- 新的比旧的长的时候代表要新增结点
- 旧的比新的长的时候代表要删除结点
由于分为左端和右端对比, 所以有如下四种情况
2.3 新的比旧的长 -- 右端创建
当左端对比找到第一个不同的结点时,发现新的结点数比旧的多,并且左端都保持一样,说明是要在右端创建新的结点
2.4 新的比旧的长 -- 左端创建
类似地,当从右端对比找到第一个不同结点的时候,发现新的比旧的长,说明要在左端创建新的结点
2.5 旧的比新的长 -- 右端删除
类似地,右端对比结束后,发现旧的比新的长,说明要在右端删除结点
2.6 旧的比新的长 -- 左端删除
当左端对比结束后发现旧的比新的长,说明需要在左端删除结点
2.7 双端一样,处理中间差异
以上几种情况都是比较简单的情况,其实最复杂的情况也无非就是两端一样,然后中间有不同的,并且不同的结点涉及新增、删除和修改
3. 实现左端对比
首先我们根据前面描述的第一种左端对比的情况,设计一个相应的demo,然后来实现左端对比,并通过demo进行验证
由于我们要从左端开始对比,所以我们可以用一个指针i从新旧children的左端同时开始对比,一旦遇到不一样的就说明左端对比结束
为了避免指针越过新旧children数组的索引,我们还需要有两个e1和e2指针,分别指向旧结点的末尾和新结点的末尾,并且严格控制i不能超过e1和e2
3.1 demo 场景
创建一个和之前描述图中一样的左端对比场景,旧结点为A、B、C,新节点为A、B、D、E
如果对比成功的话:
i应当停在索引为2的地方e1索引为2e2索引为3
// ==================== Case1: 左端对比 ====================
const prevChildrenCase1 = [
h('p', { key: 'A' }, 'A'),
h('p', { key: 'B' }, 'B'),
h('p', { key: 'C' }, 'C'),
];
const nextChildrenCase1 = [
h('p', { key: 'A' }, 'A'),
h('p', { key: 'B' }, 'B'),
h('p', { key: 'D' }, 'D'),
h('p', { key: 'E' }, 'E'),
];
export const ArrayToArrayCase1 = {
name: 'ArrayToArrayCase1',
setup() {
const toggleChildrenCase1 = ref(true);
window.toggleChildrenCase1 = toggleChildrenCase1;
return {
toggleChildrenCase1,
};
},
render() {
return this.toggleChildrenCase1
? h('div', {}, prevChildrenCase1)
: h('div', {}, nextChildrenCase1);
},
};
3.2 key 属性的作用
key用于确保新旧结点是否是同一个结点
比如下标索引为1的地方,尽管它们的类型是一样的,比如都是element类型的vnode,但是如果key不相同,说明已经不是同样的结点了,那么没必要再对它们的children进行对比了,因为已经是不同的结点了
如果没有key这个属性的话,仅仅通过vnode.type来判断它们的类型相同的话,我们还需要进一步递归调用检查它们的children是否也相同,不相同的时候需要发生变化
这也是为什么我们在vue中写v-for的时候需要添加一个key属性
3.3 找到双端 diff 算法的调用入口
首先我们要回到patchChildren函数,找到array -> array的地方
function patchChildren(n1, n2, container, parentComponent) {
// n2 的 children 是 text 类型
const prevShapeFlag = n1.shapeFlag;
const { shapeFlag } = n2;
const c2 = n2.children;
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 新 children 是 text 类型
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 旧 children 是 array 类型 -- 从 array 变为 text
// 卸载 array 的内容
unmountChildren(n1.children);
// 挂载 text 的内容
hostSetElementText(container, c2);
} else {
// 旧 children 是 text 类型 -- 从 text 变为 text
hostSetElementText(container, c2); // 直接修改文本内容即可
}
} else {
// 新 children 是 array 类型
if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 旧 children 是 text 类型 -- 从 text 变为 array
// 清空旧结点中的文本内容
hostSetElementText(container, '');
// 挂载新结点中 array 的内容
mountChildren(c2, container, parentComponent);
+ } else {
+ // 旧 children 是 array 类型 -- 从 array 变为 array
+ }
}
}
在这里面去编写双端diff算法,作为调用入口,实现一个patchKeyedChildren函数处理有key属性的vnode,接下来我们就去实现它
3.4 处理带有 key 属性的 children
首先要在patchKeyedChildren函数中获取到三个要用到的指针 -- i、e1和e2
function patchKeyedChildren(c1, c2) {
let i = 0; // 从左端开始遍历新旧 children
let e1 = c1.length - 1; // 指向旧 children 的末尾
let e2 = c2.length - 1; // 指向新 children 的末尾
}
然后需要从左端开始进行对比,找出第一个不一样的结点
function patchKeyedChildren(c1, c2, container, parentComponent) {
let i = 0; // 从左端开始遍历新旧 children
let e1 = c1.length - 1; // 指向旧 children 的末尾
let e2 = c2.length - 1; // 指向新 children 的末尾
/**
* @description 判断两个结点是否是相同结点
* @param n1 vnode1
* @param n2 vnode2
* @returns 结点是否是同一个结点
*/
const isSameVNodeType = (n1, n2) => {
return n1.type === n2.type && n1.key === n2.key;
};
while (i <= e1 && i <= e2) {
const n1 = c1[i];
const n2 = c2[i];
if (isSameVNodeType(n1, n2)) {
// 新旧结点是同一个结点 -- 递归处理它们的 children 看看是否有变化
patch(n1, n2, container, parentComponent);
} else {
// 遇到不相同的结点 -- 左端对比结束
break;
}
i++;
}
console.log(i);
}
当遇到不是同一个vnode的时候,说明找到了第一个从左端开始数起的不同的vnode,于是我们就可以停止遍历了,这里我们可以先输出一下i,看看和预想的结果是否一样
由于我们使用到了vnode.key,所以还需要给vnode加上key这个属性
export function createVNode(type, props?, children?) {
const vnode = {
type,
props,
children,
shapeFlag: getShapeFlag(type),
el: null,
key: props?.key,
};
// ...
}
现在我们在控制台中将toggleChildrenCase1改成false,demo中的children就会渲染新的children,由于新旧children都是array类型的,所以会触发我们的patchKeyedChildren函数进行对比
可以看到,对比了两次,第三次由于发现
C和D是不相同的,所以不会调用patch去处理它们的children了,直接break退出,结束左端对比的流程
并且输出的i === 2,与我们前面在demo场景中描述的预期情况一致,说明左端对比已经实现了
4. 实现右端对比
首先左端对比,遇到了左端第一个不相同的结点的时候,就退出循环,从右端开始对比,找出右端第一个不一样的结点 所以我们需要在左端对比的下面再用一个循环从右端开始寻找
function patchKeyedChildren(c1, c2, container, parentComponent) {
let i = 0; // 从左端开始遍历新旧 children
let e1 = c1.length - 1; // 指向旧 children 的末尾
let e2 = c2.length - 1; // 指向新 children 的末尾
/**
* @description 判断两个结点是否是相同结点
* @param n1 vnode1
* @param n2 vnode2
* @returns 结点是否是同一个结点
*/
const isSameVNodeType = (n1, n2) => {
return n1.type === n2.type && n1.key === n2.key;
};
// 左端对比
while (i <= e1 && i <= e2) {
const n1 = c1[i];
const n2 = c2[i];
if (isSameVNodeType(n1, n2)) {
// 新旧结点是同一个结点 -- 递归处理它们的 children 看看是否有变化
patch(n1, n2, container, parentComponent);
} else {
// 遇到不相同的结点 -- 左端对比结束
break;
}
i++;
}
// 右端对比
while (i <= e1 && i <= e2) {
const n1 = c1[e1];
const n2 = c2[e2];
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container, parentComponent);
} else {
break;
}
e1--;
e2--;
}
console.log(`i: ${i}, e1: ${e1}, e2: ${e2}`);
}
以前面右端对比的情况为例:
以该情况为例,首先会进行左端对比,由于第一个就已经不一样了,所以
i应为0
之后就开始从右端对比,当遍历到下标为2,即第三个结点的时候,就应当让e1和e2停下
测试一下是否能够完成右端对比
// ==================== Case2: 右端对比 ====================
const prevChildrenCase2 = [
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 nextChildrenCase2 = [
h('p', { key: 'E' }, 'E'),
h('p', { key: 'H' }, 'H'),
h('p', { key: 'C' }, 'C'),
h('p', { key: 'F' }, 'F'),
h('p', { key: 'G' }, 'G'),
];
export const ArrayToArrayCase2 = {
name: 'ArrayToArrayCase2',
setup() {
const toggleChildrenCase2 = ref(true);
window.toggleChildrenCase2 = toggleChildrenCase2;
return {
toggleChildrenCase2,
};
},
render() {
return this.toggleChildrenCase2
? h('div', {}, prevChildrenCase2)
: h('div', {}, nextChildrenCase2);
},
};
可以看到和预想的情况一样,所以右端对比也没问题
5. 新的比旧的多 -- 创建结点
当新的children比旧的多时,可能会在左端创建,也可能在右端创建
5.1 右端创建
首先处理一下在右端创建的情况
根据前面的左端右端对比,最
i === 2,e1 === 1,e2 === 2
这种时候就是i > e1 && i <= e2的情形,遇到这种情况我们直接创建结点
function patchKeyedChildren(c1, c2, container, parentComponent) {
let i = 0; // 从左端开始遍历新旧 children
let e1 = c1.length - 1; // 指向旧 children 的末尾
let e2 = c2.length - 1; // 指向新 children 的末尾
/**
* @description 判断两个结点是否是相同结点
* @param n1 vnode1
* @param n2 vnode2
* @returns 结点是否是同一个结点
*/
const isSameVNodeType = (n1, n2) => {
return n1.type === n2.type && n1.key === n2.key;
};
// 左端对比
while (i <= e1 && i <= e2) {
const n1 = c1[i];
const n2 = c2[i];
if (isSameVNodeType(n1, n2)) {
// 新旧结点是同一个结点 -- 递归处理它们的 children 看看是否有变化
patch(n1, n2, container, parentComponent);
} else {
// 遇到不相同的结点 -- 左端对比结束
break;
}
i++;
}
// 右端对比
while (i <= e1 && i <= e2) {
const n1 = c1[e1];
const n2 = c2[e2];
if (isSameVNodeType(n1, n2)) {
patch(n1, n2, container, parentComponent);
} else {
break;
}
e1--;
e2--;
}
+ // 新的比旧的多
+ if (i > e1) {
+ if (i <= e2) {
+ // 右端创建结点
+ patch(null, c2[i], container, parentComponent);
+ }
+ }
console.log(`i: ${i}, e1: ${e1}, e2: ${e2}`);
}
现在来看看对应的demo
// ==================== Case3: 新的比旧的多 ====================
const prevChildrenCase3 = [
h('p', { key: 'A' }, 'A'),
h('p', { key: 'B' }, 'B'),
];
const nextChildrenCase3 = [
h('p', { key: 'A' }, 'A'),
h('p', { key: 'B' }, 'B'),
h('p', { key: 'C' }, 'C'),
];
export const ArrayToArrayCase3 = {
name: 'ArrayToArrayCase3',
setup() {
const toggleChildrenCase3 = ref(true);
window.toggleChildrenCase3 = toggleChildrenCase3;
return {
toggleChildrenCase3,
};
},
render() {
return this.toggleChildrenCase3
? h('div', {}, prevChildrenCase3)
: h('div', {}, nextChildrenCase3);
},
};
在更新之前,只有A B
而更新之后,则变成了
A B C
5.2 左端创建
如果我们不改动我们的代码,直接让其处理左端添加结点的逻辑
demo
// ==================== Case4: 新的比旧的多 ====================
const prevChildrenCase4 = [
h('p', { key: 'B' }, 'B'),
h('p', { key: 'A' }, 'A'),
];
const nextChildrenCase4 = [
h('p', { key: 'C' }, 'C'),
h('p', { key: 'B' }, 'B'),
h('p', { key: 'A' }, 'A'),
];
export const ArrayToArrayCase4 = {
name: 'ArrayToArrayCase4',
setup() {
const toggleChildrenCase4 = ref(true);
window.toggleChildrenCase4 = toggleChildrenCase4;
return {
toggleChildrenCase4,
};
},
render() {
return this.toggleChildrenCase4
? h('div', {}, prevChildrenCase4)
: h('div', {}, nextChildrenCase4);
},
};
可以看到,本该在
B A之前添加一个C才对,但是现在却是在B A之后添加了一个C
这明显不符合更新的逻辑,这是因为我们调用patch添加新节点的时候,是调用hostInsert,直接简单粗暴添加到数组的最后的,并没有提供插入到指定位置的功能
所以这里我们应该扩展一下hostInsert接口,增加能够插入到指定位置的特性,这个指定位置就叫做锚点(anchor)
我们的锚点定义为vnode,在数组中找到锚点vnode,在该vnode之前进行插入,从当前这个例子可以看出,我们应当在第e2 + 1这个vnode之前插入新节点,因为e2总是会指向第一个与e1不同的结点处,在e2 + 1处插入,也就是在原本和e1相同的位置之前插入新节点
比如n1是B A,n2是C B A,则e2指向0,e1指向-1此时应当在B的前面插入新节点,也就是e2 + 1之前插入新节点,所以锚点应当定位e2 + 1处的el
// 新的比旧的多 -- 创建结点
if (i > e1) {
if (i <= e2) {
// 确定插入位置
const nextPos = e2 + 1;
// 确定锚点 -- 在锚点之前插入新增结点
const anchor = nextPos > c2.length ? null : c2[nextPos].el;
patch(null, c2[i], container, parentComponent, anchor);
}
}
这里给patch函数添加了一个anchor参数,添加后需要修改用到了patch函数的地方
其中最关键的是在render中调用patch的时候需要传递该参数为null,表明参数为空,因为对于render函数来说,此时整个vnode.children中什么都没有,就是需要在最后创建结点,所以没必要传入锚点
function render(vnode: any, container: any) {
// 调用 patch
patch(null, vnode, container, null, null);
}
最后还需要修改runtime-dom中insert的实现,将元素添加到锚点之前(如果有传入锚点的话)
function insert(child, parent, anchor) {
parent.insertBefore(child, anchor || null);
}
这样整个过程就完成啦,现在看看demo的效果
可以看到,插入的顺序是正确的,在
B A之前插入了新增元素C
5.3 bug 修复 -- 右端创建错误
我们现在实现了左端创建结点的功能,但是别忘了要检查一下之前已经实现的右端创建结点是否正常,说不定会被我们新实现的左端创建给影响了
果不其然,真的影响到了已经实现好的右端创建功能了,那肯定是刚刚我们新增的代码出问题了,也就是新增的
anchor有问题
const nextPos = e2 + 1;
const anchor = nextPos > c2.length ? null : c2[nextPos].el;
很明显我们的anchor忽略了nextPos === c2.length的情况,当nextPos为c2.length时,发生了数组下标索引越界,导致无法获取到c2[nextPos].el,所以我们可以加上一个等号即可
这里为了和vue3源码统一,vue3中用的是<的判断逻辑而不是>=,相当于进行一个取反操作
const anchor = nextPos < c2.length ? c2[nextPos].el : null;
现在无论是左端创建还是右端创建都是正常的了!
5.4 创建多个结点
事实上新节点可能有多个,所以会创建多次,所以我们的patch应当调用多次,直到i > e2为止
现在我们修改demo,更新后的children是在左端插入三个新元素
const prevChildrenCase4 = [
h('p', { key: 'B' }, 'B'),
h('p', { key: 'A' }, 'A'),
];
const nextChildrenCase4 = [
h('p', { key: 'E' }, 'E'),
h('p', { key: 'D' }, 'D'),
h('p', { key: 'C' }, 'C'),
h('p', { key: 'B' }, 'B'),
h('p', { key: 'A' }, 'A'),
];
看看能否成功
可以看到没问题,那么右端添加三个新元素呢?
可以看到也没问题
6. 旧的比新的多 -- 删除结点
6.1 右端删除
右端删除时,对应的指针指向为:
可以看到,此时的
i已经不再是小于e1了,而是和它相等,事实上如果说原来是A B C D,后来是A B的话,则有i < e1,所以触发右端删除的条件应当是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++;
}
}
这就是核心逻辑了,当遇到需要删除的时候,直接调用hostRemove,也就是渲染器实现的remove函数去处理元素的删除逻辑
可以看到右端删除成功
6.2 左端删除
左端删除时的指针指向:
可以看到,此时仍然是
i > e2,且i <= e1所以上面右端删除的逻辑同样适用于左端删除
demo
// ==================== Case6: 新的比旧的多 -- 左端删除 ====================
const prevChildrenCase6 = [
h('p', { key: 'E' }, 'E'),
h('p', { key: 'D' }, 'D'),
h('p', { key: 'C' }, 'C'),
h('p', { key: 'B' }, 'B'),
h('p', { key: 'A' }, 'A'),
];
const nextChildrenCase6 = [
h('p', { key: 'B' }, 'B'),
h('p', { key: 'A' }, 'A'),
];
export const ArrayToArrayCase6 = {
name: 'ArrayToArrayCase6',
setup() {
const toggleChildrenCase6 = ref(true);
window.toggleChildrenCase6 = toggleChildrenCase6;
return {
toggleChildrenCase6,
};
},
render() {
return this.toggleChildrenCase6
? h('div', {}, prevChildrenCase6)
: h('div', {}, nextChildrenCase6);
},
};
可以看到确实是通用的
现在的篇幅已经太长了,还剩下一个中间对比的处理,将会放在下一篇文章进行讲解