vue中关于v-for循环key值问题的研究
作者:小白路过
介绍
关于key的作用,官方是这样描述的
key
的特殊属性主要用在 Vue 的虚拟 DOM 算法,在新旧 nodes 对比时辨识 VNodes。
如果不使用 key,Vue 会使用一种最大限度减少动态元素并且尽可能的尝试修复/再利用相同类型元素的算法。
使用 key,它会基于 key 的变化重新排列元素顺序,并且会移除 key 不存在的元素。
有相同父元素的子元素必须有独特的 key。重复的 key 会造成渲染错误。
之前不知道在哪里看的文章,一直以为使用key是为了就地复用,但是从上面的描述中可以看到,实际上不用key,vue也会尽可能的尝试使用复用修复策略,没有设置key的时候,vnode的key值就是undefined,而在源码patchVnode函数中有一个判断函数sameVnode用于判断是否是相同节点。
A.key(undefined )=== B.key(undefined)
也是判断为相同节点的。
/* 判断两个VNode节点是否是同一个节点,需要满足以下条件 key相同 tag(当前节点的标签名)相同 isComment(是否为注释节点)相同 是否data(当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型,可以参考VNodeData类型中的数据信息)都有定义 当标签是<input>的时候,type必须相同 */ 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) ) }
由此也可以看出,如果要想不复用,可以通过给组件以不同的key值来实现,用于强制替换元素/组件而不是重复使用它。
作用
既然key值不是用于就地复用的目的,那为什么要设置这个key值呢?
上面说使用key值时,提到了重新排列顺序及删除的问题。
查看源码中patch函数中对于key值得应用,主要看updateChildren
中节点比较部分,在前面的新旧首尾互比中,设置key和不设置key的比对是一样的,在下面这块,设置key的作用就凸显出来了
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) { ... //新旧首尾互比之后 /* 生成一个key与旧VNode的key对应的哈希表(只有第一次进来undefined的时候会生成,也为后面检测重复的key值做铺垫) 比如childre是这样的 [{xx: xx, key: 'key0'}, {xx: xx, key: 'key1'}, {xx: xx, key: 'key2'}] beginIdx = 0 endIdx = 2 结果生成{key0: 0, key1: 1, key2: 2} */ if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) /*如果newStartVnode新的VNode节点存在key并且这个key在oldVnode中能找到则返回这个节点的idxInOld(即第几个节点,下标)*/ idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : null if (isUndef(idxInOld)) { // New element /*newStartVnode没有key或者是该key没有在老节点中找到则创建一个新的节点*/ createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm) newStartVnode = newCh[++newStartIdx] } else { /*获取同key的老节点*/ elmToMove = oldCh[idxInOld] /* istanbul ignore if */ if (process.env.NODE_ENV !== 'production' && !elmToMove) { /*如果elmToMove不存在说明之前已经有新节点放入过这个key的Dom中,提示可能存在重复的key,确保v-for的时候item有唯一的key值*/ warn( 'It seems there are duplicate keys that is causing an update error. ' + 'Make sure each v-for item has a unique key.' ) } if (sameVnode(elmToMove, newStartVnode)) { /*如果新VNode与得到的有相同key的节点是同一个VNode则进行patchVnode*/ patchVnode(elmToMove, newStartVnode, insertedVnodeQueue) /*因为已经patchVnode进去了,所以将这个老节点赋值undefined,之后如果还有新节点与该节点key相同可以检测出来提示已有重复的key*/ oldCh[idxInOld] = undefined /*当有标识位canMove实可以直接插入oldStartVnode对应的真实Dom节点前面*/ canMove && nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm) newStartVnode = newCh[++newStartIdx] } else { // same key but different element. treat as new element /*当新的VNode与找到的同样key的VNode不是sameVNode的时候(比如说tag不一样或者是有不一样type的input标签),创建一个新的节点*/ createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm) newStartVnode = newCh[++newStartIdx] } } ... }
从上面的源码中可以看到,key值作为快速索引,用于查找当前vnode在旧的vnode序列中的位置。
假设:oldVnodes里有
[{xx: xx, key: 'key0'}, {xx: xx, key: 'key1'}, {xx: xx, key: 'key2'}];
新的vnodes有
[{xx: xx, key: 'key1'}, {xx: xx, key: 'key3'}, {xx: xx, key: ‘key2'}]
在循环遍历新的vnodes时,例如key1,在oldVnodes中找到了,但是数组索引位置(1)和当前新的vnodes中的位置(0)不一样,表示需要进行节点移动,此时做的操作
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
是用旧的vnode队列中key值对应的节点来复用patch生成新的vnode,然后进行节点移动,节点移动后,会将oldVnodes中(1)位置清空,置为undefined。
再例如key3节点,在oldVnodes中没有,就意味着这是一个新的节点,需要进行新建和插入指定位置的操作。
这里需要注意,即便是sameVnode,也是要进行patch操作的,而不是直接拿旧的节点来用。
key值在新旧节点在变化前后顺序不一致的情况下,能够快速的定位,从旧的节点队列中找到具有相同key值得节点来复用渲染。
应用场景
日常开发中最常见的用例是结合 v-for
:,开发过程中vue要求为循环生成的节点必须绑定key值
<ul> <li v-for="item in items" :key="item.id">...</li> </ul>
为何不建议用index做key
使用index作为key值,有可能造成错误的渲染。简单的来说,假如有oldVnode集合[A, B, C],删除第一个元素,剩下的vnode集合就是[B,C],当循环patchVnode的时候,oldVnode中A的key是0,vnode中B的key也是0,vue在实际渲染的过程中就会复用A来渲染B
例如下面根据list循环生成三个子组件,以数组index作为key值,当在删除的时候可能会出现异常。
<div class="list"> <child v-for="(item, i) in list" :data="item" :key="i" @del="onDelChild" ></child> </div> <script> export default { data() { return { list: [ { text: "circle", id: 1 }, { text: "trangle", id: 2 }, { text: "square", id: 3 } ] }; }, methods: { onDelChild(item) { const idx = this.list.findIndex(it => it.id == item.id); this.list.splice(idx, 1); } } }; </script>
<template> <div> {{ data.id }} <input v-model="text" /> <button @click="onDel">delete</button> <span @click="onClick">{{ staticText }}</span> </div> </template> <script> export default { props: ["data"], data() { return { text: "hello", staticText: "click Me" }; }, methods: { onDel() { this.$emit("del", this.data); }, onClick() { this.staticText = "clicked"; } } }; </script>
先点击第一条的click Me,改变文本内容。
再点击第一条的删除按钮,结果如下图所示,并没有如我们期待的那样删除第一条数据,你会发现视图的一部分确实正确更新了,如前面的id,但是后面的input内容和文本内容都没有按预期的删除第一条。
上面的异常情况中,很明显的用oldVnode集合中的A复用渲染了vnode中的B,oldVnode中的B复用渲染了vnode中的C,然后删除了oldVnode中的C。
注意:上面未正确更新的内容,实际绑定的值是Child子组件内部的自有属性,如果将Child内容改为一下,则可以得到预期效果
<div> {{ data.id }} <input v-model="data.text" /> <button @click="onDel">delete</button> </div>
初步的结论是,循环渲染子组件,并以index作为key值绑定时,当动态改变父组件中的list集合,vue会按index索引来查找oldVnode中的子节点用以复用,且在进行patch时,只更新了那些由父组件传递过来的prop绑定视图,子组件自身的实例属性不会更新
那么,为什么会出现这种渲染错误的情况?
首先,问题的源头肯定是节点复用,也就是我们上面key值复用其中的一步
if (sameVnode(elmToMove, newStartVnode)) { /*如果新VNode与得到的有相同key的节点是同一个VNode则进行patchVnode*/ patchVnode(elmToMove, newStartVnode, insertedVnodeQueue) /*因为已经patchVnode进去了,所以将这个老节点赋值undefined,之后如果还有新节点与该节点key相同可以检测出来提示已有重复的key*/ oldCh[idxInOld] = undefined /*当有标识位canMove实可以直接插入oldStartVnode对应的真实Dom节点前面*/ canMove && nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm) newStartVnode = newCh[++newStartIdx] }
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
在这里面用eleToMove复用渲染生成newStartVnode时,更新渲染的dom出现了问题。
下面我们来看看这个错误是如何出现的
在进入patchVnode之前,我们首先需要明确当前elmToMove是什么,newStartVnode又是怎样的。
elmToMove是从旧的渲染队列中拿出来的,由此可以看出它是一个完成的已经渲染过的数据,其中包含着数据变更前,child子组件对应的实例及生成的dom等数据。
而newStartVnode则不尽然,它只是一个占位vnode,具体何为占位vnode,可以详细了解下之前学习过的vue源码中create-component这一节。
对于<child></child>
这种自定义组件,在调用h函数生成vnode时,会生成一个vue-component-${Ctor.cid}${name}
的占位节点,具体可以看源码中create-component.js中的createComponent
方法,这里只截取一部分,来简单说明下占位节点也就是当前newStartVnode有哪些内容。
/*创建一个组件节点,返回Vnode节点*/ export function createComponent ( Ctor: Class<Component> | Function | Object | void, data?: VNodeData, context: Component, children: ?Array<VNode>, tag?: string ): VNode | void { //..... // return a placeholder vnode /** 组件节点,本质上是一个占位节点,在实际dom中并没有 * 当出现 <HelloWorld></HelloWorld> 这种组件节点时, * 会相应生成一个tag为vue-component-xx-hellowrold的占位vnode * 占位vnode没有children */ const name = Ctor.options.name || tag const vnode = new VNode( `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`, data, undefined/** 组件是没有children的 */, undefined, undefined, context, { Ctor, propsData, listeners, tag, children }/** componentOptions */ ) return vnode }
这里重点关注一下,占位vnode中componentOptions
,后面在尽心patch时会用到,data里面是一些钩子函数,用于组件节点实例的创建或者patch更新,详细的这里不过多解释。
所以,此时新的vnode其实还没有创建实例,生成dom节点,接下来看看patchVnode中如何为新的vnode生成实例和dom节点的
/*patch VNode节点*/ function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) { ... const data = vnode.data if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) { /*i = data.hook.prepatch,如果存在的话,见"./create-component componentVNodeHooks"。*/ // 根据vnode,更新oldVnode子组件实例对应的相关属性 i(oldVnode, vnode) } //这里复用,直接将oldVnode的dom赋给了vnode const elm = vnode.elm = oldVnode.elm const oldCh = oldVnode.children const ch = vnode.children if (isDef(data) && isPatchable(vnode)) { /*调用update回调以及update钩子*/ for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode) if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode) } ... }
在上面的代码中,导致了后续的渲染错误。
首先,作为组件占位vnode,根据我们上面看到的create-component中Vnode的构造函数中可以看出,vnode.data中是一堆hook函数,用于组件vnode的实例创建和patch更新,这里调用了hook.prepatch方法,用oldVnode来patch生成vnode。然后直接将oldVnode的dom赋给了vnode
我们来看下这个prepatch都做了些啥
prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) { //props相关的数据在componentOptions里 const options = vnode.componentOptions //实例覆盖:将oldVnode的实例直接赋值给了vnode const child = vnode.componentInstance = oldVnode.componentInstance updateChildComponent( child, options.propsData, // updated props options.listeners, // updated listeners vnode, // new parent vnode options.children // new children ) }
这里可以看到,在prepatch中,直接将oldVnode的实例直接赋值给了vnode,到这里,问题就出现了。
vnode中那些实例相关的属性(例如data中的数据)就会丢失,转而成为oldVnode中的实例属性。
这里可能会有点绕,简单举个例子
上面例子中用A节点复用生成B节点的时候,在prepatch的时候将A的实例直接赋值给了B,此时新节点队列中的B对应的实例属性text就是A,渲染后会生成对应的text:A。而oldVnode中B对应的text: B,这就出现了之前我们所说的渲染错误。
那为什么最上面例子中组件节点中的{{ data.id }}
能正确渲染呢?
这里就要看下面这段代码
updateChildComponent( child, options.propsData, // updated props options.listeners, // updated listeners vnode, // new parent vnode options.children // new children )
这里的options.propsData
用的是vnode.componentOptions中的propsData,而不是oldVnode,所以当对应的propsData有变化的时候,能够正确的渲染。而覆盖后的新旧实例属性为同一实例,无法对比差异,所以会直接复用oldVnode的实例属性dom。
所以,日常开发中,如果需要用index作为key值时,需要明确会不会造成这种渲染错误,否则可能会出现意想不到的难题。
总结
以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。