一个例子从源码中理解vue 的 diff, vnode, patch,key

594 阅读6分钟

前言

关于 vue 的 diff 算法、 patch、和 vnode、 还有为什么要加 key, key 不能为 index 的这些问题,是一个关联性的问题,这些问题聚合在一起,形成了 vue 的整个通过把真实 dom 转换为 vdom 通过 diff 算法依照 key 对比找出最小的更新,然后 patch 作相应的修改和添加真实 dom 的操作整个流程操作。

这篇文章解决的问题

  1. vue 的整个 dom 更新流程
  2. 什么是 diff
  3. 什么是 patch
  4. key 的作用以及为什么不能用 index 作为 key

演示代码

在代码运行出来的时候,审查元素时,把第一个和最后一个设置了颜色,像这样, 这样是为了最后一个问题,检查 index 作为 key 时出现的问题。

image.png

<div id="app">
    <button @click="del">删除第一项</button>
    <div v-for="(item, index) in list" :key="index"> {{ `这是第${item} 项` }}</div>
</div>
<script>
  new Vue({
    el: '#app',
    data() {
      return {
        list: [1, 2, 3]
      }
    },
    methods: {
      del() {
        this.list.splice(0, 1);
      }
    }
  })
</script>

首先简略梳理一下,vue 的初始化,过程如图所示

image.png

整个图,粗略的展示了初始化时,做的一些基本的操作,首次更新时,因为没有老的虚拟节点,所以对应的没有执行到核心的 diff 流程,源码中的这个,老节点没有,直接创建元素。

if (isUndef(oldVnode)) {
   createElm(vnode, insertedVnodeQueue)
} 

点击按钮删除时又发生了什么

image.png

因为借用的 splice 触发数组的响应式,更新,一次执行 dep.notify -> watcher.update -> watcher.run 方法从新执行 update 方法, 这样就产生了新老两份 vnode, 从而可以进行 patchVnode。

当执行删除第一项操作时,摘抄部分源代码如下,

  function patchVnode (
    oldVnode,
    vnode,
    insertedVnodeQueue,
    ownerArray,
    index,
    removeOnly
  ) {
        const oldCh = oldVnode.children
        const ch = vnode.children
        if (isUndef(vnode.text)) {
          if (isDef(oldCh) && isDef(ch)) {
            // 新老节点都有孩子,并且孩子不相同,则进行diff, 遍历新老两个列表,进行对比。
            if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
          } else if (isDef(ch)) {
            // 判断条件能走到这儿,说明已经不是 新老 vnode 的子节点都存在的情况,
            // 新节点的孩子存在,老节点的孩子不存在,说明是新增孩子节点
            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)
          }
        }
    }
    

当我们执行了patchVnode 操作之后,如果新老节点都有子节点,进行updatechildren 操作,由此也可以看出,vue的对比过程是同级比较

子节点的 diff 过程

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)) {
        // 新开和旧开是同一节点,执行 patch
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        // patch 结束后 更新新开和旧开的索引 + 1
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        // 新后和旧后是同一节点
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        // patch 结束后,索引-1
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        // 新后和旧开始同一节点, 执行 patch
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        // 新后和旧开相同,说名需要移动节点的位置
        // 把 节点移动到 oldChildren 中所有未处理节点的最后面,因为是新后,移动到未处理节点的最后,才能是后
        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)
        // 移动到所有oldChildren 节点中未处理节点的最前面
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      } else {
        // 如果上面四种假设都不成立,则通过遍历找到新节点在老节点中的位置索引

        // 找到老节点中每个节点 key 和索引之间的关系映射,{key1: idx1, ...}
        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]
          // 如果两节节点是同一个,值执行 patch
          if (sameVnode(vnodeToMove, newStartVnode)) {
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
            // patch 结束,将 老节点设置为 undefined
            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)
    }
  }

这里的思路是,因为要遍历新老节点的子节点列表,进行两两对比,这里做了一个优化,就是,不断地减少遍历的长度,而不是直接去比较 这里设置了四种节点新头结点、旧头结点、新尾节点、旧尾结点, 这四种情况先比较

场景:因为考虑到大部分的业务场景,并不是所有的子节点的位置都会发生变动,所以,采用以下已知的几种对比方式,来减小循环的次数

  • 新头和旧尾比较
  • 新尾和旧头比较
  • 新头和旧头比较
  • 新尾和旧尾比较 依照代码所示,当比较相同时,就进行 patchVnode 操作,同时更改对应的下标,很好的解决了遍历的次数。当这四种情况都不是的话,在老节点中通过 createKeyToOldIdx 建立key 与 索引的映射,然后找到,找到新节点中对应的 key ,如果新节点的 key 不存在,就建立删除,如果存在,进行 patchVnode 操作

最后,都对比完之后,如果老节点还有剩余,说明剩余节点是新增的,增加节点,如果新节点有剩余,说明是新元素,新增元素。

分析完基本的 patchdiff 的流程,来具体看一看删除了列表的第一项,会发生什么

  1. 首先会执行一个 patchVnode 方法,然后对比执行 updateChildren, 具体生成的vnode 抽取关键部分如下
    这是老子节点列表的基本结构
[
    {
        tag: 'button',
        children: [
            {text: '删除第一项'}
        ]
    },
    {
        tag: 'div',
        key: 0,
        children: [
            {text: '这是第1项'}
        ]
    },
    {
        tag: 'div',
        key: 1,
        children: [
            {text: '这是第2项'}
        ]
    }
    {
        tag: 'div',
        key: 2,
        children: [
            {text: '这是第3项'}
        ]
    }
]

新的子节点列表的结构
[
    {
        tag: 'button',
        children: [
            {text: '删除第一项'}
        ]
    },
    {
        tag: 'div',
        key: 0,
        children: [
            {text: '这是第2项'}
        ]
    },
    {
        tag: 'div',
        key: 1,
        children: [
            {text: '这是第3项'}
        ]
    }
]

我们抛开按钮的 patchVnode 过程,只看 v-for 循环出来的结果,首先遍历时,比较列表项,oldChch,通过 updateChildren 进行比较,拿到的是两个 div, 然后因为又同时都有子节点文本节点,所以继续 updateChildren 进行 diff 操作,然后又执行到了 patchVnode,因为是文本节点,所以走到逻辑是,直接设置新节点的文本到真实dom 上。即下图所示的情况。

image.png

接着执行下面一项,会重复上面的过程,从 而实现,这样

image.png

此时的情况是,在执行 updateChildren 的过程中,新 Vnode 遍历完毕,这样由于旧 Vnode 还剩下一个没有遍历完, 执行删除节点的操作,然后就出现了这样的情况。

image.png

我们观察到一个现象

  1. 文本删除正确,
  2. dom 删除不正确

正因为这样,所以 我们在审查元素时把元素增加了颜色,以便我们看清楚到底是否是删除错误。

因为我们把 key 设置为 index 导致了,新老 Vnode 中的前两项被认为是相同的节点,执行了 patchVnode 在这个过程中更改了文本,在 updateChildren 之后老节点有剩余,所以把最后一个 vnode 删除了,这就是误删,所以,不能使用 index 来作为 key。不能使用 index 作为 key。 不能使用 index 作为 key !!!

以上,便是整个过程,看过好多次,看过好多相关博客,总结出来,还有的部分还理解的不是很明白,可能几天后我又读了读,又会发现跟我之前理解的有出入,可能又会有新的认识,然后再看,再查,再理解,慢慢的,经过反反复复的过程可能才会慢慢的进步吧。