Vue3 Diff 算法深度解读

55 阅读9分钟

初始状态:

  • arr1 (旧子节点): ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
  • arr2 (新子节点): ['a', 'b', 'e', 'c', 'd', 'i', 'g', 'h']

1. 从头部开始同步

从两个数组的头部开始,逐一比较对应位置的节点,直到遇到不同的节点为止。i 指针递增。

let i = 0 // 起始索引
const l2 = c2.length // 新子节点的长度
let e1 = c1.length - 1 // 旧子节点的尾部索引
let e2 = l2 - 1 // 新子节点的尾部索引

// 1. 从头部开始同步
// (a b) c
// (a b) d e
while (i <= e1 && i <= e2) {
  // 当前比较的旧 vnode 和新 vnode
  const n1 = c1[i]
  const n2 = (c2[i] = optimized
    ? cloneIfMounted(c2[i] as VNode)
    : normalizeVNode(c2[i]))

  // 类型相同则进行深度比较
  if (isSameVNodeType(n1, n2)) {
    patch(
      n1,
      n2,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
  // 类型不同跳出循环
  } else {
    break
  }
  
  i++ // 头部指针自增
}

比较过程:

  • 比较 arr1[0]arr2[0] -> 都是 'a',相同,继续。
  • 比较 arr1[1]arr2[1] -> 都是 'b',相同,继续。
  • 到达 arr1[2]arr2[2] -> 'c' vs 'e',不同,停止。

头部指针 i 自增到 2

2. 从尾部开始同步

从两个数组的尾部开始,逐一比较对应位置的节点,直到遇到不同的节点为止。e1e2 指针递减。

// 2. 从尾部开始同步
// a (b c)
// d e (b c)
while (i <= e1 && i <= e2) {
  // 获取旧 vnode 的尾部元素
  const n1 = c1[e1]
  // 获取 vnode 的尾部元素,并进行优化处理:如果节点已挂载,则克隆它;否则规范化它
  const n2 = (c2[e2] = optimized
    ? cloneIfMounted(c2[e2] as VNode)
    : normalizeVNode(c2[e2]))

  // 类型相同则进行深度比较
  if (isSameVNodeType(n1, n2)) {
    patch(
      n1,
      n2,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
  // 类型不同跳出循环
  } else {
    break
  }

  // 移动到数组的前一个元素进行下一次比较
  e1-- // 旧子节点尾部指针递减
  e2-- // 新子节点尾部指针递减
}

比较过程:

  • 比较 arr1[7]arr2[7] -> 都是 'h',相同,继续。
  • 比较 arr1[6]arr2[6] -> 都是 'g',相同,继续。
  • 到达 arr1[5]arr2[5] -> 'f' vs 'i',不同,停止。

旧子节点尾部指针 e1 自减2,变为5

新子节点尾部指针 e2 自减2,变为5

3. 新子节点有剩余,添加新节点

成立条件为 i > e1i > e1 表示所有旧子节点都已经遍历完成,此时仍有新子节点没有被遍历到,添加它们。

// 3. 新子节点有剩余,要添加新节点
// (a b)
// (a b) c
// i = 2, e1 = 1, e2 = 2
// (a b)
// c (a b)
// i = 0, e1 = -1, e2 = 0
// 检查是否已处理完所有旧子节点,但仍有新子节点要处理
if (i > e1) {
  // 检查是否还有未处理的新子节点
  if (i <= e2) {
    // 计算下一个要处理的新子节点的位置
    const nextPos = e2 + 1
    // 确定要插入的锚点。如果下一个位置小于新子节点数组的长度,
    // 则使用该位置的元素作为锚点;否则,使用父锚点。
    const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor

    // 循环处理剩余的新子节点
    while (i <= e2) {
      // 对每个新子节点执行 patch 操作。
      // 如果启用了优化并且节点已挂载,则克隆它;否则,规范化它。
      patch(
        null, // 由于这是挂载新节点,旧节点参数为 null
        (c2[i] = optimized
          ? cloneIfMounted(c2[i] as VNode)
          : normalizeVNode(c2[i])),
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        slotScopeIds,
        optimized
      )

      // 移动到下一个新子节点
      i++
    }
  }
}

此时 i = 2e1 = 5e2 = 5

由于 i > e1 不成立,此步骤无操作

4. 旧子节点有剩余,删除多余节点

成立条件为 i > e2i > e2 表示所有新子节点都已经遍历完成,还有一些旧子节点没有被遍历到,删除它们。

// 4. 旧子节点有剩余,要删除多余节点
// (a b) c
// (a b)
// i = 2, e1 = 2, e2 = 1
// a (b c)
// (b c)
// i = 0, e1 = 0, e2 = -1
else if (i > e2) {
  while (i <= e1) {
    // 卸载多余的旧子节点
    unmount(c1[i], parentComponent, parentSuspense, true)
    i++
  }
}

由于 i > e2 不成立,此步骤无操作

5. 处理未知子序列

  • 5.1 为新子节点构建 { key: index }的映射

const s1 = i // 旧子节点的起始索引
const s2 = i // 新子节点的起始索引

// 5.1 为新子节点构建键到索引(key:index)的映射
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]))

