理解 Vue 的 key 和 diff 算法

2,341 阅读5分钟

问题

之前在开发中遇到过类似如下的问题:

<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 居然默认选中了?

记得之前好像听到同事有说过用 indexkey 会出问题,通过询问得知将 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 作为 keykey 始终不变。看来问题是出现在了 keyVue 比对 虚拟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)
      )
    )
  )
}

当两个 Vnodekey tag 都相同,Vnode 都含有 data 或者都不含有 data 时会进行 patchVnode的操作,这里的 key 至关重要,结合 updateChildren 就可以找到我们开始那个问题的原因。

如果两个 vnode 都没有绑定 key 的话这里的 a.key === b.key 也是满足的,都是 undefined

updateChildren 过程

代码可查看 Vue源码 patch.jsupdateChildren 部分。

这里把代码中的变量做个简称:

  • oldStartVnode旧头
  • oldEndVnode旧尾
  • newStartVnode新头
  • oldEndVnode新尾
  1. 头尾交叉对比,如果是 samenode,那么就把 新节点 patch旧节点
  2. 头尾交叉对比 完了之后,如果还没有匹配到的话,就会利用 新头key 对比

新头 分为 keykey 两种情况,key 的话就去 旧节点 生成的 map 对象上匹配:

  1. 没有对应的 key ,那么创建新的节点
  2. 如果有对应的 key 并且是相同的节点,把**新节点 patch**到 旧节点
  3. 如果有对应的 key 但是不是相同的节点,则创建新节点,只要创建了新节点 新头指针后移,再次进入循环

如果新头没 key 的话 就用 旧节点数组 循环对比 新头

调试 Vue 源码

我们查看源码了解了 diff 算法,要验证最开始的那个问题就需要调试源码。

调试源码可以让我们清晰看到 Vue 是如何运行 diff 算法的,我们需要先做好如下准备工作:

  1. clone Vue项目,然后执行 npm install 安装依赖。
  2. 修改 package.json 中的 script 下的 dev,在 TARGET:web-full-dev 后添加 --sourcemap
  3. 执行 npm run dev,这时会在 dist 文件夹下生成一个 vue.js.map 的文件
  4. 在页面中引用 vue.js 便可以在 chromesources 上看到 vue 中各个目录下的文件了

我们可以在源码的 patchVnodeupdateChildren 等方法上打上断点,然后在我们操作页面的时候进行查看。

通过调试可以发现,首先在 divul 这两级的 vnode 对比都是 sameNode ,然后对比 li

li 的对比中,由于 newVnodelikeyoldVnodelikey 相同都是 0,所以判断为 sameVnode

oldVnode调试截图

newVnode调试截图

接下来对比 li 的子 Vnode,首先对比的是新老 inputVnode,流程大概如下:

  1. 各属性都相同满足 sameVnode 进入 patchVnode
  2. 执行 const elm = vnode.elm = oldVnode.elmdom 节点复用。
  3. 满足 if (isDef(data) && isPatchable(vnode)),进行了 cbs.update

其中 第2步elm 也就是 dom 节点中包含了该 input 的原生属性,也包括 checked 属性,所以进行复用之后,新的 vnodechecked 默认也是 true, 也就是选中状态。

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

这里的大部分方法从名字上可以看出来他们的功能,它们都没有更改 inputchecked

弄明白为什么 checked 复用了,再来看下整个 diff 算法的执行过程:

唯一值 key 的情况

那当我们使用唯一值的 key 的时候,li 对比流程如下:

  1. 第1次对比:
  • 新头旧头对比(旧li key1 对比 新li key2),不是 sameVnode
  • 新尾旧尾对比(旧li key3 对比 新li key3),满足 sameVnode,进行 patchVnode,然后复用,新旧尾指针-1
  1. 第2次对比:
  • 新头旧头对比(旧li key1 对比 新li key2),不是 sameVnode
  • 新尾旧尾对比(旧li key2 对比 新li key2),满足 sameVnode,进行 patchVnode,然后复用,新旧尾指针-1
  1. 第3次对比:
  • newStartIdx > newEndIdxwhile 循环结束
  • 进入 else if(newStartIdx > newEndIdx),执行 removeVnodes(oldCh, oldStartIdx, oldEndIdx)

newStartIdx > newEndIdx 时,说明新的节点已经遍历完, 此时老的节点 旧li key1 被剩下,最后将其删除完成对比。

来看下使用唯一 key 的情况下,diff 算法执行过程:

最后

通过 diff 算法的分析与源码的调试,我们明白了产生问题的原因在哪里,也理解了 Vuekey 到底有什么用。

如果我们需要确保我们的 dom 更新的话,我们就必须为 key 添加一个唯一的值。

但是如果我们需要遍历的节点较为简单,比如是纯文本的这种,那么我们把 index 作为 key 可以对列表项进行原地复用,事实上效率更高,这个在官方文档 也有提到。


原文地址

参考