关于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类型中的数据信息)都有定义
当标签是的时候,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值,有可能造成错误的渲染。简单的来说,假如有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">deletebutton>
<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内容改为一下,则可以得到预期效果
{{ data.id }}
初步的结论是,循环渲染子组件,并以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这一节。对于
这种自定义组件,在调用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中并没有
* 当出现 这种组件节点时,
* 会相应生成一个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值时,需要明确会不会造成这种渲染错误,否则可能会出现意想不到的难题。