vue3
底层做了很多优化,其中diff
算法是被津津乐道的优化策略之一
,也是作为前端面试的选题之一
。接下来希望能够以简洁的方式介绍明白这件事儿:
代码如下:
xml
<template>
<ul @click="changeList">
<li v-for="item in activeList" :key="item.keyId">
{{ item.name }}:{{ item.keyId }}
</li>
</ul>
</template>
<script setup>
import { ref } from "vue";
// 原来的数组
let preList = [
{ name: "A", keyId: "a" },
{ name: "B", keyId: "b" },
{ name: "C", keyId: "c" },
{ name: "D", keyId: "d" },
{ name: "E", keyId: "e" },
{ name: "F", keyId: "f" },
];
// 新的数组
let curList = [
{ name: "A", keyId: "a" },
{ name: "B", keyId: "b" },
{ name: "E", keyId: "e" },
{ name: "C", keyId: "c" },
{ name: "G", keyId: "g" },
{ name: "D", keyId: "d" },
{ name: "F", keyId: "f" },
];
// 定义响应式数据
const activeList = ref(preList);
// 定义数组改变的函数
const changeList = () => {
activeList.value = curList;
};
</script>
执行结果如下:
从代码到视图,中间所经过的核心代码如下,心里先有个印象,稍后会有图文解释。
一、源码概览
vue
的更新渲染,最后会执行到以下核心逻辑:
ini
const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized) => {
let i = 0;
const l2 = c2.length;
let e1 = c1.length - 1;
let e2 = l2 - 1;
// 1、从头部开始进行新旧vnode的比对,如果是type和key相同的新旧vnode,直接进行patch操作
while (i <= e1 && i <= e2) {
const n1 = c1[i];
const n2 = c2[i] = optimized ? cloneIfMounted(c2[i]) : normalizeVNode(c2[i]);
if (isSameVNodeType(n1, n2)) {
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
} else {
break;
}
// patch结束后,进行索引的递增
i++;
}
// 2、从尾部进行新旧vnode的比对,如果是type和key相同的新旧vnode,直接进行patch操作
while (i <= e1 && i <= e2) {
const n1 = c1[e1];
const n2 = c2[e2] = optimized ? cloneIfMounted(c2[e2]) : normalizeVNode(c2[e2]);
if (isSameVNodeType(n1, n2)) {
patch(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
} else {
break;
}
// patch结束后,进行尾索引的递减
e1--;
e2--;
}
// 3、如果旧vnode列表已经扫描结束
if (i > e1) {
// i <= e2 指的是新vnode未扫描完,未扫描到的通过while循环进行patch的新增操作
if (i <= e2) {
const nextPos = e2 + 1;
const anchor = nextPos < l2 ? c2[nextPos].el : parentAnchor;
while (i <= e2) {
patch(
null,
c2[i] = optimized ? cloneIfMounted(c2[i]) : normalizeVNode(c2[i]),
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
i++;
}
}
// 4、如果新vnode列表已经扫描结束
} else if (i > e2) {
// i <= e1 指的是旧vnode未扫描完,未扫描到的通过while循环进行unmount的移除操作
while (i <= e1) {
unmount(c1[i], parentComponent, parentSuspense, true);
i++;
}
} else {
// 5、这部分就是经过前4步掐头去尾后剩余的中间部分
const s1 = i; // 新节点开始索引
const s2 = i; // 旧节点开始索引
// (1)新vnode列表中key值和index的映射关系
const keyToNewIndexMap = /* @__PURE__ */ new Map();
for (i = s2; i <= e2; i++) {
const nextChild = c2[i] = optimized ? cloneIfMounted(c2[i]) : normalizeVNode(c2[i]);
if (nextChild.key != null) {
// 如果编写的key有重复,会报如下的警告
if (!!(process.env.NODE_ENV !== "production") && keyToNewIndexMap.has(nextChild.key)) {
warn(
`Duplicate keys found during update:`,
JSON.stringify(nextChild.key),
`Make sure keys are unique.`
);
}
// 只有在当前节点有key值的时候才会进行映射关系
keyToNewIndexMap.set(nextChild.key, i);
}
}
let j;
// 已经被patch的长度
let patched = 0;
// 需要被patch的长度
const toBePatched = e2 - s2 + 1;
let moved = false;
let maxNewIndexSoFar = 0;
// (2)去头掐尾后的中间部分vnode的索引,与其在旧节点中的映射关系
const newIndexToOldIndexMap = new Array(toBePatched);
for (i = 0; i < toBePatched; i++)
// 为newIndexToOldIndexMap赋值,
newIndexToOldIndexMap[i] = 0;
for (i = s1; i <= e1; i++) {
const prevChild = c1[i];
if (patched >= toBePatched) {
unmount(prevChild, parentComponent, parentSuspense, true);
continue;
}
let newIndex;
if (prevChild.key != null) {
newIndex = keyToNewIndexMap.get(prevChild.key);
} else {
for (j = s2; j <= e2; j++) {
if (newIndexToOldIndexMap[j - s2] === 0 && isSameVNodeType(prevChild, c2[j])) {
newIndex = j;
break;
}
}
}
if (newIndex === void 0) {
// 如果newIndex不存在,说明其在新列表中不存在,直接卸载即可
unmount(prevChild, parentComponent, parentSuspense, true);
} else {
// 这里的newIndex - s2表示从0开始重新映射关系;
// 这里的i+1表示的次序是1、2、3、4...n,目的是为了表示newIndexToOldIndexMap中的0表示新vnode在旧列表中是不存在的。
newIndexToOldIndexMap[newIndex - s2] = i + 1;
if (newIndex >= maxNewIndexSoFar) {
// 表示当前newIndex所到达的最大位置
maxNewIndexSoFar = newIndex;
} else {
// 否则,maxNewIndexSoFar都已经大于newIndex,说明当前去头掐尾的数组需要移动
moved = true;
}
patch(
prevChild,
c2[newIndex],
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
patched++;
}
}
// (3)通过贪心+二分查找的算法,求得最长递增子序列,目的是为了确定最长的无需移动的旧的节点
const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : EMPTY_ARR;
// 无语移动数组increasingNewIndexSequence的最后一位,即将和当前去头掐尾的新数组,从末位向前扫描
j = increasingNewIndexSequence.length - 1;
// (4)以下逻辑是针对于去头掐尾后,处理新列表中vnode中的逻辑
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i;
const nextChild = c2[nextIndex];
const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : parentAnchor;
// 如果新节点在旧节点中未找到,重新patch
if (newIndexToOldIndexMap[i] === 0) {
patch(
null,
nextChild,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
);
} else if (moved) {
// j < 0 表示无需移动节点扫描结束;或者当前i不是无需移动的节点时,进行移动
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, anchor, 2);
} else {
// 否则,j--,无需移动的节点-1,扫描索引i也即将-1;进行下一次循环
j--;
}
}
}
}
};
二、各步骤详情
起初新旧vnode列表如下所示:
1、首首扫描
新旧vnode列表从头开始扫描,如果是isSameVNodeType(n1, n2)为true
(type和key相同)的新旧vnode,通过patch(n1,n2)
的方式进行更新,直到isSameVNodeType(n1, n2)为false
时进行终止。此时,i
移动到了2
的位置。
2、尾尾扫描
新旧vnode列表从尾部开始扫描,如果是isSameVNodeType(n1, n2)为true
的新旧vnode,通过patch(n1,n2)
的方式进行更新,直到isSameVNodeType(n1, n2)为false
时进行终止。此时,e1
移动到了4
的位置,e2
移动了5
的位置。
3、如果旧vnode列表已经扫描结束
假设:
旧vnode列表:A B F
新vnode列表:A B C D E
F
旧节点vnode列表通过头头、尾尾的方式已经扫描完成,那么,未被扫描的C D E
就是新增的vnode,直接通过pathch(null, vnode)
的方式进行新增操作。
4、如果新vnode列表已经扫描结束
假设:
旧vnode列表:A B C D E
F
新vnode列表:A B F
新节点vnode列表通过头头、尾尾的方式已经扫描完成,那么,旧vnode列表中未被扫描的C D E
在新vnode列表中不存在,直接通过unmount(vnode)
的方式进行移除。
5、经过前4步
后的中间部分
以下这部分就是经过前4
步掐头去尾后剩余的中间部分,旧vnode列表为C D E
,新vnode列表为E D G C
。
(1)构建新vnode列表中key值和index的映射关系
keyToNewIndexMap:
构建keyToNewIndexMap的目的是,可以通过key值得到新vnode中的index值。
(2)中间部分新vnode列表的与旧节点中vnode的索引映射关系
newIndexToOldIndexMap:
结论1:这幅图表示了旧vnode列表和新vnode列表的索引对应关系,其中旧节点的每一个值都被加了1
,目的是剔除首位0
的情况,这样,旧的索引值就为0 1 2 ... n
。这里的0
就可以表示,当前新节点G
在旧vnode列表不存在,需要通过创建得到。
结论2:其次在扫描过程中,还定义了maxNewIndexSoFar
,每一次newIndex >= maxNewIndexSoFar
时会进行更新,如果整个遍历中新旧vnode列表的索引对应关系时递增的,那么就无需移动。否则,moved = true
,就是图中虚线关系。
结论3:图中例子简单,很明显5 3 0 4
的递增子序列时3 4
,对应新vnode列表中的索引就是1 3
,即C D
保持不变,G
在旧vnode中对应的是0
,需要递增,E
的值为5
,不在递增子序列3 4
中,需要移动。这里有个疑问,复杂渲染场景中1 3
这样的值是怎么得到的?这就涉及到了最长递增子序列:
(3)最长递增子序列
通过贪心+二分查找的算法,求得最长递增子序列,目的是为了确定最长的无需移动的旧的节点,vue3
中的实现为:
ini
function getSequence(arr) {
const p = arr.slice();
const result = [0];
let i, j, u, v, c;
const len = arr.length;
for (i = 0; i < len; i++) {
const arrI = arr[i];
if (arrI !== 0) {
// 如果结果中的最后一个值小于当前索引值先通过p[i]记录i的前一个索引是j;再进行push;
j = result[result.length - 1];
if (arr[j] < arrI) {
p[i] = j;
result.push(i);
continue;
}
u = 0;
v = result.length - 1;
// 二分查找法,找到当前值能够替换的结果值中的位置
while (u < v) {
c = u + v >> 1;
if (arr[result[c]] < arrI) {
u = c + 1;
} else {
v = c;
}
}
// 通过p[i] = result[u - 1]的方式记录当前值的前一个索引,并替换当前值
if (arrI < arr[result[u]]) {
if (u > 0) {
p[i] = result[u - 1];
}
result[u] = i;
}
}
}
// 以结果的最后一个值开始,通过p的对应关系,刷新result值
u = result.length;
v = result[u - 1];
while (u-- > 0) {
result[u] = v;
v = p[v];
}
return result;
}
(4)根据最长递增子序列处理中间部分
此时从新节点的尾部开始,同时,获取到的递增子序列increasingNewIndexSequence
也在从尾 --> 头进行扫描,如下图所示:
在扫描过程中,遇到D
,其索引值在increasingNewIndexSequence
中,索引-1
即可;
遇到G
,ewIndexToOldIndexMap[i] === 0
,需要进行首次渲染挂载patch(null,nextChild)
;
遇到C
,其索引值在increasingNewIndexSequence
中,索引-1
即可;
遇到E
,j < 0
,需要通过move(nextChild, container, anchor, 2)
进行移动。
总结
vue3
中的diff
本着能复用就复用
原则,先从新旧vnode
首首、尾尾进行扫描,然后处理新旧vnode
列表各自扫描完后的新增或卸载。最后,通过递增子序列
找到最长无需移动的索引,让在索引在其中的vnode
对应的dom
保持不动,处理其他需要新增或移动的dom
。