解锁vue3 diff算法

2,781 阅读6分钟

二、 vue3 diff

  • 经过vue2diff算法的学习,这次我们直接进入正题
  • 这次采用五步走的方式来学习
  • 源码地址:patchKeyedChildren函数

初始化

//省略了参数,c1为旧结点,c2为新节点
  const patchKeyedChildren = (...) => {
    let i = 0
    const l2 = c2.length //新节点数组长度
    let e1 = c1.length - 1 // 指向旧节点的最后一个元素
    let e2 = l2 - 1 // 指向新节点的最后一个元素

顺便观察一下后面的结构,就是先进行两个循环,然后挨个判断是否命中 image.png

头比较

  • 先来看一下里面涉及(后面也涉及)的isSameVNodeType函数
  • 源码地址: isSameVNodeType函数
export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
  if (
    __DEV__ &&
    n2.shapeFlag & ShapeFlags.COMPONENT &&
    hmrDirtyComponents.has(n2.type as ConcreteComponent)
  ) {
    // HMR only: if the component has been hot-updated, force a reload.
    return false
  }
  return n1.type === n2.type && n1.key === n2.key //主要看这里,如果type和key一致则返回true
}
  • 进入正文
    while (i <= e1 && i <= e2) {  //只要i还没有走到最后,就执行循环
      const n1 = c1[i];//旧节点数组中下标为i的节点
      const n2 = (c2[i] = optimized 
        ? cloneIfMounted(c2[i] as VNode)
        : normalizeVNode(c2[i]))
      if (isSameVNodeType(n1, n2)) {  //判断是否值得打补丁
        patch(...) //打补丁
      } else { 
        break //一旦遇到不值得的则跳出循环
      }
      i++ //向右走
    }

尾比较

//此时的i为上一次while循环跳出循环时候的i
    while (i <= e1 && i <= e2) {
      const n1 = c1[e1] //指向的是最后的节点
      const n2 = (c2[e2] = optimized
        ? cloneIfMounted(c2[e2] as VNode)
        : normalizeVNode(c2[e2]))
      if (isSameVNodeType(n1, n2)) { //判断是否值得打补丁
        patch(...) //打补丁
      } else {
        break
      }
      e1--  //向左走
      e2--
    }

处理新增

  • 经过上面两步的处理,我们可以让e1, i , e2分别指向应该到的位置
  • 此时的情况为
    • i>e1: 可以看到旧节点都有箭头指向,说明当i>e1的时候,旧节点已经处理完毕,没有可以复用的节点了
    • i<=e2: 可以看到新节点中仍有一个节点没有被箭头指向,那么就是说新节点还没有处理完,在上面两个遍历的过程中都没有涉及到

image.png

  • 直接看如何处理该情况
 if (i > e1) {
      if (i <= e2) {
        const nextPos = e2 + 1  //让nextPos指向e2指向的下一个节点
        const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
        while (i <= e2) { //在区间[i, e2]间处理节点
          patch(...)
          i++
        }
      }
    }

处理删除

  • 同样的,我们再来一个例子,让e1, i , e2分别指向应该到的位置
  • 此时的情况为
    • i > e2: 可以看到新节点中都有箭头指向,则说明所需的新节点处理完毕
    • i <= e1: 可以看到旧节点中仍有两个节点没有被箭头指向,那么就是说旧节点还没处理完,上面两个遍历的过程都没有涉及到(但是现在已经有所需结果了,所以应该是把这个范围的删除掉) image.png
    else if (i > e2) {
      while (i <= e1) {  //处理区间为[i, e1]
        unmount(c1[i], parentComponent, parentSuspense, true)  //移除真实节点
        i++
      }
    }

最后处理(重点)

  • 进入这个分支则说明 i<= e1i <= e2,也就是说,新旧节点数组中都仍然有未处理的节点, 如下图所示

image.png

  • 这个部分也好长,我把它分为了三个部分,采用三步走的形式
形成keyToNewIndexMap
else {
      const s1 = i // 让s1指向i指向的旧节点
      const s2 = i // 让s2指向i指向的新节点
 start ------------------------------------------------------------------------------
       //这个地方就是用map结构来存储[s2, e2]范围内键为新节点的key, 值为下标
      const keyToNewIndexMap: Map<string | number | symbol, number> = new Map()
      for (i = s2; i <= e2; i++) { //遍历[s2, e2]这个范围
        const nextChild = (c2[i] = optimized
          ? cloneIfMounted(c2[i] as VNode)
          : normalizeVNode(c2[i]))
        ....
           //map结构存储键为[s2, e2]范围内键为新节点的key, 值为下标
          keyToNewIndexMap.set(nextChild.key, i) 
        }
      }
  end-----------------------------------------------------------------------------
  • 继续使用例子,让s1, s2指向该指向的节点 image.png
  • 得到keyToNewIndexMap image.png
进入for循环
  • 先来点准备工作
 let j
      let patched = 0
      const toBePatched = e2 - s2 + 1 //[s2, e2]这个范围的长度
      let moved = false
      // used to track whether any node has moved
      let maxNewIndexSoFar = 0
       //新建一个长度为toBePatched的数组
      const newIndexToOldIndexMap = new Array(toBePatched)
       //每一项初始化为0,我觉得可以直接fill(0)?
      for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
  • 进入循环正文
for start -------------------------------------------------------------------------------------
        //遍历旧节点[s1, e1]这个范围
       for (i = s1; i <= e1; i++) {
        const prevChild = c1[i] //当前下标指向的旧节点
        //当已经patch的数量大于等于需要被patch的数量,则说明需要的节点已经处理好了
        if (patched >= toBePatched) {
            //剩下的节点直接say88即可
          unmount(prevChild, parentComponent, parentSuspense, true)
          continue
        }
  start--------------------------------------------------------
        //这一个地方就是去找到 可以复用的当前指向的旧节点 的新节点的下标newIndex
        let newIndex
        if (prevChild.key != null) {  //当前下标指向的旧节点的key有效
            //去上面存储的map结构取出该key对应的下标(没有该key的时候则取出undefined)
          newIndex = keyToNewIndexMap.get(prevChild.key) 
        } else {
          // key无效的节点
          for (j = s2; j <= e2; j++) {  //还是去[s2, e2]这个区间找
            if (
            //需要先去newIndexToOldIndexMap中找对应节点的值为0(初始化的时候为0)
              newIndexToOldIndexMap[j - s2] === 0 && 
            //判断是否值得打补丁
              isSameVNodeType(prevChild, c2[j] as VNode)
            ) {
            //将值得打补丁的节点坐标
              newIndex = j 
              break
            }
          }
          //如果找不到的话则说明newIndex没有被赋值,则值为undefined
        }
    end ---------------------------------------------------------------------------
        //上面有说过拿到的newIndex可能是undefined
        if (newIndex === undefined) { 
        //其实意思就是在新节点列表中都找不到能够重用你这个旧节点的节点
        //也就是说你这个旧节点没用喽,跟你说88
          unmount(prevChild, parentComponent, parentSuspense, true)
        } else { //能够找到可以复用该旧节点prevChild的新节点下标newIndex
           //newIndexToOldIndexMap中该新节点对应的值赋值为i+1 
          newIndexToOldIndexMap[newIndex - s2] = i + 1
          //maxNewIndexSoFar初始值也为0
          //如果每个节点都按需递增的话,就每次都是进入这个分支,说明不用去求最长递增子序列(后面会有)
          if (newIndex >= maxNewIndexSoFar) {
            maxNewIndexSoFar = newIndex //让该值始终指向可以复用旧节点的下标的最大值
          } else {
          //节点出现交叉,说明需要去求最长递增子序列
            moved = true  
          }
          patch(...)
          patched++  //被处理的节点数量+1
        }
      }
for end----------------------------------------------------------------------
  • 依旧是使用例子来加深理解,以下都为key有效的情况,key无效的情况还是看代码吧哈哈哈

第一次进入for循环

  • 遍历旧节点[s1, e1]这个范围, 首先让prevChild指向目前i指向的也就是s1指向的节点,然后寻找newIndex, 从keyToNewIndexMap取出键为B的值2即为newIndex的值 image.png
  • newIndexToOldIndexMap中该节点对应的值变为i+1, 然后由于maxNewIndexSoFar的值小于等于newIndex,则将newIndex赋值给maxNewIndexSoFar, 然后patch++ image.png

第二次进入for循环

  • prevChild指向目前i指向的节点,也就是c, 然后寻找newIndex, 从keyToNewIndexMap取出键为C的值1, 即为newIndex的值 image.png
  • newIndexToOldIndexMap中该节点对应的值变为i+1, 然后由于maxNewIndexSoFar的值大于newIndex,则将move赋值为true, 然后patch++ image.png
  • 到这里就跳出循环了,主要想获取到的数据是newIndexToOldIndexMap
最后的处理
  • 先看涉及到的函数getSequence,其实就是我们经常说到的求最长升上子序列,这个函数就是用来得到这个序列,不过返回的结果是下标
  • 源码地址: getSequence函数

什么是最长上升子序列?

  • 子序列: 子序列中的元素都存在于该序列中,且不要求连续
  • 上升:序列中的数字从小到大排序
  • 最长:长度最长
function getSequence(arr: number[]): number[] {
 ...
}
  • 进入正文
//moved为ture的时候,获取newIndexToOldIndexMap的最长上升子序列的下标
      const increasingNewIndexSequence = moved 
        ? getSequence(newIndexToOldIndexMap) 
        : EMPTY_ARR
      j = increasingNewIndexSequence.length - 1 //指向上升子序列的最后一位
      //toBePatched的为[s2, e2]这个范围的长度
      for (i = toBePatched - 1; i >= 0; i--) { //从待处理的新节点从后向前遍历
        const nextIndex = s2 + i //当前要处理的新节点的下标
        const nextChild = c2[nextIndex] as VNode  //当前结点
        const anchor =
          nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
        //如果该值为0, 则说明在旧节点数组中找不到能够复用的旧节点---说明是新增的节点
        if (newIndexToOldIndexMap[i] === 0) {
          patch(null, nextChild)  
        } else if (moved) { //moved为true,说明有节点需要移动
        //j<0 说明没有子序列
        //i!=increasingNewIndexSequence[j]说明当前结点不在这个子序列中
          if (j < 0 || i !== increasingNewIndexSequence[j]) {
          //那么就对他进行一个移动处理
            move(nextChild, container, anchor, MoveType.REORDER)
          } else {
          //如果节点在子序列中,那么就不处理,直接跳过
            j--
          }
        }
      }
    }
  }
  • 还是上面这个例子,对于newIndexToOldIndexMap[3, 2, 0]的数组,其最长递增子序列[2],其最长递增子序列下标为[1]
  • 一开始i=2j=0,此时获取的newIndexToOldIndexMap[i]0,则说明其对应的节点是新增的,进行patch新增

image.pngimage.png

  • 接着i--, 此时i === increasingNewIndexSequence[j], 故跳过,直接j--,此时j小于0 image.pngimage.png
  • 接着i--, 此时发现j<0了,故对他进行一个移动处理

image.pngimage.png

到这里其实就很明显了,简略一点思路就是,在最长递增子序列上的元素就可以不用处理,然后对其他待处理的节点进行移动,从而达到更多复用的目的

三、总结

步骤总结

  • 从头进行节点对比,发现不同记录i,跳出
  • 从尾进行节点对比,发现不同记录e1, e2, 跳出
  • 新节点数组还有节点为未处理--进行新增
  • 旧节点数组还有节点为未处理--进行卸载
  • 剩余情况
    • 把没有比较过新节点用keyToNewIndexMap进行保存(键为key, 值为下标)
    • newIndexToOldIndexMap的索引为待处理的新节点的索引,值为可以复用的旧节点的索引+1, 若值为0,则说明该索引代表的新节点没有可以复用的旧节点---就是说新老节点的映射表
    • increasingNewIndexSequence存储newIndexToOldIndexMap的最长上升子序下标
    • 遍历待处理的新节点数组
      • 若该节点在newIndexToOldIndexMap中的值为0,则说明为新增节点,对其新增
      • 否则遍历newIndexToOldIndexMap,若索引值在increasingNewIndexSequence中, 则跳过该节点不移动,否则移动节点

几句话概括

  • 思路:先处理新旧节点两组子节点中相同的前置节点相同的后置节点,当前前置节点和后置节点都处理完毕后,如果无法通过简单的挂载和卸载来完成更新时,在根据节点的索引关系,构造出一个最长递增子序列,它指向的节点即为不需移动的节点

四、区别

  • 我个人是觉得,vue2vue3 diff相似度其实也不高,那核心一点的区别就是

  • vue2 中是通过存储了旧节点列表{ key, index } 的映射表,然后遍历新节点列表的剩余节点,根据newVnode.key在旧映射表中寻找可复用的节点,然后打补丁并且移动到正确的位置 image.png (图:旧节点映射表) image.png (图: 遍历寻找打补丁)

  • 在 vue3 中是建立一个存储新节点数组中的剩余节点在旧节点数组上的索引的映射关系数组,建立完成这个数组后也即找到了可复用的节点,然后通过这个数组计算得到最长递增子序列,这个序列中的节点保持不动,然后将新节点数组中的剩余节点移动到正确的位置 image.png (图:生成映射关系数组)

    image.png

    (图:得到最长递增子序列)

本文正在参加「金石计划 . 瓜分6万现金大奖」