问题
之前在开发中遇到过类似如下的问题:
<div id="app">
<ul>
<li v-for="(item,index) in list" :key="index">
<input type="checkbox">
子项{{item}}
<button @click="deleteItem(index)">删除</button>
</li>
</ul>
</div>
var app = new Vue({
el: '#app',
data: {
list: [1, 2, 3]
},
methods:{
deleteItem(index) {
this.list.splice(index, 1);
}
}
})
勾选 子项1中的 checkbox,点击 子项1 的 删除 按钮后,子项2 居然默认选中了?
记得之前好像听到同事有说过用 index 当 key 会出问题,通过询问得知将 key 设置为唯一的值即可解决问题,然后尝试了下将 key 设置为 item :
<div id="app">
<ul>
<li v-for="(item,index) in list" :key="item">
<input type="checkbox">
子项{{item}}
<button @click="deleteItem(index)">删除</button>
</li>
</ul>
</div>
以上两种情况唯一的区别就是,用 index 做为 key,删除第一项之后,第二项和第三项的 key 发生了变化,而用 item 作为 key,key 始终不变。看来问题是出现在了 key 对 Vue 比对 虚拟dom 时有影响。
虚拟 dom 与 diff 算法
虚拟 dom 是对 dom 的 抽象,其本质是一个JavaScript对象。 当数据被修改时,vue内部会先通过虚拟 dom 的比对再决定如何更新真实的dom,这可以减少很多dom操作。
vue在修改数据时,消息管理器Dep会发出通知,然后就会通过 diff 算法比对新旧的虚拟dom,diff算法的流程大致如下:

可以在 Vue源码 中查看具体实现。
这里主要看下 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)
) || (
isTrue(a.isAsyncPlaceholder) &&
a.asyncFactory === b.asyncFactory &&
isUndef(b.asyncFactory.error)
)
)
)
}
当两个 Vnode 的 key tag 都相同,Vnode 都含有 data 或者都不含有 data 时会进行 patchVnode的操作,这里的 key 至关重要,结合 updateChildren 就可以找到我们开始那个问题的原因。
如果两个
vnode都没有绑定key的话这里的a.key === b.key也是满足的,都是undefined
updateChildren 过程
代码可查看 Vue源码 patch.js 中 updateChildren 部分。
这里把代码中的变量做个简称:
oldStartVnode: 旧头oldEndVnode: 旧尾newStartVnode:新头oldEndVnode: 新尾
- 先 头尾交叉对比,如果是
samenode,那么就把 新节点patch到 旧节点 - 头尾交叉对比 完了之后,如果还没有匹配到的话,就会利用 新头 的
key对比
新头 分为 有 key 和 无 key 两种情况,有 key 的话就去 旧节点 生成的 map 对象上匹配:
- 当没有对应的
key,那么创建新的节点 - 如果有对应的
key并且是相同的节点,把**新节点patch**到 旧节点 - 如果有对应的
key但是不是相同的节点,则创建新节点,只要创建了新节点 新头指针后移,再次进入循环
如果新头没 key 的话 就用 旧节点数组 循环对比 新头
调试 Vue 源码
我们查看源码了解了 diff 算法,要验证最开始的那个问题就需要调试源码。
调试源码可以让我们清晰看到 Vue 是如何运行 diff 算法的,我们需要先做好如下准备工作:
- 先
cloneVue项目,然后执行npm install安装依赖。 - 修改
package.json中的script下的dev,在TARGET:web-full-dev后添加--sourcemap - 执行
npm run dev,这时会在dist文件夹下生成一个vue.js.map的文件 - 在页面中引用
vue.js便可以在chrome的sources上看到vue中各个目录下的文件了
我们可以在源码的 patchVnode、updateChildren 等方法上打上断点,然后在我们操作页面的时候进行查看。
通过调试可以发现,首先在 div 和 ul 这两级的 vnode 对比都是 sameNode ,然后对比 li。
在 li 的对比中,由于 newVnode 的 li 的 key 和 oldVnode 的 li 的 key 相同都是 0,所以判断为 sameVnode。
oldVnode调试截图

newVnode调试截图

接下来对比 li 的子 Vnode,首先对比的是新老 input 的 Vnode,流程大概如下:
- 各属性都相同满足
sameVnode进入patchVnode。 - 执行
const elm = vnode.elm = oldVnode.elm,dom节点复用。 - 满足
if (isDef(data) && isPatchable(vnode)),进行了cbs.update。
其中 第2步 的 elm 也就是 dom 节点中包含了该 input 的原生属性,也包括 checked 属性,所以进行复用之后,新的 vnode的 checked 默认也是 true, 也就是选中状态。

第3步 的 cbs.update 是一个数组,里面包含了更新 dom 属性等方法,如下:

这里的大部分方法从名字上可以看出来他们的功能,它们都没有更改 input 的 checked 。
弄明白为什么 checked 复用了,再来看下整个 diff 算法的执行过程:

唯一值 key 的情况
那当我们使用唯一值的 key 的时候,li 对比流程如下:
- 第1次对比:
- 新头旧头对比(
旧li key1对比新li key2),不是sameVnode - 新尾旧尾对比(
旧li key3对比新li key3),满足sameVnode,进行patchVnode,然后复用,新旧尾指针-1
- 第2次对比:
- 新头旧头对比(
旧li key1对比新li key2),不是sameVnode - 新尾旧尾对比(
旧li key2对比新li key2),满足sameVnode,进行patchVnode,然后复用,新旧尾指针-1
- 第3次对比:
newStartIdx > newEndIdx,while循环结束- 进入
else if(newStartIdx > newEndIdx),执行removeVnodes(oldCh, oldStartIdx, oldEndIdx)
当 newStartIdx > newEndIdx 时,说明新的节点已经遍历完,
此时老的节点 旧li key1 被剩下,最后将其删除完成对比。
来看下使用唯一 key 的情况下,diff 算法执行过程:

最后
通过 diff 算法的分析与源码的调试,我们明白了产生问题的原因在哪里,也理解了 Vue 的 key 到底有什么用。
如果我们需要确保我们的 dom 更新的话,我们就必须为 key 添加一个唯一的值。
但是如果我们需要遍历的节点较为简单,比如是纯文本的这种,那么我们把 index 作为 key 可以对列表项进行原地复用,事实上效率更高,这个在官方文档 也有提到。