// 如果节点存在 key
if (nextChild.key != null) {
  // 如果在开发模式下发现重复的键,发出警告
  if (__DEV__ && keyToNewIndexMap.has(nextChild.key)) {
    warn(
      `在更新期间发现重复的键(key):`,
      JSON.stringify(nextChild.key),
      `请确保键(key)是唯一的。`
    )
  }

  // 将节点的 key 和其在数组中的索引添加到映射中
  keyToNewIndexMap.set(nextChild.key, i)
}
}

遍历新子节点中的未知序列部分(['e', 'c', 'd', 'i']),建立 keyindex 的映射表 keyToNewIndexMap。如下:

  • 'e' -> 2
  • 'c' -> 3
  • 'd' -> 4
  • 'i' -> 5
  • 5.2 遍历旧子节点列表,尝试匹配并更新节点,移除不再存在的节点

    • 确定是否有节点移动 (moved)
    • 追踪新节点在旧节点列表中的位置 (newIndexToOldIndexMap)
    • 检查每个旧子节点是否可以在新子节点列表中找到匹配项,如果找不到,则移除该节点。
// 5.2 遍历旧子节点列表,尝试匹配并更新节点,移除不再存在的节点
let j
let patched = 0 // 已处理的新子节点数
const toBePatched = e2 - s2 + 1 // 需要处理的新子节点总数
let moved = false // 用于跟踪是否有节点移动
let maxNewIndexSoFar = 0 // 到目前为止遇到的最大的新节点索引

// 作为 Map<newIndex, oldIndex> 使用
// 注意 oldIndex 是以 +1 偏移量
// 用于确定最长递增子序列
const newIndexToOldIndexMap = new Array(toBePatched) // 追踪新节点在旧节点列表中的位置

// 初始化数组,每个元素值都为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, parentComponent, parentSuspense, true)
    continue
  }

  let newIndex
  // 如果旧节点有 key,则尝试在 keyToNewIndexMap 中找到对应的新节点索引
  if (prevChild.key != null) {
    newIndex = keyToNewIndexMap.get(prevChild.key)
  } else {
    // 对于没有 key 的节点,尝试找到类型相同的新节点
    for (j = s2; j <= e2; j++) {
      if (
        newIndexToOldIndexMap[j - s2] === 0 &&
        isSameVNodeType(prevChild, c2[j] as VNode)
      ) {
        newIndex = j
        break
      }
    }
  }

  // 如果在新节点列表中找不到对应节点,则卸载旧节点
  if (newIndex === undefined) {
    unmount(prevChild, parentComponent, parentSuspense, true)
  } else {
    // 更新 newIndexToOldIndexMap 以便于后续确定最长递增子序列
    newIndexToOldIndexMap[newIndex - s2] = i + 1
    
    if (newIndex >= maxNewIndexSoFar) {
      maxNewIndexSoFar = newIndex
    } else {
      // 如果新节点索引小于之前的最大索引,则表示节点有移动
      moved = true
    }
    // 更新节点
    patch(
      prevChild,
      c2[newIndex] as VNode,
      container,
      null,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
    patched++
  }
}
  1. 初始化 newIndexToOldIndexMap[0, 0, 0, 0](长度为新子节点未知序列的长度,对应 'e', 'c', 'd', 'i')。

  2. 遍历旧子节点中的未知序列部分(['c', 'd', 'e', 'f']):

    • 遍历到 'c'
      • i = 2
      • 通过映射找到 'c' 在新子节点中的新位置是 3
      • newIndex = 3
      • newIndexToOldIndexMap[1] = 3newIndexToOldIndexMap 更新为 [0, 3, 0, 0]
      • maxNewIndexSoFar = 3
      • moved = false (因为这是第一个元素)
    • 遍历到 'd'
      • i = 3
      • 通过映射找到 'd' 在新子节点中的新位置是 4
      • newIndex = 4
      • newIndexToOldIndexMap[2] = 4newIndexToOldIndexMap 更新为 [0, 3, 4, 0]
      • maxNewIndexSoFar = 4 (因为 4 > 3)
      • moved 保持 false
    • 遍历到 'e'
      • i = 4
      • 通过映射找到 'e' 在新子节点中的新位置是 2
      • newIndex = 2
      • newIndexToOldIndexMap[0] = 5newIndexToOldIndexMap 更新为 [5, 3, 4, 0]
      • maxNewIndexSoFar 保持 4 (因为 2 < 4)
      • moved = true (因为 2 < 4)
    • 遍历到 'f'
      • i = 5
      • newIndex = undefined
      • arr2 中没有找到 'f',因此卸载 'f'
  • 5.3 移动和挂载新节点

    • 通过 LIS 计算出最长递增子序列,LIS 用于优化节点的移动操作,确保只移动必要的节点。
    • 倒序遍历新节点未知子序列,执行挂载或移动节点。倒序遍历是为了确保在移动或添加节点时,每个节点都可以被放置在正确的位置,尤其是当节点需要移动到其他已经存在的节点之前时。
