Vue 3 diff算法自用版

73 阅读6分钟

Vue 3 中当新旧两个 Vnode 节点的 children 都是 Array 时,会进入 Vue 的 Diff 环节,也就是 patchKeyedChildren 函数。

接下来我带大家过一下该方法的主要逻辑,源代码已经经过简化:

1. 准备工作:
  // can be all-keyed or mixed
  const patchKeyedChildren = (
    c1: VNode[],
    c2: VNodeArrayChildren,
    container: RendererElement
  ) => {
    let i = 0
    const l2 = c2.length
    let e1 = c1.length - 1 // prev ending index
    let e2 = l2 - 1 // next ending index
	}
  • c1 是旧的节点序列, e1 指向旧序列的最后一个节点
  • c2 是新的节点序列, e2 指向新序列的最后一个节点
  • i 我们用来遍历使用

为了方便讲解,我假设如下序列:

prev: a b c d e f g h
next: a b e c d i g h
i = 0
e1 = 7
e2 = 7
2. 过滤相同头节点
  • 通过 isSameVNodeType 会对比节点的 keytype 是否相同。
isSameVNodeType = (n1,n2) => n1.type === n2.type && n1.key === n2.key
  • 如果相同那么代表旧节点可以复用 ,继续对比下一个节点

  • 如果有不同的头节点,那么直接结束循环进入下一步流程

    对比后序列如下的形式:

prev: (a b) c d e f g h
next: (a b) e c d i g h
i = 2
e1 = 7
e2 = 7

例子中对比完成后 , a b我们达成了复用,不会参与到后续的diff中

以下是源代码:

// 正序遍历
while (i <= e1 && i <= e2) {
  const n1 = c1[i]
  const n2 = c2[i]
  if (isSameVNodeType(n1, n2)) {
    // 相同节点直接复用
    patch( n1, n2, container )
  } else {
    // 不同结束循环,进入尾节点 流程比较
    break
  }
//继续循环对比
  i++
}
3. 过滤相同的尾节点
  • 通过 isSameVNodeType 会对比节点的 keytype 是否相同。

  • 如果相同那么代表旧节点可以复用 ,e1-- 和 e2-- 继续对比下一个节点

  • 如果有不同的尾节点,那么直接结束循环进入下一步流程。

    对比后序列如下的形式:

prev:(a b) c d e f (g h)
next:(a b) e c d i (g h)
i = 2
e1 = 5
e2 = 5

对比完成后 , g 和 h 我们达成了复用,不会参与到后续的diff中。

以下是源代码:

    // 2. 新旧子序列尾节点比较,过滤掉相同的尾节点
    while (i <= e1 && i <= e2) {
      const n1 = c1[e1]
      const n2 = c2[e2] 
      // n1.type === n2.type && n1.key === n2.key
      if (isSameVNodeType(n1, n2)) {
        // 相同节点复用
        patch( n1, n2, container )
      } else {
        // 如果有不同的尾节点,直接结束循环,进入第三步
        break
      }
        //倒序去搞
      e1--
      e2--
    }
4. 旧序列遍历完成,mount 新节点
  • 如果旧序列已经遍历完成,而新序列还未遍历完成,可以继续遍历新序列,然后 mount 新节点

    旧序列可以直接复用的数据有两种情况:

  • 从头节点遍历完全相同

	prev: (a b)
	next: (a b) c
	i = 2
	e1 = 1
	e2 = 2
  • 从尾节点遍历完全相同
	prev:(a b)
	next:c (a b)
	i = 0
	e1 = -1
	e2 = 0

针对这两种情况,我们直接 mount 新节点,以下是源代码:

// 旧序列遍历完成
if (i > e1) {
    // 新序列还未遍历完成
  if (i <= e2) {
    const nextPos = e2 + 1
    while (i <= e2) {
    // mount节点 n1 为 null,n2 为 VNode
      patch(
        null,
        c2[i],
        container,
      )
      i++
    }
  }
}
5. 新序列遍历完成,unmount 旧节点

那么新节点mount完成了,我们来看另一种情况:如果新序列已经遍历完,而旧序列还未遍历完,我们可以继续遍历旧序列,然后 unmount 无用节点。

数据形式也是两种情况:

  • 从头节点遍历完全相同
	prev: (a b) c
	next: (a b) 
	i = 2
	e1 = 2
	e2 = 1
  • 从尾节点遍历完全相同
	prev:c (a b)
	next:  (a b)
	i = 0
	e1 = 0
	e2 = -1

针对这两种情况,我们直接 unmount 移除节点,以下是源代码:

// 新序列遍历完成
else if (i > e2) {
    while (i <= e1) {
        unmount(c1[i])
        i++
    }
}
6.处理不同的子序列

