Vue3.x源码阅读笔记(四)-Diff算法
之前讲过当新旧节点两个都是数组vnode的时候,就会执行我们的diff过程,也就是patchKeyedChildren方法。
我们可以回忆一下在Vue2中的diff,是通过头部和头部,尾部和尾部,头部和尾部等交叉对比来实现的,我们可以看一下在Vue3中,是如何实现的。
- 没有交叉对比,
Vue3中主要是通过一个时间复杂度为O(nlogn)的求最长递增子序列来实现的。 - 可复用节点的,
Vue2是老的vnode作为map,Vue3是新的vnode作为map在1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36// packages/runtime-core/src/renderer.ts
const patchKeyedChildren = (
c1, //旧的vnode
c2, // 新的vnode
container,
parentAnchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
) => {
let i = 0 // 索引记录
const l2 = c2.length // 新的vnode的长度
let e1 = c1.length - 1 // prev ending index // 旧的vnode的结尾索引
let e2 = l2 - 1 // next ending index // 新的vnode的结尾索引
while (i <= e1 && i <= e2) {
...
}
while (i <= e1 && i <= e2) {
...
}
if (i > e1) {
if (i <= e2) {
...
}
} else if (i > e2){
while (i <= e1) {
...
}
}else {
...
}
......
}diff过程中,主要会有几种情况1. 同步头部节点
第一种情况就是假如以下新旧节点为这种,a、b是相同的节点,头部相同,那么就执行patch更新节点可以看到,声明了一个索引1
2旧节点:(a b) c
新节点:(a b) d ei为0,通过while循环,依次遍历新旧节点,通过isSameVNodeType判断是否是相同节点,如果是,那么就执行patch,不是相同节点就跳过。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45const patchKeyedChildren = (
c1, //旧的vnode
c2, // 新的vnode
container,
parentAnchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
) => {
let i = 0 // 索引记录
const l2 = c2.length // 新的vnode的长度
let e1 = c1.length - 1 // prev ending index // 旧的vnode的结尾索引
let e2 = l2 - 1 // next ending index // 新的vnode的结尾索引
// 1. sync from start // 同步头部节点-头相同的情况
// (a b) c
// (a b) d e
while (i <= e1 && i <= e2) { // 如果当前i 小于等于旧的vnode长度 并且 当前i 小于等于新的vnode长度,否则结束
const n1 = c1[i] // 获取旧的vnode的每一个节点
const n2 = (c2[i] = optimized // 获取新的vnode的每一个节点
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
if (isSameVNodeType(n1, n2)) { // 判断新旧节点的key和type是否相等
// 相同的节点,递归执行patch更新节点
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else {
break
}
i++
}
}
2. 同步尾部节点
接下来就是同步尾部节点,和同步头部节点类似,这次不过是从尾部开始同步这里在经过同步头部节点之后,此时1
2旧节点:a (b c)
新节点:d e (b c)i = 2, e1是3,e2是4,满足while循环条件,从尾部开始,向前同步,如果相同就执行patch方法,然后新旧节点长度–,直到不满足i <= e1 && i <= e2。同步完成头部节点和尾部节点,剩下有几种情况处理1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46const patchKeyedChildren = (
c1, //旧的vnode
c2, // 新的vnode
container,
parentAnchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
) => {
let i = 0 // 索引记录
const l2 = c2.length // 新的vnode的长度
let e1 = c1.length - 1 // prev ending index // 旧的vnode的结尾索引
let e2 = l2 - 1 // next ending index // 新的vnode的结尾索引
// 上面同步头部节点完成之后
// i = 2, e1是3,e2是4
// 2. sync from end // 同步尾部节点-尾部相同的情况。这里的e1,e2新旧节点vnode长度,都会减少--
// a (b c)
// d e (b c)
while (i <= e1 && i <= e2) { // 和上面一样
const n1 = c1[e1] // 旧的vnode结尾
const n2 = (c2[e2] = optimized // 新的vnode结尾
? cloneIfMounted(c2[e2] as VNode)
: normalizeVNode(c2[e2]))
if (isSameVNodeType(n1, n2)) { // 从结尾向前对比,相同的进行patch
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else {
break
}
// 从后向前进行对比
e1--
e2--
}
}
- 节点有新增,新的
vnode比旧的vnode多1
2
3
4
5
6旧节点:(a b)
新节点:(a b) c
旧节点:a b
新节点:c (a b) - 节点有删除,新的
vnode比旧的vnode少1
2
3
4
5
6旧节点:(a b) c
新节点:(a b)
旧节点:a (b c)
新节点:(b c) - 未知序列,通过求最长递增子序列索引来进行计算
3. 节点有新增
同步完成头部节点和尾部节点之后,节点可能有新增,比如下面这种情况,可能是尾部有节点新增,也可能是头部有节点新增。此时i满足大于e1并且小于e2。可以看下对于这种情况的逻辑处理1
2
3
4
5
6
7// i = 2, e1 = 1, e2 = 2
旧节点:(a b)
新节点:(a b) c
// i = 0, e1 = -1, e2 = 0
旧节点:a b
新节点:c (a b)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23if (i > e1) {// 如果索引大于旧的vnode的结尾索引
if (i <= e2) { // 并且,如果 i小于新的vnode的结尾索引。证明新的vnode比旧的vnode节点多,新的增加了节点,可能是头部增加也可能是尾部增加
const nextPos = e2 + 1
const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
// 这里的意思就是,因为上面两个while已经同步了头和尾,那么这里直接逻辑是新的vnode多了,直接挂载从i到e2之间的节点
while (i <= e2) {
patch(
null,
(c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i])),
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
i++
}
}
}4. 节点有删除
如果不满足新增的逻辑,那就判断是否节点有删除的逻辑,比如这种情况这种情况就直接卸载掉多余的节点。1
2
3
4
5
6
7// i = 2, e1 = 2, e2 = 1
旧节点:(a b) c
新节点:(a b)
// i = 0, e1 = 0, e2 = -1
旧节点:a (b c)
新节点:(b c)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
let i = 0
const l2 = c2.length
// 旧子节点的尾部索引
let e1 = c1.length - 1
// 新子节点的尾部索引
let e2 = l2 - 1
// 1. 从头部开始同步
// i = 0, e1 = 4, e2 = 3
// (a b) c d e
// (a b) d e
// ...
// 2. 从尾部开始同步
// i = 2, e1 = 4, e2 = 3
// (a b) c (d e)
// (a b) (d e)
// 3. 普通序列挂载剩余的新节点
// i = 2, e1 = 2, e2 = 1
// 不满足
if (i > e1) {
}
// 4. 普通序列删除多余的旧节点
// i = 2, e1 = 2, e2 = 1
else if (i > e2) { //如果上面逻辑相反,可能是头部删除也可能是尾部删除
while (i <= e1) { // 直接unmount卸载掉旧的vnode节点,卸载从 i到e1之间的节点
// 删除节点
unmount(c1[i], parentComponent, parentSuspense, true)
i++
}
}
}5. 未知子序列
这里的情况就是,在同步完成头部节点和尾部节点,中间部分不适用于简单的挂载新增或者删除。比如这种情况对中间部分新老元素进行遍历,就免不了双重循环,所以需要一个降低复杂度的方法,把时间复杂度从1
2
3
4// [i ... e1 + 1]: a b [c d e] f g
// [i ... e2 + 1]: a b [e d c h] f g
// 同步完成头部节点和尾部节点之后的i,e1,e2分别是
// i = 2, e1 = 4, e2 = 5O(n2)降低到O(n)。
总体思路就是根据新节点建立的索引去查找老的是否存在,存在的话就直接patch,不存在就直接新增,多余的节点删除,需要移动的节点就移动。5.1 根据key建立Map索引
在Vue2中,也以类似建立键值对,但是应该是遍历老的节点建立,这里是对新的元素遍历建立。
我们在写v-for时候,都会写一个key,这个key就是用来建立索引图的,相同的话,就认为是一个节点。没有的话,后面会遍历所有的节点,会有一定的性能浪费。这里通过遍历新元素1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
let i = 0
const l2 = c2.length
// 旧子节点的尾部索引
let e1 = c1.length - 1
// 新子节点的尾部索引
let e2 = l2 - 1
// 1. 从头部开始同步
// i = 0, e1 = 7, e2 = 7
// (a b) c d e f g h
// (a b) e c d i g h
// 2. 从尾部开始同步
// i = 2, e1 = 7, e2 = 7
// (a b) c d e f (g h)
// (a b) e c d i (g h)
// 3. 普通序列挂载剩余的新节点, 不满足
// 4. 普通序列删除多余的旧节点,不满足
// i = 2, e1 = 4, e2 = 5
// 旧子序列开始索引,从 i 开始记录
const s1 = i
// 新子序列开始索引,从 i 开始记录
const s2 = i //
// 5.1 根据 key 建立新子序列的索引图
const keyToNewIndexMap = new Map()
for (i = s2; i <= e2; i++) {
const nextChild = c2[i]
/**
* a b [e d c h] f g 比如上面的例子,这里的结果就是
* {e:2,d:3,c:4,h:5} ,假设key是字符,保存下来的结果大概就是这样
*/
keyToNewIndexMap.set(nextChild.key, i) // 将新的vnode的key作为索引图中的key,值是i
}
}vnode,建立一个Map数据结构,通过set方法,将key作为Map数据的key。如同以下节点1
2
3
4
5
6
7// [i ... e1 + 1]: a b [c d e] f g
// [i ... e2 + 1]: a b [e d c h] f g
// 同步完成头部节点和尾部节点之后的i,e1,e2分别是
// i = 2, e1 = 4, e2 = 5
// 遍历完成之后keyToNewIndexMap就是
// keyToNewIndexMap = {e:2,d:3,c:4,h:5}5.2 更新和删除旧节点
已经有了新的节点作为Map,剩下就是需要遍历老的节点,有相同的节点就patch,找不到的就删除,需要移动的就去移动。这里有两个1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
let i = 0
const l2 = c2.length
// 旧子节点的尾部索引
let e1 = c1.length - 1
// 新子节点的尾部索引
let e2 = l2 - 1
// 1. 从头部开始同步
// i = 0, e1 = 7, e2 = 7
// (a b) c d e f g h
// (a b) e c d i g h
// 2. 从尾部开始同步
// i = 2, e1 = 7, e2 = 7
// (a b) c d e f (g h)
// (a b) e c d i (g h)
// 3. 普通序列挂载剩余的新节点, 不满足
// 4. 普通序列删除多余的旧节点,不满足
// i = 2, e1 = 4, e2 = 5
// 旧子序列开始索引,从 i 开始记录
const s1 = i
// 新子序列开始索引,从 i 开始记录
const s2 = i //
// 5.1 根据 key 建立新子序列的索引图
// 5.2 更新和删除节点
let j
// 新子序列已更新节点的数量,每次更新一次,这个值会增加++
let patched = 0
// 新子序列待更新节点的数量,等于新子序列的长度,就像上面中括号位置里面等更新的元素个数,上面的patched增加到toBePatched,就表示更新完了
const toBePatched = e2 - s2 + 1 // 新的vnode结尾索引-新的子序列索引+1,总的需要patch元素的数量
// 是否存在要移动的节点
let moved = false
// used to track whether any node has moved
// 用于跟踪判断是否有节点移动
let maxNewIndexSoFar = 0
// 这个数组存储新子序列中的元素在旧子序列节点的索引,用于确定最长递增子序列
// 下标是新元素的相对下标,value是老元素的下标+1
// 0 1 2 3 4 5 6
// [i ... e1 + 1]: a b [c d e] f g
// [i ... e2 + 1]: a b [e c d h] f g
// i = 2, e1 = 4, e2 = 5,比如上面这组元素,得到的结果就是[5,3,4,0],新元素第一个是e,在老节点索引是4,4+1=5
const newIndexToOldIndexMap = new Array(toBePatched)
/**
* 初始化数组,每个元素的值都是 0
* 0 是一个特殊的值,如果遍历完了仍有元素的值为 0,则说明这个新节点没有对应的旧节点
*/
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0 // 将上面的数组全部初始化为0,这里在之后的patch如果有复用会被替换成下标,否则就还是0
for (i = s1; i <= e1; i++) { // 遍历旧的vnode,e1是旧的vnode的结尾索引
const prevChild = c1[i] // c1是旧的vnode,找到第一个需要patch的元素 a b [e d c h] f g 比如说e
if (patched >= toBePatched) { // 当前patched数量已经大于等于了toBePatched总的数量,剩下的删除
unmount(prevChild, parentComponent, parentSuspense, true)
continue
}
let newIndex
if (prevChild.key != null) {
// 时间复杂度O(1)
newIndex = keyToNewIndexMap.get(prevChild.key) // 判断老的vnode在新的vnode是不是也用到了,是的话获取到当前map中的索引
} else {
// key-less node, try to locate a key-less node of the same type
// 处理没有key的情况,遍历所有新的vnode节点来判断是否存在,复杂度O(n)
for (j = s2; j <= e2; j++) {
if (
newIndexToOldIndexMap[j - s2] === 0 &&
isSameVNodeType(prevChild, c2[j] as VNode)
) {
newIndex = j
break
}
}
}
// 新老节点没有找到相同的,不考虑复用,诸直接删除
// 如果找到了,那么更新旧的vnode的索引节点
if (newIndex === undefined) {
// 直接卸载不能复用的旧的vnode
unmount(prevChild, parentComponent, parentSuspense, true)
} else {// 这里证明新老节点有可以复用的元素
/**
* ex:
* a b [c(patch) d(patch) e(patch) f(unmount)] g h
*
* a b [e c d i] g h 新的vnode记录一个map
* 最终:
* keyToNewIndexMap = {e:2,c:3,d:4,i:5}
* newIndexToOldIndexMap = [5,3,4,0] // 说明节点有移动
*/
// 更新新的vnode在在旧的vnode中的索引,+1是为了避免后续求最长递增子序列产生影响
// newIndex - s2 这里是计算一个相对位置[e c d i],计算相对这几个元素的相对位置
newIndexToOldIndexMap[newIndex - s2] = i + 1 // 上面的数组记录变化
if (newIndex >= maxNewIndexSoFar) {
// maxNewIndexSoFar 始终存储的是上次求值的 newIndex,如果不是一直递增,则说明有移动
/**
* 一旦本次求值的 newIndex 小于 maxNewIndexSoFar,这说明顺序遍历旧子序列的节点在新子序列中的索引并不是一直递增的,也就说明存在移动的情况。
*/
maxNewIndexSoFar = newIndex
} else {
// 节点被移动过。增加的元素不算
// [1,2,3] 变为 [1,2,3,10]这种不会算moved = true
moved = true
}
// 节点可以复用,做一次patch
patch(
prevChild,
c2[newIndex] as VNode,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
// 每复用一次,patched++
patched++
}
}
}for循环第一个是这个1
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0 // 将上面的数组全部初始化为0,这里在之后的patch如果有复用会被替换成下标,否则就还是0toBePatched字面意思就是需要去patch的元素,这里是根据toBePatched=e2-s2+1计算出来的,也就是新的vnode结尾索引-新的子序列索引+1,等于总的需要patch元素的数量,我们遍历这个长度,将newIndexToOldIndexMap全部填充为0,这个数组是用来记录新旧节点对应关系的,如何来记录?看第二个for循环,首先是对旧的节点遍历,如果当前patched数量已经大于等于了toBePatched总的数量,剩下的删除。
如果找得到,那么就更新newIndexToOldIndexMap,比如可以看到,记录的就是对应1
2
3
4
5
6// old vnode
// a b [c(patch) d(patch) e(patch) f(unmount)] g h
// new vnode
// a b [e c d i] g h 新的vnode记录一个map
keyToNewIndexMap = {e:2,c:3,d:4,i:5}
newIndexToOldIndexMap = [5,3,4,0] // 这里的就是对应old vnode中的位置old vnode中的位置索引,如果是0,就代表这个是新增加的元素。maxNewIndexSoFar是用来记录当前节点是否是处于递增状态,如果不是递增,那就是节点有移动,同时move也会被置为true,需要求解最长递增子序列的索引。5.3 移动和挂载节点
最后是移动节点和挂载节点
如果moved为true,那么就会getSequence求解当前最长递增子序列,比如getSequence([5,3,4,0]),就会返回[1,2],这个里面返回的元素都不需要移动,因为3、4是递增的。
接下里就是倒叙遍历新的vnode,判断newIndexToOldIndexMap是否存在为0的情况,如果是0,就代表这个元素是新增的,直接挂载。
1 | |
否则就是判断是否存在移动的元素。j<0,意味着两种情况
- 没有递增的子序列,比如完全倒叙排序
- 当前遍历的节点索引不在稳定的子序列当中,[5,3,4,0]遍历到5了,稳定序列getSequence([5,3,4,0])返回[1,2],5的i是0,0不在稳定序列[1,2]当中,所以需要移动。
1
2
3
4
5
6
7
8
9
10
11
12else if (moved) {
// 没有最长递增子序列(reverse 的场景)或者当前的节点索引不在最长递增子序列中,需要移动
// j小于0,因为刚开始j是increasingNewIndexSequence.length - 1 ,如果increasingNewIndexSequence.length为0,表示最长递增子序列里面没有值,就意味着没有稳定的子序列,可能是新节点这里倒序排列了,需要移动每一个值
// 或者[5,3,4,0]遍历到5了,稳定序列getSequence([5,3,4,0])返回[1,2],5的i是0,0不在稳定序列[1,2]当中,所以需要移动
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, anchor, 2)
}
else {
// 倒序递增子序列
j--
}
}求最长递增子序列索引
1 | |
本文标题:Vue3.x源码阅读笔记(四)-Diff算法
文章作者:Niuhk
发布时间:2022-06-28
最后更新:2022-07-23
原始链接:https://www.niuhk.cn/2022/06/28/Vue3.x源码阅读笔记(四)-Diff算法/
版权声明:转载请注明出处!
分享