看了一些讲解,v-for 中加key得文章,发现都描述得很笼统,甚至有很多不准确得,那不妨自力更生,这次直接从 vue3 得源码入手,带你了解真相,洞悉真理。
注:全文基于 vue v3.2.38版本源码
先看看官方文档对key的描述:
Vue 默认按照“就地更新”的策略来更新通过
v-for
渲染的元素列表。当数据项的顺序改变时,Vue 不会随之移动 DOM 元素的顺序,而是就地更新每个元素,确保它们在原本指定的索引位置上渲染。默认模式是高效的,但只适用于列表渲染输出的结果不依赖子组件状态或者临时 DOM 状态 (例如表单输入值) 的情况。
为了给 Vue 一个提示,以便它可以跟踪每个节点的标识,从而重用和重新排序现有的元素,你需要为每个元素对应的块提供一个唯一的
key
attribute
这里,我们可以得到几个有用的信息:
1.没有 key 的元素列表会通过就地更新,保证他们在原本指定的索引位置上渲染
2.添加了一堆的 key 属性可以高效地重用和重新排序现有的元素。
3.默认模式(不加key)只适用于列表渲染输出的结果不依赖子组件状态或者临时 DOM 状态的情况。
前置了解
磨刀不误砍柴工,在这之前,我们需要了解vue3 的编译优化和渲染器模块中的 patch流程
编译优化
vue3 为了渲染函数的灵活性和对 vue2 的兼容,还是选择保留了虚拟 DOM 的设计。因此不可避免地也要承担虚拟 DOM 带来的额外性能开销(相较于直接编译成原生 DOM 代码)。为了优化这一方面的开销, vue3 引入了 Block 和 PatchFlags 的概念。
首先我们需要了解一下什么是动态节点,如下一段代码
<div>
<div>我是静态</div>
<P>{{ dynamic }}</P>
</div>
上述模板中只有 dynamic 是个可以动态修改的变量,因此将 <p>{{ dynamic }}</p> 编译成的vnode 就是个动态节点。
所以优化的思路其实就是,在创建 vnode 阶段,就将这些动态节点给标记和提取出来,如果要更新,就只更新这些动态节点,静态节点保持不变。
其中 PatchFlags 就是用来标记动态节点类型的,动态节点具有如下类型:
export const enum PatchFlags {
// 文本节点
TEXT = 1,
// 动态 class
CLASS = 1 << 1,
// 动态 style
STYLE = 1 << 2,
// 具有动态属性的元素或组件
PROPS = 1 << 3,
// 具有动态 key 属性的节点更新(不包括类名和样式)
FULL_PROPS = 1 << 4,
// 带有监听事件的节点
HYDRATE_EVENTS = 1 << 5,
// 子节点顺序不会变的 Fragment
STABLE_FRAGMENT = 1 << 6,
// 带有 key 属性的 Fragment
KEYED_FRAGMENT = 1 << 7,
// 不带 key 的 Fragment
UNKEYED_FRAGMENT = 1 << 8,
// 仅对非 props 进行更新
NEED_PATCH = 1 << 9,
// 动态插槽
DYNAMIC_SLOTS = 1 << 10,
// 开发时放在根节点下的注释 Fragment,因为生产环境注释会被剥离
DEV_ROOT_FRAGMENT = 1 << 11,
// 以下是内置的特殊标记,不会在更新优化中用到
// 静态节点标记(用于手动标记静态节点跳过更新)
HOISTED = -1,
// 可以将 diff 算法退出优化模式而走全量 diff
BAIL = -2
}
Block 其实就相当于普通的虚拟节点加了个 dynamicChildren 属性,能够收集节点本身和它所有子节点中的动态节点。当需要更新 Black 中的子节点时,只要对 dynamicChildren 存放的动态子节点进行更新就可以了。
同时,由于每个动态节点都有 patch Flag 标记了它们的动态属性,所有更新也只需要更新动态节点标记的这些属性iu可以了。
举个例子:
<script setup>
import { ref } from 'vue'
const dynamic = ref('动态节点')
setTimeout(() => {
dynamic.value = '变更文本'
}, 3000)
</script>
<template>
<div>
<div>静态节点</div>
<P>{{ dynamic }}</P>
</div>
</template>
这是一个简单的文本变更过程,三秒后”动态节点“会变成”变更文本“
按照传统的 diff 流程,文本变更会生成一棵新的虚拟 DOM 树,所以对比新旧 DOM 树就需要按照虚拟DOM 的层级结构一层一层地遍历对比。上面这段模板从最外层地 div 往内一路对比过来,直接到更新 P 中地文本内容。
而有了 Block 的收集动态节点和标记动态属性的方式,在文本产生中变更需要更新的时候,只需要更新 P 节点中的文本属性。相较传统 diff 模式,简直是性能上的飞跃。大致对比如下:
上述例子中模板的根节点就是一个 Block
,因为根节点可以自上而下将它的动态子节点都收集到dynamicChildren
里去,子节点需要更新的时候再把dynamicChildren
抛出去做 diff 流程就行了。
那和 v-for 有啥关系?
v-for 指令渲染的是一个片段,会被标记为 Fragment 类型,同时 v-for 指令的节点会让虚拟树变得不稳定,所以需要将其编译为 Block。
所以 v-for 就是一个能够手机动态子节点得Block,它的子节点 patchFlag 一共有三种
STABLE_FRAGMENT
当使用 v-for 去遍历常量时,会标记为STABLE_FRAGMENT
KEYED_FRAGMENT
当使用 v-for 去遍历变量且绑定了 key,会标记为KEYED_FRAGMENT
UNKEYED_FRAGMENT
当使用 v-for 去遍历变量且没有绑定 key,会标记为KEYED_FRAGMENT
v-for去遍历常量时会被标记为
STABLE_FRAGMENT 。是因为遍历常量渲染出的子节点是不会变更顺序的,子节点中可能包含的动态子节点会走自身的更新逻辑。所以在下文中我们就可以不考虑这一类的情况。
知道以上这些知识,我们就可以继续往下了
Patch 流程
众所周知,patch 函数是 vue3 中一手承包了组件挂载和更新的,大致的 patch 流程如下:
详细的过程就不分析了,可能需要篇几万字的长文,没关系,这里我们只要关注流程的最末端
是不是很眼熟?
当使用 v-for 去遍历变量时,变量如果产生响应式更新就会走到这一步,可以看到,v-for带 key 的话会执行 patchKeyChildren 方法更新子节点,而不带 key 会执行 patchUnkeyedChildren 方法更新子节点。
所以我们只要弄清楚这两个方法的差异,就能知道v-for 带不带 key 的根本原因了!
话不多说,回到源码
当相同类的新旧 vnode 的子节点都是一组节点的时候,会根据有无 key 值分开处理:
const patchChildren: PatchChildrenFn = (
...
) => {
...
if (patchFlag > 0) {
if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
// 处理全部有 key 和部分有 key 的情况
patchKeyedChildren(
...
)
return
} else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
// 处理完全没 key 的情况
patchUnkeyedChildren(
...
)
return
}
}
}
接着我们来仔细看看这两个函数
PatchKeydChildren
有 key 的子节点数组更新会调用 patchkeyedChildren 这个方法,这就是流传甚广的“vue”核心diff算法,主要是根据节点绑定的key 值进行了以下五步处理:
1.同步头节点
// 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(
n1,
n2,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else {
break
}
i++
}
2.同步尾节点
// 2. sync from end
// a (b c)
// d e (b c)
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--
}
3.新增新的节点
// 3. common sequence + mount
// (a b)
// (a b) c
// i = 2, e1 = 1, e2 = 2
// (a b)
// c (a b)
// i = 0, e1 = -1, e2 = 0
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++
}
}
}
4.卸载多余的节点
// 4. common sequence + unmount
// (a b) c
// (a b)
// i = 2, e1 = 2, e2 = 1
// a (b c)
// (b c)
// i = 0, e1 = 0, e2 = -1
else if (i > e2) {
while (i <= e1) {
unmount(c1[i], parentComponent, parentSuspense, true)
i++
}
}
5.处理未知子序列节点
此处代码篇幅过长,且不是本文重点,就放一小部分了,感兴趣的可以自行搜索相关文章或者等我以后补发
// 5. unknown sequence
// [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
else {
const s1 = i // prev starting index
const s2 = i // next starting index
// 建立索引图
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)
}
}
// 更新和移除旧节点
...
// 移动和挂载新节点
...
可以看到,vue 对有 key 的元素更新下了这么大的功夫去处理,目的式为了对没有发生改变的节点进行复用。DOM 的频繁创建和销毁对性能不友好,所以通过 key 值复用 DOM 可以尽可能地减小这方面的性能开销。
那么,那些没有 key 的节点数组怎么更新呢?
patchUnkeydChildren
const patchUnkeyedChildren = (
c1: VNode[],
c2: VNodeArrayChildren,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
...
}
没有 key 的子节点数组更新会调用 patchUnkeydChildren 方法,它的实现就简单很多了:
总共只有两步:给公共长度部分节点打补丁(oatch)、根据新旧子节点数组长度移除或挂载节点
1.公共长度部分节点打补丁
首先获取新、旧节点数组的长度和公共长度部分
c1 = c1 || EMPTY_ARR
c2 = c2 || EMPTY_ARR
const oldLength = c1.length
const newLength = c2.length
const commonLength = Math.min(oldLength, newLength)
接着遍历共长部分,对共长部分的新子节点直接调用 patch 方法更新
let i
for (i = 0; i < commonLength; i++) {
const nextChild = (c2[i] = optimized
? cloneIfMounted(c2[i] as VNode)
: normalizeVNode(c2[i]))
patch(
c1[i],
nextChild,
container,
null,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
这里就是文章开头提到的就地更新,没有对 DOM 节点直接进行创建和删除,而是通过 patch打补丁的方式对对应索引位置的新节点的一些属性直接进行更新。
2.根据长度移除多余的节点或者挂载新节点
if (oldLength > newLength) {
// 旧子节点数组更长,将多余的节点全部卸载
unmountChildren(
c1,
parentComponent,
parentSuspense,
true,
false,
commonLength // 起始索引
)
} else {
// 新子节点数组更长,将剩余部分全部挂载
mountChildren(
c2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized,
commonLength // 起始索引
)
}
注意:unmountChildren 和 mountChildren 会传入 commonLength 作为卸载/挂载节点的起始索引遍历到节点尾部。
具体流程如下:
相比较直接用新节点覆盖旧节点来说,这种处理方式也属于一种性能上的优化,同样式减少了DOM的创建和销毁,对相同索引位置的新旧节点“就地更新”, 然后再处理剩余节点。
对比
代码描述可能不是很直观,所以用图片展示:
假设我们要将旧子节点更新为如下的新子节点
那么两种方式的更新方式分别是这样的
道理我都懂,所以这两种更新方式究竟会带来什么影响?
举个例子就明白啦
<script lang="ts" setup>
import { reactive } from 'vue'
const list = reactive([1, 2, 3, 4, 5])
// // 删除索引为 2 的输入框
const deleteInput = () => {
list.splice(2, 1)
}
</script>
<template>
<div v-for="item in list">
<input type="text">
</div>
<button @click="deleteInput">删除</button>
</template>
有一个v-for生成的输入框列表,先不绑定 key ,点击删除按钮后会将索引为2的输入框删除
我们将每个输入框中输入它们各自位置的索引,然后点击删除试一试
神奇吧,不用怀疑 splice 的用法出错,这就是更新过程就地更新会带来的后果:DOM的上一次的状态也被留在了原地
我们加上 key 再试试
<div v-for="item in list" :key="item">
<input type="text">
</div>
效果就正常了。
所以我们可以得出,没有 key 的更新过程,为了减少 dom 重复创建和销毁的开销,采用了就地更新的策略,但是这种策略会让 dom 的状态得以留存,就会出现以上在这种“更新不正确的”渲染效果,所以 vue 官方很贴心的提示了我们:默认模式(不加key)只适用于列表渲染输出的结果不依赖子组件状态或者临时DOM状态(例如表单输入值)的情况。
总结
问:
v-for 遍历列表为什么要加 key?
答:
Vue 在处理更新同类型 vnode 的一组子节点的过程中,为了减少 DOM 频繁创建和销毁的性能开销:
对没有 key 的子节点数组更新调用的是patchUnkeyedChildren
这个方法,核心是就地更新的策略。它会通过对比新旧子节点数组的长度,先以比较短的那部分长度为基准,将新子节点的那一部分直接 patch 上去。然后再判断,如果是新子节点数组的长度更长,就直接将新子节点数组剩余部分挂载(mount);如果是新子节点数组更短,就把旧子节点多出来的那部分给卸载掉(unmount)。所以如果子节点是组件或者有状态的 DOM 元素,原有的状态会保留,就会出现渲染不正确的问题。
有 key 的子节点更新是调用的patchKeyedChildren
,这个函数就是大家熟悉的实现核心 diff 算法的地方,大概流程就是同步头部节点、同步尾部节点、处理新增和删除的节点,最后用求解最长递增子序列的方法区处理未知子序列。是为了最大程度实现对已有节点的复用,减少 DOM 操作的性能开销,同时避免了就地更新带来的子节点状态错误的问题。
综上,如果是用 v-for 去遍历常量或者子节点是诸如纯文本这类没有”状态“的节点,是可以使用不加 key 的写法的。但是实际开发过程中更推荐统一加上 key,能够实现更广泛场景的同时,避免了可能发生的状态更新错误,我们一般可以使用 ESlint 配置 key 为 v-for 的必需元素。