经过以上处理,我们得到以下的结构:

// [i ... e1 + 1]: a b [c d e] f g
// [i ... e2 + 1]: a b [e d c h] f g
// i = 2, e1 = 4, e2 = 5

接下来我们来处理中间的子序列,处理这个序列中的新增、删除、移动的情况。

6.1 生成 keyToNewIndexMap

得出旧节点在新序列中的位置,生成数组 keyToNewIndexMap

   
    else {
      // 旧子序列开始索引,从i开始记录
      const s1 = i 
      // 新子序列开始索引,从i开始记录
      const s2 = i 
      // 得出节点在新序列中的位置
      const keyToNewIndexMap = new Map()
      for (i = s2; i <= e2; i++) {
        const nextChild = c2[i]
        if (nextChild.key != null) {
          keyToNewIndexMap.set(nextChild.key, i)
        }
      }
6.2 新旧子序列节点的更新、多余节点的删除

建立了一个 newIndexToOldIndexMap 存储新子序列的节点的索引和旧子序列节点的索引之间的映射关系,并确定是否有移动 moved

  let j
  let patched = 0
  const toBePatched = e2 - s2 + 1
  // 用户跟踪判断是否有节点移动
  let moved = false
  let maxNewIndexSoFar = 0

  // 这个数组用来存储新子序列中元素在旧子序列中元素的位置
  const newIndexToOldIndexMap = new Array(toBePatched)
  // 初始化数组,数组的每一位都是0
  // 0 是一个特殊的值,如果遍历完了仍有元素的值为0,则说明这个新节点没有对应的旧节点 ,需要新建节点
  for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
  // 正序遍历子序列
  for (i = s1; i <= e1; i++) {
    const prevChild = c1[i]
    if (patched >= toBePatched) {
      // 所有新的子项都已修补,因此这只能是删除
      unmount(prevChild)
      continue
    }
    let newIndex
    if (prevChild.key != null) {
      // 如果有key,查看该老节点在新序列中的位置
      newIndex = keyToNewIndexMap.get(prevChild.key)
    } else {
      // 如果没有 key,尝试在新列表中查找相同节点的索引
      for (j = s2; j <= e2; j++) {
        if (
          newIndexToOldIndexMap[j - s2] === 0 &&
          isSameVNodeType(prevChild, c2[j])
        ) {
          newIndex = j
          break
        }
      }
    }

    if (newIndex === undefined) {
       // 如果newIndex没有查到 直接卸载节点
      unmount(prevChild)
    } else {
      // 更新新子序列中的元素在旧子序列中的索引,这里加 1 偏移,是为了避免 i 为 0 的特殊情况,影响对后续最长递增子序列的求解
      newIndexToOldIndexMap[newIndex - s2] = i + 1
      // maxNewIndexSoFar 始终存储的是上次求值的 newIndex 如果不是一直递增,则说明有移动
      if (newIndex >= maxNewIndexSoFar) {
        maxNewIndexSoFar = newIndex
      } else {
        moved = true
      }
      // 更新新旧子序列中匹配的节点
      patch(
        prevChild,
        c2[newIndex] as VNode,
        container
      )
      patched++
    }
  }
6.3 移动和挂载节点

根据 (newIndexToOldIndexMap 简称) map 生成最长递增子序列 sequence,倒序遍历 map,如果map[i]为 0 新建节点,如果不为 0 移动节点

      // 仅当节点移动时才生成最长递增子序列
      const sequence = moved
        // getSequence 最长递增子序列算法
        ? getSequence(newIndexToOldIndexMap)
        : EMPTY_ARR
      j = sequence.length - 1
      //  倒序遍历以便我们可以以最后更新的节点作为锚点
      for (i = toBePatched - 1; i >= 0; i--) {
        const nextIndex = s2 + i
        const nextChild = c2[nextIndex] as VNode
      // 如果newIndexToOldIndexMap取出的数为 0 那么新建节点
        if (newIndexToOldIndexMap[i] === 0) {
      // 挂载新的子节点
          patch(
            null,
            nextChild,
            container
          )
        } else if (moved) {
          // 没有最长递增序列(reverse 数组的场景)
          // 或者 当前的节点索引不在最长递增子序列中,需要移动节点
          if (j < 0 || i !== sequence[j]) {
            move(nextChild, container, anchor, MoveType.REORDER)
          } else {
          // 倒序最长递增子序列
            j--
          }
        }
      }
    }
  }

以上即为 Vue 3 中 patchKeyedChildren 的主要逻辑,也是diff算法的主要逻辑,时间关系(最长递增子序列/patch/unmount/move)的代码,还未贴上,后续会继续更上的。