从源码解惑,为什么写列表v-for必须设置key

1,638 阅读5分钟

问题描述

在列表中使用key已经是老生常谈的问题了,官方 最佳实践 也大力推荐使用key。。。不对,官方原话是:

在组件上总是必须用 key 配合 v-for,以便维护内部组件及其子树的状态。

既然官方都说的这么中肯了,那么肯定是很重要了。但是,当初不明真相的我,总是觉得写和不写也没啥区别啊,反正都能渲染出来。写着好麻烦啊~🥱,算了,下次,下次一定写。。。

其实之所以看不出来区别是因为一切发生的太快了。。。直到有一天我打了一个断点才发现区别大的不止一点点,事情是这样的

我写了一个下面这样的列表,开始有五个元素,分别是A,B,C,D,E。在组件mount过后两秒向列表头部插入一个元素F,然后对比一下使用key和不使用key的区别

<div id="app">
  <ul>
<!--    <li v-for="item in list" :key="item">{{item}}</li>-->
        <li v-for="item in list">{{item}}</li>
  </ul>
  <h2>不使用key,仔细看上面元素的变化</h2>
</div>
  const app = new Vue({
    data() {
      return {
        list: ['A', 'B', 'C', 'D', 'E']
      }
    },
    el: '#app',
    mounted() {
      setTimeout(() => {
        this.list.unshift('F')
      }, 2000)
    }
  })

不使用key

pic1

使用key

pic2

区别

  • 在不使用key的时候,一共进行了五次更新操作和一次新建插入(元素E)操作
    pic3
  • 使用key时,只做了一次新建插入(元素F)操作
    pic4

问题分析

聪明的你可能会问了,为什么呢?

我们知道,虚拟dom是一个树结构,当组件数据变化时会执行组件的patchVnode方法,然后按照深度优先,同层比较的原则进行diff。如果新老节点都有子节点,则调用updateChildren方法进行子节点的比较,虚拟dom diff的核心算法就在这个方法里面。

vue中的虚拟dom 补丁算法是在 snapdom 的基础上改造而来,最主要的优化就包括针对web场景,在web中,我们最多进行的操作是在队尾或者队首插入元素,在遍历查找新的元素对应的老元素之前,先进行新老首尾2x2=4次盲猜。

为了方便查看,我会在下面源码中插入相关注释

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    let oldStartIdx = 0
    let newStartIdx = 0
    let oldEndIdx = oldCh.length - 1
    let oldStartVnode = oldCh[0]
    let oldEndVnode = oldCh[oldEndIdx]
    let newEndIdx = newCh.length - 1
    let newStartVnode = newCh[0]
    let newEndVnode = newCh[newEndIdx]
    let oldKeyToIdx, idxInOld, vnodeToMove, refElm

    const canMove = !removeOnly

    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(newCh)
    }
    // 如果新老任何一组元素的游标重合,就结束循环
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
      } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx]
        // 针对web中常见操作,在遍历查找新的元素对应的老元素之前,先进行新老首尾2x2=4次盲猜
        // 对比新开始和老开始
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
        // 对比新结束和老结束
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
        // 对比新结束和老开始
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]
        // 对比新开始和老结束
      } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      } else {
        // 都没有命中,创建一个老节点索引和key的映射表,循环查找
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
        if (isUndef(idxInOld)) { // New element
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        } else {
          vnodeToMove = oldCh[idxInOld]
          if (sameVnode(vnodeToMove, newStartVnode)) {
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
            oldCh[idxInOld] = undefined
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          } else {
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
    // 当循环结束,如果新的列表里面还有没处理的元素,就全部创建新增
    if (oldStartIdx > oldEndIdx) {
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else if (newStartIdx > newEndIdx) {
      // 如果老的列表里还有没处理的元素,就全部删除
      removeVnodes(oldCh, oldStartIdx, oldEndIdx)
    }
 }

讲到这里大家可能还是不明白为什么要用key呢,其实答案就在盲猜时执行的sameVnode方法,废话不多说,直接看代码!

// 判断两个vnode是否是相同vnode
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)
      )
    )
  )
}

当我们在列表开头插入一个F元素时,如果不使用key,则key等于undefined,而undefined===undefined恒成立,并且tag都是li,都不是注释节点,所以会把新插入的F(newStartVnode)和老的A(oldStartVnode)认为是同一个节点执行更新,游标向后移动一位,并且后续4个元素都按照这种逻辑更新,最后老节点先结束了(游标先重合),新节点还剩一个元素E,就创建一个新的节点并插入,最终一共进行了五次更新操作和一次新建插入(元素E)操作。

pic3

如果我们使用了key,对比newStartVnodeoldStartVnode时发现key不同,不是同一个元素,就继续对比oldEndVnode newEndVnode发现他们key相同,都为E,并且文本内容和其他属性也没变,就直接复用然后游标向前移动一位,最后老节点先结束了(游标先重合),新节点还剩一个元素F,就创建一个新的节点并插入。最终只进行了一次新建插入(元素F)操作

pic4

引申

使用key除了提高性能以外,还有一些其他的使用场景,例如:

  1. 当你想为一个元素添加动画,比如上下移动,如果没有使用key,这个元素很有可能在updateChildren时被替换了,那么元素的动画可能不会完整的展示
  2. 当你在一个input上获取焦点后,如果没有使用key,经过updateChildren更新后,可能会失去焦点
  3. 其他因为dom顺序改变可能造成的bug

pic6

解决上述问题最直接最有效的办法就是为元素增加key
PS:下次一定要写key 😂

最后,这篇文章属于 从源码解惑 系列文章中的一篇,欢迎大家关注、留言、拍砖