vue2和vue3的diff算法有一些差别,这里就讲一下vue3的diff算法
vue3的diff算法的大致过程主要有以下几点
- 预处理前置节点
- 预处理后置节点
- 处理仅有新增的节点情况
- 处理仅有卸载的节点情况
- 处理其他情况(新增/卸载/移动)
预处理前置节点
过程大概是这样的
- 定义一个变量 i ,用于记录前置索引值
- 从前到后对比新旧列表,节点相同则直接patch更新,无需进一步diff(如下图)
- 节点不同,结束遍历,当前 i 值为 2。表示从这里节点开始不同
在源码中的体现如下(ps:这里的源码其实是一起的,但为了能够讲的清楚,就分开来显示了)
const patchKeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
parentAnchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
let i = 0 // 记录前置索引值
const l2 = c2.length
let e1 = c1.length - 1 // prev ending index 旧节点列表最后一个索引
let e2 = l2 - 1 // next ending index 新节点列表最后一个索引
// 1. sync from start
// (a b) c
// (a b) d e
while (i <= e1 && i <= e2) {
const n1 = c1[i]
const n2 = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
if (isSameVNodeType(n1, n2)) { // 如果新旧节点相同,直接patch更新
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else { // 否则跳出循环
break
}
i++
}
...
}
预处理后置节点
在上一过程中,我们找到了前置差异节点的起始位置。这一步开始处理列表尾部的节点
从上面这段源码中,我们可以看到这里定义了e1和e2,它们分别表示旧节点列表和新节点列表的最后一个节点的索引
大致过程与处理前置节点的步骤相似
对于源码如下
const patchKeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
parentAnchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
...
while (i <= e1 && i <= e2) {
const n1 = c1[e1]
const n2 = (c2[e2] = optimized
? cloneIfMounted(c2[e2] as VNode)
: normalizeVNode(c2[e2]))
if (isSameVNodeType(n1, n2)) {
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else {
break
}
e1--
e2--
}
...
}
此时,新旧节点列表的可复用节点就遍历完毕了,从第一步我们得到的两个节点列表中不同节点的起始位置,以及第二步我们得到两个列表尾部中不同节点的结束位置。
所以下一步我们就要来处理这一段发生变化的节点
这里的处理情况主要有三种
- 处理仅有新增节点情况(理想情况)
- 处理仅有卸载节点情况(理想情况)
- 处理其他情况(新增/卸载/移动,节点的情况可能是这些情况其中的一种或多种的组合)
仅有新增节点的情况
这种情况下,旧节点列表的长度一定小于节点列表的长度,所以e1和e2一定是不相等的,如下图
所以要如何判断当前的节点列表是新增的情况呢?
我们往后面推断一下(此时e1和e2所指的节点相同),当遇到后置节点不同的情况时,是以下这种情况
可以看到,到这里后置的情况就处理结束了,此时可以发现,在旧列表中,本来处理后置节点的e1,越过了 i,指向了i之前处理过的前置节点,并且此时e1和e2指向的节点不相同。
所以这里就可以推测出新增情况的判断条件了。
即 i>e1&&i<=e2(这里如果不理解的话,建议自己手动画个图推测以下,这里我是画了图之后就理解了)
知道是新增的情况后,只需要把列表中多出来的节点挂载即可,无需进一步diff操作
个人思考:
因为是新增情况,旧节点的长度一定是小于新节点的长度的,那为什么不直接判断e1<e2呢?
其实答案在第二步,处理后置节点的情况,因为vue3的diff算法要考虑后置节点的复用,diff算法只需要计算有差异的节点,然后进行更新即可。
对应源码
const patchKeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
parentAnchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
...
// 处理完前置和后置之后
// 如果满足 i>e1&& i<=e2 即为新增
if (i > e1) {
if (i <= e2) {
const nextPos = e2 + 1
const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
while (i <= e2) { // i 到 新节点列表的最后一个差异节点e2的索引,进行新增节点操作
patch(
null,
(c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i])),
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
i++
}
}
}
...
}
仅有删除节点的情况
与新增情况类似,此时旧节点列表的长度一定大于新节点列表的长度,再继续往后推测一下(e1--,e2--)
会得到这种情况
与上面一样,可以发现新节点列表中,e2指向了之前处理过的前置节点,并且e1和e2指向的节点不同。
由此推出 i>e2&&i<=e1 时,为删除情况
知道时删除情况后,这时,只需要把旧列表中多出的节点,卸载即可,无需进一步diff操作
const patchKeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
parentAnchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
...
// 否则如果满足 i>e2&& i<=e1 即为删除
else if (i > e2) {
while (i <= e1) { // 只需把旧列表中多出的节点卸载即可
unmount(c1[i], parentComponent, parentSuspense, true)
i++
}
}
...
}
以上两种情况是较为理想的情况了,如果是多种情况组合起来,处理的逻辑要麻烦许多
处理其他情况
比如这种情况
可以看到,这种情况下,新增了n8,删除了n3,同时n4、n5都进行移动了
这种情况要怎么处理呢?
根据前面的预处理之后,得到的结果是这样的
然后定义s1、s2变量,初始值都为i,分别记录新旧节点列表要处理部分的起始位置
然后构造新节点位置映射表,用于映射新节点与位置的索引关系,便于后续处理
对应的源码如下
const s1 = i // prev starting index
const s2 = i // next starting index
// 5.1 build key:index map for newChildren
const keyToNewIndexMap: Map<string | number | symbol, number> = new Map()
for (i = s2; i <= e2; i++) {
const nextChild = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
if (nextChild.key != null) {
if (__DEV__ && keyToNewIndexMap.has(nextChild.key)) {
warn(
`Duplicate keys found during update:`,
JSON.stringify(nextChild.key),
`Make sure keys are unique.`
)
}
keyToNewIndexMap.set(nextChild.key, i)
}
}
patch与移除
接下来定义这些变量
let j // 后续遍历newIndexToOldIndexMap时会使用到,在后续处理时,j = s2;j <= e2;j++
let patched = 0 // 记录已经patch的节点数量
const toBePatched = e2 - s2 + 1 // 记录新节点列表需要进行patch的节点数量
let moved = false
let maxNewIndexSoFar = 0
const newIndexToOldIndexMap = new Array(toBePatched) // 记录新旧节点的位置映射数组
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0 // 初始值为0
上面定义的这些变量中,先不关注moved和maxNewIndexSoFar,先关注其他几个。
- patched与toBePatched
在后续的处理中,patched用于记录旧节点列表中已经进行patch的节点数量,每处理过一个节点,这个patched的值就会自增。而toBePatched则是记录新节点列表中需要被patched的节点数量
如果旧节点列表中已经patch节点的数量大于toBePatched,则表示新节点列表中有删除的情况。只需把后面没有patch的节点进行删除即可(因为新节点只有toBePatched数量的节点进行处理)
if (patched >= toBePatched) {
// all new children have been patched so this can only be a removal
unmount(prevChild, parentComponent, parentSuspense, true)
continue
}
- newIndexToOldIndexMap
这个数组用于记录新节点列表发生变更的节点在旧节点列表中所对应的索引,该数组的下标索引为新节点对应的索引值(newIndexToOldIndexMap[j-s2]),数组的值为新节点在旧节点列表中对应的索引值,如果找不到对应索引,则为0。
这里的初始化如下
对应的源码如下
for (i = s1; i <= e1; i++) {
const prevChild = c1[i]
// 就是上面patched与toBePatched对应的代码
if (patched >= toBePatched) {
// all new children have been patched so this can only be a removal
unmount(prevChild, parentComponent, parentSuspense, true)
continue
}
let newIndex // 记录旧节点列表中的节点在新节点列表中的位置
if (prevChild.key != null) { // 如果key不为空
newIndex = keyToNewIndexMap.get(prevChild.key) // 在映射表找到该节点在新节点列表中的位置
} else { // 如果key为空
// key-less node, try to locate a key-less node of the same type
// 在c2中尝试找到一个key为null(节点没有绑定key值)的节点,如果找到这样的节点,就把j作为newIndex
for (j = s2; j <= e2; j++) {
if (
newIndexToOldIndexMap[j - s2] === 0 &&
isSameVNodeType(prevChild, c2[j] as VNode)
) {
newIndex = j
break
}
}
}
// 经过前面的处理,如果newIndex为undefined(没有被赋值,表示找不到对应的节点,直接删除)
if (newIndex === undefined) {
unmount(prevChild, parentComponent, parentSuspense, true)
} else {
newIndexToOldIndexMap[newIndex - s2] = i + 1 //
if (newIndex >= maxNewIndexSoFar) {
maxNewIndexSoFar = newIndex
} else {
moved = true
}
patch(
prevChild,
c2[newIndex] as VNode,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
patched++
}
}
示例的处理过程如下
i = 2
- 查找n3节点,在新节点映射表中没有找到对应索引,此时newIndex为undefined
- 判断newIndex是否为undefined,如果是,直接移除该节点,此时移除n3节点,continue
i = 3
-
查找n4节点,在新节点映射表中,找到n4节点位置为4,newIndex = 4
-
此时newIndex不为undefined,在新旧节点的位置映射数组中存储该位置,newIndexToOldIndexMap[newIndex - s2] = i + 1
-
然后判断newIndex >= maxNewIndexSoFar是否成立,如果成立,maxNewIndexSoFar = newIndex, 此时maxNewIndexSoFar = 4,表示当前节点在新节点列表中的最远位置为4
-
进行patch更新,并且patched值+1
ps:maxNewIndexSoFar 的作用用于记录节点在新数组中的最远位置,如果newIndex>=maxNewIndexSoFar成立,maxNewIndexSoFar呈递增,表示节点在新节点列表与旧节点列表中的相对顺序时一致的,如果不成立,说明顺改变
i = 4
步骤与 i = 3一致,maxNewIndexSoFar = 5,表示当前节点在新节点列表中的最远位置为5,
i = 5
-
查找n6节点,newIndex = 3
-
newIndexToOldIndexMap[newIndex - s2] = i + 1
-
关键这一步来了,前面得到maxNewIndexSoFar 值为5,此时newIndex < maxNewIndexSoFar,不更新maxNewIndexSoFar 的值了,而是把moved置为true,表示有节点移动了,在后续进行移动的处理
ps:moved用于记录当前节点顺序是否发生改变
旧节点列表需要遍历的节点已经全部遍历完了,此时得到的新旧节点位置映射数组中,还有一个n8节点没有在旧节点列表中找到(位置为0),表示该节点为新增节点,在后续处理过程中会进行操作
ps:建议多看几遍哟,这部分我自己也是看了挺久才看的懂。
移动与挂载节点
得到处理后的新旧节点位置映射数组(其实也算是map了)后,接下来就是要怎么处理这些步骤得到的结果了
vue3在处理移动节点的策略,是采用最长递增子序列算法,因为我们在记录新旧节点位置信息的时候,记录了节点在旧节点列表中的位置,呈递增顺序的节点我们可以直接复用。
最长递增子序列的实现过程就不在这里讲述,有兴趣的大佬可以自行查找(leetcode有对应的题目和题解)
先贴源码
// 5.3 move and mount
// generate longest stable subsequence only when nodes have moved
const increasingNewIndexSequence = moved // 如果moved为true,节点发生移动,获取新旧节点位置映射数组中,最长递增子序列
? getSequence(newIndexToOldIndexMap)
: EMPTY_ARR
j = increasingNewIndexSequence.length - 1 // 初始值为最长递增数组的最后一个节点的下标,用于判断节点是否需要移动
// looping backwards so that we can use last patched node as anchor
// 因为插入节点时使用 insertBefore 进行插入,倒序遍历可以使用上一个更新的节点作为基准,
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i
const nextChild = c2[nextIndex] as VNode
// 获取当前节点的下一个节点
// 如果下标越界,则把当前节点的父节点作为基准
const anchor =
nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
// 先处理新增情况。在旧节点列表中没有找到对应的节点索引,表示这个节点为新增节点,直接添加即可
if (newIndexToOldIndexMap[i] === 0) {
// mount new
patch(
null,
nextChild,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else if (moved) { // 如果是移动情况
// move if:
// There is no stable subsequence (e.g. a reverse)
// OR current node is not among the stable sequence
// j<0,下标越界,表示前面的递增序列已经处理完,当前节点只需移动到递增数组的前面即可
// 或者当前节点不在递增数组中,所以直接移动即可
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, anchor, MoveType.REORDER)
} else { // 当前节点在递增数组中,直接复用
j--
}
}
}
这里其实处理的情况分为三种:
- 在newIndexToOldIndexMap中用当前的新数组索引查找旧数组索引,发现是初始值
0,表示旧数组中没有这个节点,那么使用patch方法挂载一个新的节点即可。 - 当前索引没有在递增子序列中或j<0时,直接把当前的节点进行移动即可
- 当前节点在递增子序列中,直接复用,j--;
到这里,对于其他情况的处理过程就完成了
后续的其他更新情况,比如节点没有key值的情况,就不在这里讲了。由于文章的篇幅有点长(憋了挺久的草稿现在才发),能看到这里的要给你点个赞
感谢阅读!