2022 年什么会火?什么该学?本文正在参与“聊聊 2022 技术趋势”征文活动
大家好,我是小新,今天分享一下我在学习 vue3 diff 时输出的笔记。diff 是 vue3 源码中比较复杂的一部分,也是对 vnode 渲染进行优化的一部分内容,因此我们有必要了解一番。下面文章写的比较长,主要是因为我边看源码,边写笔记,那相对来说也更加的细致。可能对部分同学来说一看到这么一长串文字,直接放弃了,因此我推荐分为三部分阅读。
- 先看第一、二、三、四步的操作,增强阅读源码的信心。
- 一鼓作气专门看第五步。
- 找对应的单元测试进行 debug 调试,增强思路。
我们主要看一下 packages/runtime-core/src/renderer.ts
中的 patchKeyedChildren
函数,下面的内容全部在 patchKeyedChildren
函数中。patchKeyedChildren
函数处理带有 key 的 vnode 渲染时,进行的优化策略。
设置起始位置,i=0;
设置 c1,c2 的结束坐标 e1,e2。
第一步:从最开始往右同步
while循环,从i=0,开始进行对比,取出c1,c2 中下标为 i 的vnode,n2会先进行 normallize 为Vnode。对比 n1 与 n2,有两种情况:
情况①:n1 与 n2 是同一种类型。直接调用patch 进行对比两个vnode。对比完成后,此时的n2 已经完成是正确的内容了,i 指针移入下一位。最终某一方(e1、e2)彻底完毕。这时候 i 与该方的 length 相同。
情况②:n1 与 n2 类型不同,这时候会直接 break,结束第一步的序列对比。这时候 i 会小于等于e1 、e2。
第二步:从最后往左同步
同步条件是,第一步遍历时候遇到第一个n1,n2类型不同的位置i,c1,c2 结束的坐标位置必须都没有走完。也就是第一步上图的情况。
遇到相同之后,并且两者都没有对比到最后,因为中间插入了类型不同的vnode,那么,就从后往前进行对比。这个时候,其实 i 就作为终止的标志之一了。(第一步是e1,e2 ,vnode 的类型作为终止标志)。接下来,从结尾开始对比vnode。
对比也是两种情况:
情况①:n1,n2 类型想同,那么就直接patch,完成一个vnode同步。e1,e2 指标往前移一位。直到遇到某一方的标志(e1、e2)小于 i 了,这时候说明两者中有一个已经被完全遍历完了,这时同样分为两种情况:
-
e1 (p a b )被遍历完了,e2 还有剩余的(p x a b)。这代表着e2 子序列中插入了新元素。
-
e2 (p b)被遍历完了,e1 (p a b)还有剩余,这时候代表着,e2 的子序列中有元素被删除掉了。
这两种情况会分别在第三步和第四步被处理。
情况②:n1,n2 类型不同了,结束对比,结束第二步,这个时候i 是小于等于e1,e2的。
第一步和第二步是用来处理 children 中间插入了一些其他类型的 vnode。如果是中间插入vnode 的话,那其实,做法就是把左边和右边的顺序不变的 vnode 进行同步。不过中间插入 vnode 又分为好多情况,比如:中间有一段vnode 的顺序发生改变,中间一段新增vnode,或者在中间穿插加入一些vnode,这些情况都会在第五步处理。
第三步:公共序列 + c2比c1多出来的需要挂载新增的vnode。(经过第一第二步已经找出公共序列)
比如:下面一组 vnode。
// p (a b)
//p a x (a b)
i = 1, e1 = 0, e2 = 2
必须满足,只有后面 a,b想同,a到m的组件都不同,因此需要重新挂载这几个组件。
想要挂载 a x,那必须有如下条件:
- i 大于 e1,否则如下面的情况
x y (a b)
m d c (a b)
这种情况,i 小于了e1,那么,前面的内容不是纯净的相同序列,因为x y 与 m d c序列不一致,无法进行直接挂载。
- i 必须小于等于 e2。因为如果 i 大于 e2 的话,那说明,后面e2的序列较短。
// i
if (i > e1) {
if (i <= e2) {
const nextPos = e2 + 1
const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
while (i <= e2) {
patch(
null,
(c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i])),
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
i++
}
}
}
第四步:公共序列 + c1 比 c2 多出来的子节点,需要卸载多余子节点
这里第三、四、五步是互斥的,因此,在一次patch 中,只会进三者之一。
由于第四步是处理c2 比c1短的情况,也就是说处理c1中不存在于c2的元素,第二步的情况①中已经分析过了,这就需要保证i>e2。分别去挂载c1[i]中不存在的。
第五步:patch 的保底操作,处理未知序列。
这一步是,i<e1、i<e2 ,那这一步就说明了有未知序列。这一步就是处理乱序的子序列,乱序操作比较复杂。如下的序列。
// [i ... e1 + 1]: a b [c d e] f g
// [i ... e2 + 1]: a b [e d c h] f g
// i = 2, e1 = 4, e2 = 5
5.1 第一波循环,使用 keyToNewIndexMap
来记录新序列中的乱序下标和 vnode 的key之间的映射,如果有重复的key,那会弹出warn;
5.2 第二波循环,通过遍历遗留下来的老的子节点,然后给匹配节点打补丁、移除那些不再出现的节点。用变量 maxNewIndexSoFar
记录一些节点是否已经发生移动。用 Map<newIndex, oldIndex>
来记录新旧索引位置,注意:
老的索引需要+1,
并且老的索引为0时候,这是一个特别的值,用来表示新的节点已经不再对应老的节点了。
newIndexToOldIndexMap
变量用于确定最长稳定子序列。
从s1开始遍历 e1,已经打补丁的节点数量大于等于需要被打补丁的节点,所有新的子节点已经被打补丁,因此这里只能是移除。
查找设置 newIndex为打补丁做准备,这里会判断之前的节点的key是否是 null,
如果不是 null,那么key直接从 map 中找到对应 key的新节点的索引位置。
如果是 null,即没有设置 key 的节点,尝试查找相同类型的无 key 节点。从s2开始遍历e2(从s2-e2 是因为乱序子序列是s2开头,e2结尾),判断 newIndexToOldIndexMap
中的对应节点是否是0(判断 j - s2 而不是j,是因为乱序子序列是从0 开始的,如果直接用j,那就有可能忽略掉newIndexToOldIndexMap
前面的几个元素),且类型是否相同。 查找新索引之后会有2种结果,1 没找到新索引,2找到新索引。
- 没找到新索引,那么newIndex 肯定还是 undefined,因此,需要删除掉这个节点。
- 找到新索引,会给对应的
newIndexToOldIndexMap
位置的值设置+1(这样在下次循环时候,就不会再找到这个节点了。),然后判断查找到的索引是不是目前为止最大的新索引,是的话就替换 maxNewIndexSoFar = newIndex。如果不是当前最大索引,那么就需要进行移动 moved =true。(maxNewIndexSoFar 作用是为了确定这个无序子序列是否发生的节点的移动,比如后面的节点移到前面了。试想一下,每次查找的时候,都会找到一个新的索引,当突然发现一个索引的位置居然没有超过当前最新节点的位置,那说明什么,很显然,是不是说明新找到的节点位置在最大节点位置之前,那就表示这个无序节点发生的位置移动。 )接下来会用老节点与c2新节点打补丁, patched 自增。
patch(
prevChild,
// 这里是查找到的类型相同的新节点
c2[newIndex] as VNode,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
5.3 移动和挂载
increasingNewIndexSequence
变量表示仅当节点已经被移动时,生成最长稳定子序列数组。向后遍历,因为这样我们可以使用最新的打过补丁的 node 作为锚点。
这一步会挂载所有 newIndexToOldIndexMap 中还是0的节点。如果节点不为0,那就需要进行移动。如果不是0,并且是移动的话,需要判断 j是否小于0,如果小于0了,说明
在经过5.2的时候,会有一个判断节点是否发生移动的过程,如果节点没有发生移动的话,那对应的children索引一定是递增的,假如发现一个新children中某个节点的索引下标变小了,那说明这个元素发生的移动。如果知道了最大递增子序列的下标,那么就可以确定一部分节点的位置不需要进行移动, toBePatched
正好就是作为可以进行
最后,我挑选了一个vue3中的单元测试例子,我们走一遍。
可以先自己在脑子里走一遍这个流程,然后再debug看一下这个过程。
test('moving single child forward', () => {
elm = renderChildren([1, 2, 3, 4])
expect(elm.children.length).toBe(4)
elm = renderChildren([2, 3, 1, 4])
expect(elm.children.length).toBe(4)
expect((elm.children as TestElement[]).map(inner)).toEqual([
'2',
'3',
'1',
'4'
])
})
c1 = [1,2,3,4];
c2 = [2,3,1,4];
第一步:循环结果,i=0,e1 = 3, e2 = 3;
第二步:i= 0不变,e1 = 2,e2 = 2;
由于i<e1=e2;因此不存在 挂载与卸载操作。
进入第五步完成已知序列patch,处理未知序列。
5.1
s1 = s2 = 0;
keyToNewIndexMap = {};
从s2 到 e2进行遍历,结果是 keyToNewIndexMap 储存3个key。
// children key->children index
keyToNewIndexMap:{
2:0,
3:1,
1:3,
}
5.2
patched = 0;
toBePatched = 3;
move = false;
maxNexIndexSoFar = 0;
newIndexToOldIndexMap = [];
经过循环 toBePatched ,结果 newIndexToOldIndexMap = [0,0,0,0];
循环e1,找相同类型或者相同key的节点,进行patch 找不到 unount;
结果是:newIndexToOldIndexMap[3] = 1;
newIndexToOldIndexMap[0] = 2;move = true;
newIndexToOldIndexMap[1]=3;
都进行过 patch了。newIndexToOldIndexMap = [2,3,1];
5.3
increasingNewIndexSequence = [0,1] 获取最长子序列的索引。
j=1;
i = 2 increasingNewIndexSequence[1]=1;move;
i = 1 increasingNewIndexSequence[1]=1; j--;
i = 0 increasingNewIndexSequence[0]=0;j--;
最后i=-1, j=-1;
5.3的循环中,移动的条件是:没有稳定子序列(反序)或者当前节点不在稳定子序列中。
以上就是我对vue3中的diff算法的理解,其中也有一些可能说的不是特别清楚,但是我最大程度的按照源码逻辑进行描述了,也算是逐行说明了吧,有疑问欢迎与我讨论,共同进步。