浅析为什么推荐使用唯一不变的 id 作为 key、使用index作为key会导致的问题(复用错误、组件类型propsData或文本node触发重新渲染等问题)
所有熟悉 Vue
技术栈的小伙伴,都知道在列表渲染的场景下,不能使用 index
或 random
作为 key
。
也有很多小伙伴在面试的时候会被面试官比较详细的追问,假如使用index
作为 key
会有什么问题?假如使用 random
作为 key
会有什么问题?假如使用一个唯一不变的 id
作为 key
有什么好处呢?
这道题目,表面上看起来是考察我们对同级比较过程中 diff
算法的理解,唯一不变的 key
可以帮助我们更快的找到可复用的 VNode
,节省性能开销,使用 index
作为 key
有可能造成 VNode
错误的复用,从而产生 bug
,而使用 random
作为 key
会导致VNode
始终无法复用,极大的影响性能。
这么回答有问题么?没有问题。但是假如这道题目满分100,我只能给你99分。还有 1分
,涉及到 Vue
更新流程
中的一点点细节,若不理清,可能在实际的业务场景中给我们造成困扰。啥困扰呢?
一、index
作为 key
,假如我们删除某一条,结果会是啥呢?
<template>
<div id="app">
<div v-for="(item, index) in data" :key="index">
<Child />
<button @click="handleDelete(index)">删除这一行</button>
</div>
</div>
</template>
<script>
export default {
name: "App",
components: {
Child: {
template: '<span>{{name}}{{Math.floor(Math.random() * 1000)}}</span>',
props: ['name']
}
},
data() {
return {
data: [
{ name: "小明" },
{ name: "小红" },
{ name: "小蓝" },
{ name: "小紫" },
]
};
},
methods: {
handleDelete(index) {
this.data.splice(index, 1);
},
}
};
</script>
看结果可以观察到:虽然我们删除的不是最后一条,但最终却是最后一条被删除了。看起来很奇怪,但是假如你了解过 Vue
的 diff
流程,这个结果应该是可以符合你的预期的。
二、diff
大段的列源码,会增加我们的理解负担,所以我把 Vue
的更新流程
简化成一张图:
通常来讲,我们说 Vue
的 diff
流程,指的就是 patchVnode
,其中 updateChildren
就是我们说的同层比较,其实就是比较新旧两个 Vnode
数组。
Vue
会声明四个指针变量,分别记录新旧 Vnode
数组的首尾索引,通过首尾索引指针的移动,根据新头旧头、新尾旧尾、旧头新尾、旧尾新头的顺序,依次比较新旧 Vnode
,若不能命中 sameVnode
,则将oldVnode.key
维护成一个 map
, 继续查询是否包含newVnode.key
,若命中 sameVnode
,则递归执行 patchVnode
。若最终无法命中,说明无可复用的 Vnode
,创建新的 dom
节点。
若 newVnode
的首尾指针先相遇,说明 newVnode
已经遍历完成,直接移除 oldVnode
多余部分,若 oldVnode
的首尾指针先相遇,说明 oldVnode
已经遍历完成,直接新增 newVnode
的多余部分。
三、使用 index
作为 key
会有什么问题
上面我们讲,判断新旧 Vnode
是否可以复用,取决于 sameNode
方法,这个方法非常简单,就是比对 Vnode
的部分属性,其中 key
是最关键的因素
function sameVnode (a, b) {
return (
a.key === b.key &&
a.asyncFactory === b.asyncFactory && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
isUndef(b.asyncFactory.error)
)
)
)
}
我们再回到上面的栗子,看看是哪里出了问题。上面代码生成的 VNode
大约是这样的:
[
{
tag: 'div',
key: 0,
children: [
{
tag: VueComponent,
elm: 408, // 这个Vnode对应的真实dom是408
},
{
tag: 'button'
}
]
},
{
tag: 'div',
key: 1,
children: [
{
tag: VueComponent,
elm: 227, // 这个Vnode对应的真实dom是227
},
{
tag: 'button'
}
]
}
...
]
// 我们删除第一条数据,新的 VNode 大约是这样的:
[
{
tag: 'div',
key: 0,
children: [
{
tag: VueComponent,
elm: 227, // 这个Vnode对应的真实dom是227
},
{
tag: 'button'
}
]
},
{
tag: 'div',
key: 1,
children: [
{
tag: VueComponent,
elm: 324, // 这个Vnode对应的真实dom是324
},
{
tag: 'button'
}
]
}
...
]
我们人肉逻辑 一下这两个 Vnode
数组,由于 key
都是0,所以比较第一条的时候,就会命中 sameNode ,导致错误复用,然后updateChildren
,子节点的 Vnode
依然会命中 sameVnode
,同理,第二、三条均会命中 sameVnode
,而直接错误复用其关联的真实 dom
节点,所以我们明明删除的是第一条,UI表现却是最后一条被删除了。
那么到这里就结束了么?当然没有,因为很多小伙伴在刚接触 Vue
的时候,也用过 index
作为 key
,部分牛逼的项目甚至已经上线了,似乎也没人来找麻烦。why?
三、为什么我用 index
作为 key
没出现问题
如果我把代码改成这样,再删除某一条,会是什么结果呢?
<template>
<div id="app">
<div v-for="(item, index) in data" :key="index">
<Child :name="`${item.name}`" />
<button @click="handleDelete(index)">删除这一行</button>
</div>
</div>
</template>
看结果观察却是正常的。为什么?我们明明把 Vue
的更新流程
捋清楚了,用 index
作为 key
会导致 Vnode
错误复用啊,怎么这里表现却正常了呢?
具体原因:组件类型的 Vnode
,在 patchVnode
的过程中会执行 prePatch
钩子函数,给组件的 propsData
重新赋值,从而触发 setter
,假如propsData
的值有变化,则会触发 update
,重新渲染组件。
我们可以再人肉逻辑 一下,这次我们删除的是第二条,因为key
一致,新的 Vnode
数组依然会复用旧的 Vnode
数组的前三条,第一条Vnode
是正确复用,组件的 propsData
未发生变化,不会触发 update
,直接复用其关联的真实 dom
节点,但是第二条 Vnode
是错误复用,但是组件的 propsData
发生变化,由小红变成了小蓝,触发了 update
,组件重新渲染,因此我们看到其实连 random
都发生了变化,第三条同理。
那么到这里就结束了么?其实还没有,比如我们再改一下代码:
<template>
<div id="app">
<div v-for="(item, index) in data" :key="index">
<span>{{item.name}}</span>
<button @click="handleDelete(index)">删除这一行</button>
</div>
</div>
</template>
看结果观察也是正常的。为什么?这次我们没有组件类型 Vnode
,不会执行 prePatch
,为啥表现还是正常的呢?
再观察一下上面的更新流程
图,文本类型的 Vnode
,新旧文本不同的时候是会直接覆盖的。
到这里,我们已经完全明白,列表渲染的场景下,为什么推荐使用唯一不变的 id
作为 key
了。
抛开代码规范不谈,即使某些场景下,问题并未以 bug
的形式暴露出来,但是不能复用、或者错误复用 Vnode
,都会导致组件重新渲染,这部分的性能包袱还是非常沉重的!
作者:Fatty
链接:https://juejin.cn/post/6999932053466644517