vue3
写v-for
时,必须要写key
嘛?
答:如果无需对列表增删改,列表不会再变化,可以不写。否则,为了列表修改时的复用
,需要写,且推荐用渲染数据的唯一标识作为key
。如果实在不确定写不写,那就写,写总不会错的。
这里就涉及到了被津津乐道的优化策略之一
的diff
算法,也是作为高级前端面试的选题之一
。
接下来以一个例子,为了说明问题,我们以Dom
节点为例(组件key
的渲染机制类似),接下来我们开始探索diff
算法的底层实现原理:
ts
<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
的更新渲染,最后会执行到以下核心逻辑:
ts
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
的情况,这样,旧的索引值就变成了1 2 ... n
。图中old index
的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
中的实现为:
ts
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
,newIndexToOldIndexMap[i] === 0
,需要进行首次渲染挂载patch(null,nextChild)
;
遇到C
,其索引值在increasingNewIndexSequence
,原地复用
,索引-1
即可;
遇到E
,j < 0
,需要通过move(nextChild, container, anchor, 2)
,原地复用
,进行移动即可。
总结
vue3
中的diff
本着能复用就复用
原则,先从新旧vnode
首首、尾尾进行扫描,然后处理新旧vnode
列表各自扫描完后的新增或卸载。最后,通过递增子序列
找到最长无需移动的索引,让在索引在其中的vnode
对应的dom
保持不动,处理其他需要新增或移动的dom
。