Vue 中的 key 是用来作什么的?为何不推荐使用 index 做为 key?经常据说这样的问题,本篇文章带你从原理来一探究竟。前端
本文的结论对于性能的毁灭是针对列表子元素顺序被改变、或者子元素被删除的特殊状况,提早说明清楚。vue
本篇已经收录在 Github 仓库,欢迎 Star:node
以这样一个列表为例:git
<ul>
<li>1</li>
<li>2</li>
</ul>
复制代码
那么它的 vnode
也就是虚拟 dom 节点大概是这样的。github
{ tag: 'ul', children: [ { tag: 'li', children: [ { vnode: { text: '1' }}] }, { tag: 'li', children: [ { vnode: { text: '2' }}] }, ] } 复制代码
假设更新之后,咱们把子节点的顺序调换了一下:算法
{ tag: 'ul', children: [ + { tag: 'li', children: [ { vnode: { text: '2' }}] }, + { tag: 'li', children: [ { vnode: { text: '1' }}] }, ] } 复制代码
很显然,这里的 children
部分是咱们本文 diff
算法要讲的重点(敲黑板)。数据库
首先响应式数据更新后,触发了 渲染 Watcher
的回调函数 vm._update(vm._render())
去驱动视图更新,后端
vm._render()
其实生成的就是 vnode
,而 vm._update
就会带着新的 vnode
去走触发 __patch__
过程。api
咱们直接进入 ul
这个 vnode
的 patch
过程。
对比新旧节点是不是相同类型的节点:
isSameNode
为false的话,直接销毁旧的 vnode
,渲染新的 vnode
。这也解释了为何 diff
是同层对比。
ul
,进入👈)。会调用src/core/vdom/patch.js
下的patchVNode
方法。
就直接调用浏览器的 dom api
把节点的直接替换掉文字内容就好。
那么就要开始对子节点 children
进行对比了。(能够类比 ul
中的 li
子元素)。
说明是新增 children,直接 addVnodes
添加新子节点。
说明是删除 children,直接 removeVnodes
删除旧子节点
li 子节点列表
,进入👈)那么就是咱们 diff算法
想要考察的最核心的点了,也就是新旧节点的 diff
过程。
能够打开源码仓库里大体看下这个函数,接下来我会逐步讲解。
经过
// 旧首节点 let oldStartIdx = 0 // 新首节点 let newStartIdx = 0 // 旧尾节点 let oldEndIdx = oldCh.length - 1 // 新尾节点 let newEndIdx = newCh.length - 1 复制代码
这些变量分别指向旧节点的首尾
、新节点的首尾
。
根据这些指针,在一个 while
循环中不停的对新旧节点的两端的进行对比,而后把两端的指针向不断内部收缩,直到没有节点能够对比。
在讲对比过程以前,要讲一个比较重要的函数:sameVnode
:
function sameVnode (a, b) { return ( a.key === b.key && ( ( a.tag === b.tag && a.isComment === b.isComment && isDef(a.data) === isDef(b.data) && sameInputType(a, b) ) ) ) } 复制代码
它是用来判断节点是否可用的关键函数,能够看到,判断是不是 sameVnode
,传递给节点的 key
是关键。
而后咱们接着进入 diff
过程,每一轮都是一样的对比,其中某一项命中了,就递归的进入 patchVnode
针对单个 vnode
进行的过程(若是这个 vnode
又有 children
,那么还会来到这个 diff children
的过程 ):
旧首节点和新首节点用 sameNode
对比。
旧尾节点和新尾节点用 sameNode
对比
旧首节点和新尾节点用 sameNode
对比
旧尾节点和新首节点用 sameNode
对比
若是以上逻辑都匹配不到,再把全部旧子节点的 key
作一个映射到旧节点下标的 key -> index
表,而后用新 vnode
的 key
去找出在旧节点中能够复用的位置。
而后不停的把匹配到的指针向内部收缩,直到新旧节点有一端的指针相遇(说明这个端的节点都被patch过了)。
在指针相遇之后,还有两种比较特殊的状况:
有新节点须要加入。 若是更新完之后,oldStartIdx > oldEndIdx
,说明旧节点都被 patch
完了,可是有可能还有新的节点没有被处理到。接着会去判断是否要新增子节点。
有旧节点须要删除。 若是新节点先patch完了,那么此时会走 newStartIdx > newEndIdx
的逻辑,那么就会去删除多余的旧子节点。
假设咱们有这样的一段代码:
<div id="app"> <ul> <item :key="index" v-for="(num, index) in nums" :num="num" :class="`item${num}`" ></item> </ul> <button @click="change">改变</button> </div> <script src="./vue.js"></script> <script> var vm = new Vue({ name: "parent", el: "#app", data: { nums: [1, 2, 3] }, methods: { change() { this.nums.reverse(); } }, components: { item: { props: ["num"], template: ` <div> {{num}} </div> `, name: "child" } } }); </script> 复制代码
实际上是一个很简单的列表组件,渲染出来 1 2 3
三个数字。咱们先以 index
做为key,来跟踪一下它的更新。
咱们接下来只关注 item
列表节点的更新,在首次渲染的时候,咱们的虚拟节点列表 oldChildren
粗略表示是这样的:
[ { tag: "item", key: 0, props: { num: 1 } }, { tag: "item", key: 1, props: { num: 2 } }, { tag: "item", key: 2, props: { num: 3 } } ]; 复制代码
在咱们点击按钮的时候,会对数组作 reverse
的操做。那么咱们此时生成的 newChildren
列表是这样的:
[ { tag: "item", key: 0, props: { + num: 3 } }, { tag: "item", key: 1, props: { + num: 2 } }, { tag: "item", key: 2, props: { + num: 1 } } ]; 复制代码
发现什么问题没有?key的顺序没变,传入的值彻底变了。这会致使一个什么问题?
原本按照最合理的逻辑来讲,旧的第一个vnode
是应该直接彻底复用 新的第三个vnode
的,由于它们原本就应该是同一个vnode,天然全部的属性都是相同的。
可是在进行子节点的 diff
过程当中,会在 旧首节点和新首节点用
sameNode对比。
这一步命中逻辑,由于如今新旧两次首部节点
的 key
都是 0
了,
而后把旧的节点中的第一个 vnode
和 新的节点中的第一个 vnode
进行 patchVnode
操做。
这会发生什么呢?我能够大体给你列一下: 首先,正如我以前的文章props的更新如何触发重渲染?里所说,在进行 patchVnode
的时候,会去检查 props
有没有变动,若是有的话,会经过 _props.num = 3
这样的逻辑去更新这个响应式的值,触发 dep.notify
,触发子组件视图的从新渲染等一套很重的逻辑。
而后,还会额外的触发如下几个钩子,假设咱们的组件上定义了一些dom的属性或者类名、样式、指令,那么都会被全量的更新。
而这些全部重量级的操做(虚拟dom发明的其中一个目的不就是为了减小真实dom的操做么?),均可以经过直接复用 第三个vnode
来避免,是由于咱们偷懒写了 index
做为 key
,而致使全部的优化失效了。
另外,除了会致使性能损耗之外,在删除子节点
的场景下还会形成更严重的错误,
假设咱们有这样的一段代码:
<body> <div id="app"> <ul> <li v-for="(value, index) in arr" :key="index"> <test /> </li> </ul> <button @click="handleDelete">delete</button> </div> </div> </body> <script> new Vue({ name: "App", el: '#app', data() { return { arr: [1, 2, 3] }; }, methods: { handleDelete() { this.arr.splice(0, 1); } }, components: { test: { template: "<li>{{Math.random()}}</li>" } } }) </script> 复制代码
那么一开始的 vnode列表
是:
[ { tag: "li", key: 0, // 这里其实子组件对应的是第一个 假设子组件的text是1 }, { tag: "li", key: 1, // 这里其实子组件对应的是第二个 假设子组件的text是2 }, { tag: "li", key: 2, // 这里其实子组件对应的是第三个 假设子组件的text是3 } ]; 复制代码
有一个细节须要注意,正如我上一篇文章中所提到的为何说 Vue 的响应式更新比 React 快?,Vue 对于组件的 diff
是不关心子组件内部实现的,它只会看你在模板上声明的传递给子组件的一些属性是否有更新。
也就是和v-for平级的那部分,回顾一下判断 sameNode
的时候,只会判断key
、 tag
、是否有data的存在(不关心内部具体的值)
、是不是注释节点
、是不是相同的input type
,来判断是否能够复用这个节点。
<li v-for="(value, index) in arr" :key="index"> // 这里声明的属性 <test /> </li> 复制代码
有了这些前置知识之后,咱们来看看,点击删除子元素后,vnode 列表
变成什么样了。
[ // 第一个被删了 { tag: "li", key: 0, // 这里其实上一轮子组件对应的是第二个 假设子组件的text是2 }, { tag: "li", key: 1, // 这里其实子组件对应的是第三个 假设子组件的text是3 }, ]; 复制代码
虽然在注释里咱们本身清楚的知道,第一个 vnode
被删除了,可是对于 Vue 来讲,它是感知不到子组件里面究竟是什么样的实现(它不会深刻子组件去对比文本内容),那么这时候 Vue 会怎么 patch
呢?
因为对应的 key
使用了 index
致使的错乱,它会把
原来的第一个节点text: 1
直接复用。原来的第二个节点text: 2
直接复用。text: 3
丢掉。至此为止,咱们本应该把 text: 1
节点删掉,而后text: 2
、text: 3
节点复用,就变成了错误的把 text: 3
节点给删掉了。
<item :key="Math.random()" v-for="(num, index) in nums" :num="num" :class="`item${num}`" /> 复制代码
其实我听过一种说法,既然官方要求一个 惟一的key
,是否是能够用 Math.random()
做为 key
来偷懒?这是一个很鸡贼的想法,看看会发生什么吧。
首先 oldVnode
是这样的:
[ { tag: "item", key: 0.6330715699108844, props: { num: 1 } }, { tag: "item", key: 0.25104533240710514, props: { num: 2 } }, { tag: "item", key: 0.4114769152411637, props: { num: 3 } } ]; 复制代码
更新之后是:
[ { tag: "item", + key: 0.11046018699748683, props: { + num: 3 } }, { tag: "item", + key: 0.8549799545696619, props: { + num: 2 } }, { tag: "item", + key: 0.18674467938937478, props: { + num: 1 } } ]; 复制代码
能够看到,key
变成了彻底全新的 3 个随机数。
上面说到,diff
子节点的首尾对好比果都没有命中,就会进入 key
的详细对比过程,简单来讲,就是利用旧节点的 key -> index
的关系创建一个 map
映射表,而后用新节点的 key
去匹配,若是没找到的话,就会调用 createElm
方法 从新创建 一个新节点。
具体代码在这:
// 创建旧节点的 key -> index 映射表 oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx); // 去映射表里找能够复用的 index idxInOld = findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx); // 必定是找不到的,由于新节点的 key 是随机生成的。 if (isUndef(idxInOld)) { // 彻底经过 vnode 新建一个真实的子节点 createElm(); } 复制代码
也就是说,我们的这个更新过程能够这样描述: 123
-> 前面从新建立三个子组件 -> 321123
-> 删除、销毁后面三个子组件 -> 321
。
发现问题了吧?这是毁灭性的灾难,建立新的组件和销毁组件的成本大家晓得的伐……原本仅仅是对组件移动位置就能够完成的更新,被咱们毁成这样了。
通过这样的一段旅行,diff
这个庞大的过程就结束了。
咱们收获了什么?
用组件惟一的 id
(通常由后端返回)做为它的 key
,实在没有的状况下,能够在获取到列表的时候经过某种规则为它们建立一个 key
,并保证这个 key
在组件整个生命周期中都保持稳定。
若是你的列表顺序会改变,别用 index
做为 key
,和没写基本上没区别,由于无论你数组的顺序怎么颠倒,index 都是 0, 1, 2
这样排列,致使 Vue 会复用错误的旧子节点,作不少额外的工做。列表顺序不变也尽可能别用,可能会误导新人。
千万别用随机数做为 key
,否则旧节点会被所有删掉,新节点从新建立,你的老板会被你气死。
这篇文章发布之后,不少小伙伴提出了本身的建议和优化。可是也有不少人在评论区说,既然 index
只是在某些特定的场景下会出问题,那 列表顺序保持不变
的状况下仍是能够接着用。这样作有什么问题呢?
团队代码规范,假设这样一个场景吧,你这边代码里所有写的 :key="index"
,有一个新人入职了跟着写,结果他的场景是删除和乱序的,这种状况你一个个讲原理指正?这就是统一代码规范和最佳实践的做用啊。eslint
甚至也专门有一个 rule
叫作 react/no-array-index-key
,为何要有这些约束和规范?若是社区总结了最佳实践,为何必定要去打破它?这都是值得思考的。 就像 ==
操做符,为何要禁止?就是由于隐式转换会出不少问题,你说你熟背隐式转换全部原理,你能保证团队全部小伙伴都熟背?何苦有更简单的 ===
操做符能够用。
说开发效率的问题,index
做为 key
我在上面已经提到了好几种会出问题的状况了,仍是坚持要用,就由于简单。那么 TypeScript
也没有火起来的必要吗?它须要多写不少代码,“效率” 很低,为何它火了?不是由于用 JavaScript
就必定会出现类型错误,而是由于用了 TypeScript
能够更好的保证你代码的稳定性。正如用了 id
做为key,能够比 index
更好的保证稳定性,更况且用 id
也不费事啊。彻底都不像 TypeScript
带来的额外的语法成本。
所谓的列表顺序稳定,这个稳定你真的能保证吗?除了你前端写死的永远不变的一个列表,就假设你的列表没有在头部新增一项
(致使节点所有依次错误复用),在任意位置 删除一项
(有时致使错误删除)等这些会致使 patch
过程出现问题的操做。 就举个很简单的例子,你的“静态”列表的顺序是[1, 2, 3]
,数据库里忽然加入了一条新数据0
,那么你认为的不会变的列表的就变成了[0, 1, 2, 3]
。而后,1
节点就错误的和 0
节点进行 patchVnode
, 2
节点就错误的和 1
节点进行 patch
、致使本来只须要把新增的0
节点插入到头部,而后分别对 1 -> 1
、2 -> 2
、3 -> 3
进行 patchVnode
便可(基本没有变化),变成了毁灭的全量更新
。(若是子组件是个很重的组件呢?它的每一项都会经历完整的 vm._update(vm._render())
)过程,由于 props
变了。
1.若是本文对你有帮助,就点个赞支持下吧,你的「赞」是我创做的动力。
2.关注公众号「前端从进阶到入院」便可加我好友,我拉你进「前端进阶交流群」,你们一块儿共同交流和进步。