v-for 到底为啥要加上 key?
我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情
看了一些讲解 v-for 中加 key 的文章,发现都描述的很笼统,甚至有很多不准确的,那不妨自力更生,这次直接从 vue3 的源码入手,带你了解真相,洞悉真理。
注:全文基于 vue v3.2.38 版本源码
先看看官方文档对 key 的描述:
Vue 默认按照“就地更新”的策略来更新通过
v-for
渲染的元素列表。当数据项的顺序改变时,Vue 不会随之移动 DOM 元素的顺序,而是就地更新每个元素,确保它们在原本指定的索引位置上渲染。默认模式是高效的,但只适用于列表渲染输出的结果不依赖子组件状态或者临时 DOM 状态 (例如表单输入值) 的情况。
为了给 Vue 一个提示,以便它可以跟踪每个节点的标识,从而重用和重新排序现有的元素,你需要为每个元素对应的块提供一个唯一的
key
attribute
这里,我们可以得到几个有用的信息:
- 没有 key 的元素列表会通过就地更新,保证他们在原本指定的索引位置上渲染
- 添加了唯一的 key 属性可以高效地重用和重新排序现有的元素
- 默认模式(不加 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
属性,能够收集节点本身和它所有子节点中的动态节点。当需要更新 Block 中的子节点时,只要对dynamicChildren
存放的动态子节点进行更新就可以了。
同时,由于每个动态节点都有 patchFlag 标记了它们的动态属性,所以更新也只需要更新动态节点标记的这些属性就可以了。
举个例子:
<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
}
}
}
接着我们来仔细看看这两个函数
patchKeyedChildren
有 key 的子节点数组更新会调用patchKeyedChildren
这个方法,这就是流传甚广的”vue 核心 diff 算法“,主要是根据节点绑定的 key 值进行了以下五步处理:
-
同步头节点
// 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. 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. 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. 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. 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 的节点数组怎么更新呢?
patchUnkeyedChildren
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 的子节点数组更新会调用 patchUnkeyedChildren 方法,它的实现就简单很多了:
总共只有两步:给公共长度部分节点打补丁(patch)、根据新旧子节点数组长度移除或挂载节点
-
公共长度部分节点打补丁
首先获取新、旧子节点数组的长度和公共长度部分
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 打补丁的方式对对应索引位置的新节点的一些属性直接进行更新。
-
根据长度移除多余的节点或者挂载新节点
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 的必需元素。
转载自:https://juejin.cn/post/7140446835311083534