// 5.3 移动和挂载新节点
// 仅在节点有移动时生成最长递增子序列
const increasingNewIndexSequence = moved
  ? getSequence(newIndexToOldIndexMap) // 计算最长递增子序列
  : EMPTY_ARR
j = increasingNewIndexSequence.length - 1
// 倒序遍历新子节点的未知子序列,这样可以使用最后一个处理过的节点作为锚点
for (i = toBePatched - 1; i >= 0; i--) {
  // 计算当前新节点在 arr2 中的索引
  const nextIndex = s2 + i
  // 获取当前要处理的新节点
  const nextChild = c2[nextIndex] as VNode
  // 确定当前新节点的锚点位置
  const anchor =
    nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
  
  // 如果当前新节点在旧节点中没有对应的节点,则进行挂载
  if (newIndexToOldIndexMap[i] === 0) {
    patch(
      null, // 旧节点为 null 表示这是一个新挂载的节点
      nextChild,
      container,
      anchor,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
  // 如果节点有移动,则根据条件判断是否需要移动当前节点
  } else if (moved) {
    // 条件是:没有递增子序列或当前节点不在递增子序列中
    if (j < 0 || i !== increasingNewIndexSequence[j]) {
      move(nextChild, container, anchor, MoveType.REORDER)
    } else {
      j--
    }
  }
}

根据之前的步骤,得出 newIndexToOldIndexMap[5, 3, 4, 0]

根据 LIS 算出最长递增子序列 increasingNewIndexSequence[1, 2]

  1. 逆向遍历新子节点中的未知序列 (['e', 'c', 'd', 'i']):
    • toBePatched = 4 (未知序列的长度)
    • l2 = arr2.length = 8
  2. 遍历过程 :
    • 遍历 'i' (最后一个新增节点):
      • i = 3
      • nextIndex = s2 + i = 2 + 3 = 5
      • nextChild = arr2[nextIndex] = 'i'
      • anchor = nextIndex + 1 < l2 ? arr2[nextIndex + 1].el : parentAnchor = 'g'.el
      • j = 1 (LIS 的最后一个索引)
      • newIndexToOldIndexMap[i] = 0 (表示 'i' 是新节点)
      • 执行 patch 挂载 'i'
      • 执行 i--
    • 遍历 'd' :
      • i = 2
      • nextIndex = 4
      • nextChild = 'd'
      • anchor = 'i'.el (因为 'i' 刚被挂载)
      • 'd' 在 LIS 中,不需要移动
      • j-- 变为 0 (因为 j < 0 || 2 !== 2不成立)
      • 执行 i--
    • 遍历 'c' :
      • i = 1
      • nextIndex = 3
      • nextChild = 'c'
      • anchor = 'd'.el
      • 'c' 在 LIS 中,不需要移动
      • j-- 变为 -1 (因为 j < 0 || 1 !== 1不成立)
      • 执行 i--
    • 遍历 'e' :
      • i = 0
      • nextIndex = 2
      • nextChild = 'e'
      • anchor = 'c'.el
      • 'e' 不在 LIS 中,需要移动
      • 执行 move 将 'e' 移动到 'c' 的位置

手写一个 diff 算法

将 diff 算法的逻辑从 Vue3 的源码中剥离出来