vue2的diff算法

272 阅读6分钟

vue2源码的updateChildren详解

1 html代码

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Title</title>
</head>
<body>
<div id="app">
  <div v-for="item in list" :key="item.id">{{item.value}}</div>
</div>
<script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.14/vue.js"></script>
<script>
  const vm = new Vue({
    el:'#app',
    data() {
      return {
        list: [
          {
            id: 'a',
            value: 'a'
          },
          {
            id: 'b',
            value: 'b'
          },
          {
            id: 'c',
            value: 'c'
          },
        ]
      };
    }
  });
  // console.log(vm);
  // vm.$mount('#app');
  setTimeout(() => {
    vm.list = [
      {
        id: 'h',
        value: 'h'
      },
      {
        id: 'c',
        value: 'c'
      },
      {
        id: 'a',
        value: 'a'
      },
      {
        id: 'd',
        value: 'd'
      },
    ];
  }, 2000);
</script>
</body>
</html>

2 updateChildren的源码

/**
 * diff 过程:
 *   diff 优化:做了四种假设,假设新老节点开头结尾有相同节点的情况,一旦命中假设,就避免了一次循环,以提高执行效率
 *             如果不幸没有命中假设,则执行遍历,从老节点中找到新开始节点
 *             找到相同节点,则执行 patchVnode,然后将老节点移动到正确的位置
 *   如果老节点先于新节点遍历结束,则剩余的新节点执行新增节点操作
 *   如果新节点先于老节点遍历结束,则剩余的老节点执行删除操作,移除这些老节点
 */
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是一个特殊的标志,仅由 <transition-group> 使用,以确保被移除的元素在离开转换期间保持在正确的相对位置
  const canMove = !removeOnly

  if (process.env.NODE_ENV !== 'production') {
    // 检查新节点的 key 是否重复
    checkDuplicateKeys(newCh)
  }

  // 遍历新老两组节点,只要有一组遍历完(开始索引超过结束索引)则跳出循环
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // debugger
    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)) {
      // 老结束和新结束是同一个节点,执行 patch
      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)
      // 处理被 transtion-group 包裹的组件时使用
      //在老的结束节点的下一个兄弟前插入老的开始节点->把老的开始节点移到结束节点
      canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
      // patch 结束后老开始索引加 1,新结束索引减 1
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
      // debugger
      // 老结束和新开始是同一个节点,执行 patch
      patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
      canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
      // patch 结束后,老结束的索引减 1,新开始的索引加 1
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
    } else {
      // 如果上面的四种假设都不成立,则通过遍历找到新开始节点在老节点中的位置索引
      // 找到老节点中每个节点 key 和 索引之间的关系映射 => oldKeyToIdx = { key1: idx1, ... }
      // 第一次找不到会执行,并保存oldKeyToIdx
      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)) {
          // 如果这两个节点是同一个,则执行 patch
          patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
          // patch 结束后将该老节点置为 undefined
          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)
  }
}

3 图片解释

3.1 第一次比较

1.png

首先进入到while循环中,进行双指针比较,此时新旧的开始,结束节点都对不上,进入到esle逻辑 获取老节点的key与索引的映射表oldKeyToIdx={a:0,b:1,c:2} 通过新开始节点的key(h)去老节点的映射表里找位置索引,没有找到 执行

createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)

在parentElm的开始节点(a)前插入新开始节点(h) 最后新开始节点开始指针向右移动一格

此时老开始节点索引oldStartIdx=0,新开始节点索引newStartIdx=1,老结束节点索引oldEndIdx=2,新结束节点索引newEndIdx=3;

parentElm的innerHTML为

h
a
b
c

1-1.png

第二次比较

2.png

// 老结束和新开始是同一个节点,执行 patch
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)

在parentElm中,把老结束节点(c)插入到老开始节点(a)前 最后新开始节点指针向右移动一格,老结束节点指针向左移动一格 此时 老开始节点索引oldStartIdx=0, 新开始节点索引newStartIdx=2, 老结束节点索引oldEndIdx=1, 新结束节点索引newEndIdx=3,

parentElm的innerHTML为

h
c
a
b

2-1.png

第三次比较

3.png 老开始节点和新开始节点是同一个节点,执行 patch 最后老开始节点指针和新开始节点指针向右移动一格 此时老开始节点索引oldStartIdx=1,新开始节点索引newStartIdx=3,老结束节点索引oldEndIdx=1,新结束节点索引newEndIdx=3;

parentElm的innerHTML为

h
c
a
b

3-1.png

第四次比较

4.png 此时新旧的开始,结束节点都对不上,进入到esle逻辑 通过新开始节点的key(d)去老节点的映射表里找位置索引,没有找到 执行

createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)

在parentElm的开始节点(b)前插入新开始节点(d) 最后新开始节点开始指针向右移动一格

此时老开始节点索引oldStartIdx=1,新开始节点索引newStartIdx=4,老结束节点索引oldEndIdx=1,新结束节点索引newEndIdx=3;

parentElm的innerHTML为

h
c
a
d
b

4-1.png 此时新开始节点索引大于新结束节点索引,终止while循环 执行

removeVnodes(oldCh, oldStartIdx, oldEndIdx)

删除老节点(a,b,c)的老开始位置索引(1)到结束位置索引(1)的节点(b)

diff对比执行完成

parentElm的innerHTML为

h
c
a
d

4 补充

如果新老开始,结束节点对比不上,新开始节点在老节点的映射表上

// 在老节点中找到新开始节点了
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartVnode)) {
// 如果这两个节点是同一个,则执行 patch
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
// patch 结束后将该老节点置为 undefined
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
}

把对应映射表上的节点移动到老开始节点前,并将老节点对应的映射索引节点设置为undefined,这样当之后的老指针移动到该节点的时候就可以直接跳过该节点的对比

if (isUndef(oldStartVnode)) {
  // 如果节点被移动,在当前索引上可能不存在,检测这种情况,如果节点不存在则调整索引
  oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
  oldEndVnode = oldCh[--oldEndIdx]
}