文章转自:https://juejin.cn/post/6999932053466644517
前言
所有熟悉 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
的多余部分。
这种直接的文字描述会显得比较苍白,所以我给大家准备了个动画
:
第一步:
第二步:
第三步:
第四步:
第五步:
第六步:
理论上,只要你滑动的足够快,这几张图就可以动起来😊
上面描述updateChildren过程的图片均摘自 Vue技术揭秘 组件更新章节,建议大家翻阅原文
我尝试了半天实在做不出来动画,同时感觉这几张图已经可以带给我们足够直观的感受了,所以直接搬运了
侵删。
使用 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
,都会导致组件重新渲染,这部分的性能包袱还是非常沉重的!
最后的1分
纸上得来终觉浅,绝知此事要躬行
我第一次读完 Vue2
源码的时候,以为自己已经清晰的明白了这部分知识,直到团队里的小伙伴拿着一个纯文本类型的列表来质问我
不得已仔细 debug
了一遍更新流程
,才算解开了心中疑惑,补上了这 1分
的缺口
引用如下: