vue3-diff

158 阅读4分钟

Diff

Tips:下面主要讲述同层节点(如下图所示)的对比流程 截屏2021-12-22 下午5.21.48.png

具体实现:

截屏2021-12-10 上午8.09.14.png

1. 预处理 -> 找到开头和结尾不需要移动的节点

  • 头头对比 截屏2021-12-06 上午10.38.26.png
  • 变量初始化
c1: VNode[], // 老节点数组
c2: VNodeArrayChildren, // 新节点数组
let i = 0 // 开始遍历的索引
const l2 = c2.length
let e1 = c1.length - 1 
let e2 = l2 - 1
// 从头开始遍历
while (i <= e1 && i <= e2) {
      const n1 = c1[i]
      const n2 = (c2[i] = optimized
        ? cloneIfMounted(c2[i] as VNode)
        : normalizeVNode(c2[i]))
      if (isSameVNodeType(n1, n2)) { // 节点一样,对节点进行patch
        patch(
          n1,
          n2,
          container,
          null,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      } else {
        break
      }
      i++ // 遍历指针后移
    }
当左边没有相同的节点时,接下来会进行尾尾对比
  • 尾尾对比

截屏2021-12-06 上午10.49.41.png

// 从尾部开始遍历
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
        patch(
          n1,
          n2,
          container,
          null,
          parentComponent,
          parentSuspense,
          isSVG,
          slotScopeIds,
          optimized
        )
      } else {
        break
      }
      e1--
      e2--
    }

经过预处理之后,剩余节点的开头和结尾都不相同,接下来会对剩余节点进行判断处理

2. 判断


剩余节点会出现以下几种情况:

  • 老节点已经全部遍历完成,新节点有剩余
  • 新节点已经遍历完成,而老节点还未遍历完成
  • 新老节点均有剩余

老节点已经全部遍历完成,新节点有剩余

截屏2021-12-06 上午10.38.26.png

上图中,C节点为新节点中的剩余节点,对节点进行挂载处理即可

if (i > e1) { // 判断老节点是否遍历完成
      if (i <= e2) { // 新节点是否遍历完成
        const nextPos = e2 + 1
        const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
        while (i <= e2) {
          patch(
            null,
            (c2[i] = optimized
              ? cloneIfMounted(c2[i] as VNode)
              : normalizeVNode(c2[i])),
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized
          )
          i++
        }
      }
    }

新节点已经遍历完成,而老节点还未遍历完成,卸载剩余节点

截屏2021-12-06 上午10.49.41 2.png

上图中,a节点为老节点中的剩余节点,直接卸载即可

   if (i > e2) { 
      while (i <= e1) {
        unmount(c1[i], parentComponent, parentSuspense, true)
        i++
      }
    }

新老节点均未遍历完

image.png

上图中,没有用框画出来的节点,他们头和尾都不相同且均有剩余。 下面内容都以上图为示例进行介绍

  • 变量初始化
const s1 = i // 老节点剩余节点的开始节点索引
const s2 = i // 新节点剩余节点的开始节点索引
const toBePatched = e2 - s2 + 1 // 新节点中剩余需要被patch的节点数
let patched = 0 // 被patch过的数量
let moved = false // 是否需要移动节点
let maxNewIndexSoFar = 0 // 已遍历的待处理的 c1 节点在 c2 中对应的索引最大值
  • 根据新节点中的剩余节点生成map(用于新老节点的对比),map的key为当前节点的key,value为当前索引
const keyToNewIndexMap: Map<string | number | symbol, number> = new Map()
for (i = s2; i <= e2; i++) {
   const nextChild = (c2[i] = optimized
      ? cloneIfMounted(c2[i] as VNode)
      : normalizeVNode(c2[i]))
   if (nextChild.key != null) {
      keyToNewIndexMap.set(nextChild.key, i)
   }
 }
若以节点的值为key,示例生成的keyToNewIndexMap = { e:2, c:3, d:4, h:5}
  • 生成一个数组newIndexToOldIndexMap,用于存储新节点与老节点的映射关系->新节点在老节点中的位置,并用0进行填充(若到最后,仍为0,表示老节点中没有对应的新节点),该数组用来后期计算最长递增子序列
const newIndexToOldIndexMap = new Array(toBePatched)
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0

newIndexToOldIndexMap = [0, 0, 0, 0]

  • 遍历老节点剩余节点 -〉经过遍历可以获取到节点是否需要移动
 const prevChild = c1[i] // 当前遍历到的节点
  • 新节点列表所有的节点都已经patch结束,老节点列表剩余的节点进行unmount操作
if (patched >= toBePatched) {
   unmount(prevChild, parentComponent, parentSuspense, true)
   continue
}
  • 获取老节点在新节点中的位置,填充数组newIndexToOldIndexMap(填充的时候,老节点为索引+1,因为0是一个特殊标识,表示在新节点中没有对应的老节点) image.png
newIndex = keyToNewIndexMap.get(prevChild.key)
// 若新节点中没有对应的老节点,直接卸载
if (newIndex === undefined) {
  unmount(prevChild, parentComponent, parentSuspense, true)
} else {
    newIndexToOldIndexMap[newIndex - s2] = i + 1 // 保存对应的映射关系,新节点索引在老节点的位置索引
    if (newIndex >= maxNewIndexSoFar) { // 判断距离开始patch的索引的大小,用于后期移动,求公共子序列
       maxNewIndexSoFar = newIndex
    } else {
      moved = true 
    }
    // 相同的节点,递归执行 patch 更新节点
    patch(
      prevChild,
      c2[newIndex] as VNode,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
    patched++
}

经过此次遍历,此时的newIndexToOldIndexMap更新为[5, 3 ,4, 0], newIndexToOldIndexMap[0] = 5表示在新节点中剩余的第一个节点在新节点中处在老节点中的第五位,以此类推,而newIndexToOldIndexMap[3] = 0就表示当前对应节点在老节点中没有相同的节点

3.移动

是否需要移动

是否需要移动取决于moved这个变量,而moved又取决于maxNewIndexSoFar(当前可复用节点距离第一个乱序节点的最远距离) 截屏2021-12-10 上午7.57.03.png 从前向后遍历节点,如果节点在新旧序列中,都是按照相同的顺序递增,那么maxNewIndexSoFar也会一直递增,即每次迭代newIndex >= maxNewIndexSoFar(newIndex这个变量表示当前节点在新节点中的索引值),那么就不需要移动节点;但是如果某次迭代,newIndex < maxNewIndexSoFar,那么说明当前节点由之前靠后的位置移动到了现在靠前的位置,这时候就需要移动。

const increasingNewIndexSequence = moved
        ? getSequence(newIndexToOldIndexMap)
        : EMPTY_ARR
j = increasingNewIndexSequence.length - 1

示例中

  • c -》newIndex为3,maxNewIndexSoFar为3
  • d -》newIndex为4,maxNewIndexSoFar为4
  • e -》newIndex为2,小于当前的maxNewIndexSoFar -> 4,在c前面,而在旧节点中是在d后面, 出现交叉 -》需要移动

如何移动

从后向前遍历新节点中剩余节点,会出现以下几种情况

  • 当前节点未有对应的旧节点下标,则说明是新增节点
  • 该节点需要移动,进行移动
  • 该节点与旧节点序列都保持递增顺序(在递增子序列中),直接跳过即可
   for (i = toBePatched - 1; i >= 0; i--) {
        const nextIndex = s2 + i // 对应新列表的index
        const nextChild = c2[nextIndex] as VNode // 找到vnode
        const anchor =
          nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor // 下一个节点的位置,用于移动DOM
        if (newIndexToOldIndexMap[i] === 0) {
          // mount new,全新节点
          patch(
            null,
            nextChild,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized
          )
        } else if (moved) {
          // move if:
          // There is no stable subsequence (e.g. a reverse)
          // OR current node is not among the stable sequence
          // 当前节点不在稳定序列中
          if (j < 0 || i !== increasingNewIndexSequence[j]) {
            move(nextChild, container, anchor, MoveType.REORDER)
          } else {
            j--
          }
        }
      }

最长递增子序列

  • 动态规划 + 二分法查找最长递增子序列
  • 回溯:校正子序列 截屏2021-12-09 上午11.18.02.png
function getSequence(arr: number[]): number[] {
  const p = arr.slice() // 存储当前节点的上一个节点索引,用于校正result数组
  const result = [0] // 存储最长增长子序列的索引数组, 有序,递增
  let i, j, u, v, c
  const len = arr.length // 4
  for (i = 0; i < len; i++) {
    const arrI = arr[i]
    if (arrI !== 0) {
      j = result[result.length - 1]
      if (arr[j] < arrI) { // 若当前数据大于子序列索引最后一项,push当前数据
        p[i] = j // p[0] = 0 ; p[1] = 0
        result.push(i) // result = [0, 1]; result = [0, 1, 2] 
        continue
      }
      u = 0 // u为二分法开始索引, v为结束索引
      v = result.length - 1 // v = 2
      while (u < v) { // 二分法查找
        c = (u + v) >> 1
        if (arr[result[c]] < arrI) {
          u = c + 1
        } else {
          v = c
        }
      }
      if (arrI < arr[result[u]]) {
        if (u > 0) {
          // 需要替换的为result[u],p[i]记录的为需要替换的前一位
          p[i] = result[u - 1]
        }
        result[u] = i
      }
    }
  }

  u = result.length
  v = result[u - 1]
  while (u-- > 0) {
    result[u] = v 
    v = p[v]
  }
  return result
}

Tips:我们这里得到的是最长递增子序列的索引

示例中我们得到的最长递增子序列为increasingNewIndexSequence = [1,2],表示在newIndexToOldIndexMap这个数组中,索引1 ,2 组成的最长递增子序列,而这里的【1,2】在节点中表示节点【c, d】,最终我们只需要移动节点e,挂载节点h即可。

参考

源码地址;github.com/vuejs/vue-n…