前言
既然我们这里提到了key,那么我们就不得不来简单的介绍一下key
的作用了:
在 Vue 中,key
是用于优化列表渲染的一个特殊属性。当你使用 v-for
指令来渲染一个列表时,为每个列表项提供一个唯一的 key
是很重要的。这是因为 Vue 通过 key
来识别节点,并决定是否复用已存在的 DOM 元素,以及确定如何高效地更新 DOM。
且 key
应该是唯一且稳定的,这样 Vue 才能正确地识别和管理每个节点。
在文章中我们还会对key的作用详细解释。
虚拟DOM
首先,我需要先来给大家介绍一下虚拟DOM。虚拟 DOM 可以帮助减少直接 DOM 操作的次数,从而提高性能。首先假设我们有这样一个情景:
<html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> </head> <body> <div id="app"> <ul class="list" id="list"> <li class="item" v-for="item in list">{{item}}</li> </ul> </div> <script> const { createApp, ref } = Vue createApp({ setup() { const list = ref(['html', 'css', 'vue']) return { list } } }).mount('#app') </script>
这里我们通过CDN来直接使用Vue,然后我们使用v-for
去遍历一个数组,生成一段li列表。当我们在Vue里面的template
模板里面写入一串代码时,其实对Vue的编译器来说都是输入了一段字符串。Vue最后会把这些字符串变成真实的html结构。
首先,Vue会通过编译将模板转换成渲染函数(render), 然后执行这个渲染函数就会得到一个虚拟DOM
, 我给大家看一张图片:(图片来源于网络)
我简单的用JS代码来描述一下这个虚拟DOM对象,用来帮助大家更好的去理解:
<script> // compiler 编译器 let VDom = { tagName: 'ul', props: { class: 'list', id: 'list' }, children: [ { tagName: 'li', props: { class: 'item' }, children: ['html'] }, { tagName: 'li', props: { class: 'item' }, children: ['css'] }, { tagName: 'li', props: { class: 'item' }, children: ['vue'] }, ] } </script>
大家可以看到,其实虚拟DOM
本质上就是一个对象,只是它是抽象的,是轻量级的,并且它与实际的DOM结构相对应,最终通过一系列的比较之后它也会生成真实的html结构(后面会讲)。
假设我们将响应式数据源list
进行更改,将vue
替换为js
,那么就会生成一个新的虚拟DOM
,并且最后一个children
的内容为vue
。
let newDom = { // 新虚拟dom tagName: 'ul', props: { class: 'list', id: 'list' }, children: [ { tagName: 'li', props: { class: 'item' }, children: ['html'] }, { tagName: 'li', props: { class: 'item' }, children: ['css'] }, { tagName: 'li', props: { class: 'item' }, children: ['js'] }, ] }
因为对于Vue来说,当数据源变更了,编译器是一定会进行编译的,那么也就导致生成了一份新的虚拟DOM,这时候就产生了两份虚拟DOM,一份新的,一份旧的。因为虚拟DOM最终是要转化为真实DOM结构而成为html,如果直接废除旧的虚拟DOM,而将新的虚拟DOM重新渲染到浏览器是十分不友好的,它会造成大量的回流重绘,这是十分消耗浏览器的性能的。
所以Vue官方就想出了一个新的办法,当数据源更新后,我们手上存在两份虚拟DOM,一份是旧的,一份是新的,而且在常见情况下,比如上述代码,我们只是将ul
中的一个li
的内容改了一下,而浏览器只需要重新渲染这个li
就行了,根本没有必要向刚刚一样去渲染整个页面。
因此, diff算法就出现了。
diff算法
浏览器去重新渲染整个页面造成的开销是十分大的,比如在上面的情况中,我们只需要通过某种算法去找到两份虚拟DOM结构的不同,然后这个算法会生成一个补丁包patch
,然后通过patch
去更改真实的DOM结构。
这就是diff
算法,通过比对新老虚拟DOM的不同,然后生成一个补丁包patch
,变成真实DOM。
diff算法的代码是十分复杂的,而我们并不用知道diff算法的代码,我们只需要知道diff算法的实现原理,这同样也是我们在面试过程中面试官喜欢问到的。
- 过程
- 同层比较,是不是相同的结点,不相同直接废弃老DOM
- 是相同节点,比较节点上的属性,产生一个补丁包
- 继续比较下一层的子节点,采用双端队列的方式,尽量复用,产生一个补丁包
- 同上
对端对列就是头头比较,尾尾比较,头尾比较。
并且,为了减少开销,diff算法是会尽量复用相同的结点的。而key的存在且唯一就是保证了diff算法可以更好地去复用结点。
Vue为什么不建议使用index作为key
在Vue中,对于diff算法,只要结点是相同的,那么它就是可以复用的,还是用上述的例子,我用key作为index:
!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> </head> <body> <div id="app"> <ul class="list" id="list"> <li class="item" v-for="(item, index) in list" :key="index">{{item}}</li> </ul> <button @click="add">add</button> </div> <script> const { createApp, ref } = Vue createApp({ setup() { const list = ref(['html', 'css', 'vue']) const add = () => { list.value.unshift('js') } return { add } } }).mount('#app') </script>
我们写了一个add
函数,它的作用是在list
数组前面添加一个js
,并且将它绑定为按钮的点击事件。如果这里我们使用index
作为key
,那么我们先来写一下虚拟DOM结构:
let OldDom = [ { tagName: 'li', value: 'html', key: 0 }, { tagName: 'li', value: 'css', key: 1 }, { tagName: 'li', value: 'vue', key: 2 }, ] let NewDom = [ { tagName: 'li', value: 'js', key: 0 }, { tagName: 'li', value: 'html', key: 1 }, { tagName: 'li', value: 'css', key: 2 }, { tagName: 'li', value: 'vue', key: 3 } ]
在使用index
作为下标的基础上,OldDOM
为旧的虚拟DOM,而NewDOM
则是在我们点击按钮后,触发点击事件,将js
推入数组前面,生成的一个新的虚拟DOM。我们可以看到,OldDOM
的1, 2, 3结点跟NewDOM
的2, 3, 4结点其实是相同的,diff算法应该将他们复用,但是因为它们的key
不同,那么双端队列进行比较的时候就会认为结点之间是不相同的,所以不会复用,这样会浪费性能。
或是说我将list
进行翻转一下:
let OldDom = [ { tagName: 'li', key: 0, value: 'html' }, { tagName: 'li', key: 1, value: 'css' }, { tagName: 'li', key: 2, value: 'js' }, ] let NewDom = [ { tagName: 'li', key: 0, value: 'js' }, { tagName: 'li', key: 1, value: 'css' }, { tagName: 'li', key: 2, value: 'html' }, ]
我们可以发现,新旧虚拟DOM只是调转了一下顺序,是可以进行复用的,但是因为用了数组下标作为key
值,导致数组翻转后下标也随之变动了,翻转之后key
值并不相同,所以diff算法不能很好的复用。
所以,我们需要人为的去提供key
值,而不能使用数组的index
去作为下标。
假设我们通过某种手段,去给他们添加key
值:
let OldDom = [ { tagName: 'li', key: 1, value: 'html' }, { tagName: 'li', key: 2, value: 'css' }, { tagName: 'li', key: 3, value: 'js' }, ] let NewDom = [ { tagName: 'li', key: 3, value: 'js' }, { tagName: 'li', key: 2, value: 'css' }, { tagName: 'li', key:1, value: 'html' }, ]
在diff算法进行比对的时候,发现结点是完全相同的,那么可以复用结点。
如果不使用key值
那么可能小伙伴们就有疑问了,那如果我在使用v-for
时,不用key
值,那么元素翻转完它们的结点还是相同的。
首先在Vue中,如果我们在v-for循环时不使用key
,那么首先程序会发出警告,其次,使用 key
可以帮助 Vue 准确地复用和更新 DOM 元素。
我们再想一个情景,假如我们现在list = [html, css, css]
, 我们将list
进行一个翻转:
let OldDom = [ { tagName: 'li', value: 'html' }, { tagName: 'li', value: 'css' }, { tagName: 'li', value: 'css' }, ] let NewDom = [ { tagName: 'li', value: 'css' }, { tagName: 'li', value: 'css' }, { tagName: 'li', value: 'html' }, ]
那么我们再比对新旧虚拟DOM时,发现NewDom
中第一个结点跟OldDom
中第二个和第三个结点都对应,那么根据diff算法尽量复用结点的规则,那么应该去保留哪一个呢?是第二个还是第三个。
总结
相信到现在你也明白了在v-for
循环中key
的意义了,它就是为了去准确地复用虚拟DOM结点。
并且如果在日后,我们的一些操作涉及到对列表的一些增删改查,那么我们千万不能使用数组的index
去作为key
值,这样的话diff
算法就不能很好的为我们去节省性能 。