vue2 v-for中key的作用,记一次echarts使用的踩坑记录

834 阅读4分钟

一、现象描述

近来在vue页面中使用了echarts,目的是根据用户点击动态在页面顶部添加div,然后在其中画出相应的折线图。然而事与愿违,图形并没有出现在对的位置,而是始终画在了顶部的div中,如图所示。 图1

图2

图3

图4

预期的结果应该是 图5

DOM结构(没有key的情况):

<div style="display: flex;">
    <div v-for="item in divArr" :id="item.name">      
    </div>
</div>

使用v-for遍历divArr数组,用户每点击一个按钮,向divArr数组的头部添加数据divArr.unshift({name:'order', chart: undefined})。name的值即为新添加 div 的 id,在nextTick中获取到这个新添加 id 的节点,初始化echarts对象并绘制图形。

this.$nextTick((id) => {
    let chart = this.$echarts.init(document.getElementById(id))
    chart.setOption(option)
    divArr[0].chart = chart   //这里不考虑用户短时间内多次点击的情况
})

当点击四个不同的按钮时,就出现了图1-4的情况。通过控制台打印每次点击生成的echarts对象,可以看到echarts对象的dom节点不一样,但是 id 是一样的。

图6

由此推测,未绑定key时,第一个div是复用的,之后的div是新添加的。

而添加key之后的得到了预期结果:

图7 由此引出了对v-for中key的作用的思考。

下面,我们深入源码来验证,以及理解为什么绑定key之后结果会不同。

二、源码解析

由于把divArr当做了响应式对象,所以当divArr发生变化时,会触发页面更新。

vm.$el = vm.__patch__(prevVnode, vnode)

对于渲染过程和vdom不了解的童鞋可以参考 vue2 不同类型Watcher对比 关于render watcher的介绍, 以及 vue2 vdom和diff算法 这两篇文章。

下面我们看下页面更新时的path过程(列出本场景的逻辑):

function patch (oldVnode, vnode, hydrating, removeOnly) {
    if (sameVnode(oldVnode, vnode)) {   //执行patch
        patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
    } else {   // 覆盖
        const oldElm = oldVnode.elm
        const parentElm = nodeOps.parentNode(oldElm)
        // create new node
        createElm( vnode, 
                   insertedVnodeQueue, 
                   oldElm._leaveCb ? null : parentElm,
                   nodeOps.nextSibling(oldElm)
        ) 
    }
    return vnode.elm
}
function sameVnode (a, b) {
  return  a.key === b.key && a.tag === b.tag
}

未绑定key时,oldVnode 和 newVnode 的key均为undefined,tag均为div,所以sameVnode(oldVnode, newVnode) 成立,进入到patchVode --> updateChildren 的 diff 比较。

patchVode

view code

function patchVnode(){
    const oldCh = oldVnode.children
    const ch = vnode.children
    //更新attrs, class, eventListeners, props, style
    if (isDef(data) && isPatchable(vnode)) {
      for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
    }
    //文本节点的相关处理
    if (isUndef(vnode.text)) {
      if (isDef(oldCh) && isDef(ch)) {
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      } else if (isDef(ch)) {
        if (process.env.NODE_ENV !== 'production') {
          checkDuplicateKeys(ch)
        }
        if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      } else if (isDef(oldCh)) {
        removeVnodes(oldCh, 0, oldCh.length - 1)
      } else if (isDef(oldVnode.text)) {
        nodeOps.setTextContent(elm, '')
      }
    } else if (oldVnode.text !== vnode.text) {
      nodeOps.setTextContent(elm, vnode.text)
    }
}

updateAttrs方法(src\platforms\web\runtime\modules\attrs.js)执行属性的更新:

function updateAttrs (oldVnode: VNodeWithData, vnode: VNodeWithData) {
  //...
  let key, cur, old
  const elm = vnode.elm
  const oldAttrs = oldVnode.data.attrs || {}
  let attrs: any = vnode.data.attrs || {}
  // clone observed objects, as the user probably wants to mutate it
  //...
  //添加 / 更新属性
  for (key in attrs) {
    cur = attrs[key]
    old = oldAttrs[key]
    if (old !== cur) {
        elm.setAttribute(key, cur)
    }
  }
  // 删除新节点不存在的属性
  for (key in oldAttrs) {
    if (!(key in attrs)) {
        elm.removeAttribute(key)
    }
  }
}

updateChildren

view code

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

// removeOnly is a special flag used only by <transition-group>
// to ensure removed elements stay in correct relative positions
// during leaving transitions
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]
  } 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 {
    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 {
        // same key but different element. treat as new element
        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)
}




}

}

这里主要采用的是双端比较,然后递归地对children进行patchVnode,也就是我们常说的同级比较。下面以前两次点击为例,看一下diff的过程。

  1. 比较id为 I 和 II 的节点,sameVnode(oldStartVnode, newStartVnode) 成立,执行patchVnode --> updateAttrs, 更新节点的id。oldStartIdx、newStartIdx 分别加 1
  2. oldStartIdx <= oldEndIdx 不成立,退出while循环。此时,ref 为 null,执行addVnodes追加newVnode的第二个节点,即 id 为 I 的节点。
  function insert (parent, elm, ref) {
    if (isDef(parent)) {
      if (isDef(ref)) {
        if (nodeOps.parentNode(ref) === parent) {
          nodeOps.insertBefore(parent, elm, ref)
        }
      } else {
        nodeOps.appendChild(parent, elm)
      }
    }
  }

所以,文章开始提出的问题的原因,正是因为第二次点击后,就地复用了原来 id 为 I 的节点,并更新 id 为 II 。新的 id 为 I 的节点是新添加的,所以之前绘制的图形并没有保留,而是在 id 为 II 的节点上被替换掉了。

绑定key后是什么情况呢?

  1. 由于key不同,sameVnode(oldStartVnode, newStartVnode) 不成立,进而比较sameVnode(oldStartVnode, newEndVnode) 成立,之后执行oldStartVnode, newEndVnode 的patchVnode。oldStartIdx 加 1,newEndIdx 减 1。
  2. oldStartIdx <= oldEndIdx 不成立,退出while循环。此时 newEndIdx = 0,ref 为 newCh[newEndIdx + 1].elm,即 id 为 I 的节点之前,插入id 为 II 的节点。

所以,v-for循环中绑定key可以在diff的过程中更精确的定位到 vnode,减少一些不可预期的错误。