vue3源码解析:diff算法之子节点Diff算法分析

73 阅读7分钟

在上文中,我们分析了 patchChildren 函数,了解到Vue会根据子节点是否带有key,分别调用 patchKeyedChildrenpatchUnkeyedChildren 进行更新。本文我们将深入分析这两个函数的实现,理解Vue字节点Diff算法。

patchUnkeyedChildren实现分析

const patchUnkeyedChildren = (
  c1: VNode[],
  c2: VNodeArrayChildren,
  container: RendererElement,
  anchor: RendererNode | null,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  namespace: ElementNamespace,
  slotScopeIds: string[] | null,
  optimized: boolean,
) => {
  c1 = c1 || EMPTY_ARR
  c2 = c2 || EMPTY_ARR
  const oldLength = c1.length
  const newLength = c2.length
  const commonLength = Math.min(oldLength, newLength)
  let i
  for (i = 0; i < commonLength; i++) {
    const nextChild = (c2[i] = optimized
      ? cloneIfMounted(c2[i] as VNode)
      : normalizeVNode(c2[i]))
    patch(
      c1[i],
      nextChild,
      container,
      null,
      parentComponent,
      parentSuspense,
      namespace,
      slotScopeIds,
      optimized,
    )
  }
  if (oldLength > newLength) {
    // 移除多余的旧节点
    unmountChildren(
      c1,
      parentComponent,
      parentSuspense,
      true,
      false,
      commonLength,
    )
  } else {
    // 挂载新增的节点
    mountChildren(
      c2,
      container,
      anchor,
      parentComponent,
      parentSuspense,
      namespace,
      slotScopeIds,
      optimized,
      commonLength,
    )
  }
}

无key子节点的更新策略

  1. 按位置一一对应更新

    • 取新旧子节点数组的最小长度
    • 在公共长度范围内按位置进行patch
    • 不考虑节点的复用,完全按照位置对应
  2. 处理长度差异

    • 如果旧节点更多:移除多余的旧节点
    • 如果新节点更多:挂载新增的节点
    • 从commonLength位置开始处理
  3. 优化处理

    • 通过optimized参数控制是否需要克隆已挂载的节点
    • 对新节点进行标准化处理

patchKeyedChildren实现分析

const patchKeyedChildren = (
  c1: VNode[],
  c2: VNodeArrayChildren,
  container: RendererElement,
  parentAnchor: RendererNode | null,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  namespace: ElementNamespace,
  slotScopeIds: string[] | null,
  optimized: boolean,
) => {
  let i = 0
  const l2 = c2.length
  let e1 = c1.length - 1 // 旧节点的结束索引
  let e2 = l2 - 1       // 新节点的结束索引

  // 1. 从头部开始同步
  // (a b) c
  // (a b) d e
  // 这里的 a b 是相同的前缀节点,可以直接复用
  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(n1, n2, container, null, parentComponent, parentSuspense,
        namespace, slotScopeIds, optimized)
    } else {
      break
    }
    i++
  }

  // 2. 从尾部开始同步
  // a (b c)
  // d e (b c)
  // 这里的 b c 是相同的后缀节点,可以直接复用
  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(n1, n2, container, null, parentComponent, parentSuspense,
        namespace, slotScopeIds, optimized)
    } else {
      break
    }
    e1--
    e2--
  }

  // 3. 处理新增节点
  // (a b)
  // (a b) c
  // i = 2, e1 = 1, e2 = 2
  // 这种情况下需要新增节点 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], container, anchor, parentComponent,
          parentSuspense, namespace, slotScopeIds, optimized)
        i++
      }
    }
  }

  // 4. 处理需要删除的节点
  // (a b) c
  // (a b)
  // i = 2, e1 = 2, e2 = 1
  // 这种情况下需要删除节点 c
  else if (i > e2) {
    while (i <= e1) {
      unmount(c1[i], parentComponent, parentSuspense, true)
      i++
    }
  }

  // 5. 处理未知序列
  // 例如,有如下更新:
  // 旧子节点: [p q] [a b c d e] [x y]
  // 新子节点: [p q] [c e b a d f] [x y]
  // 经过前面的首尾处理后:
  // - 先同步了前缀 [p q]
  // - 再同步了后缀 [x y]
  // - 剩余中间部分 [a b c d e] -> [c e b a d f] 需要处理
  else {
    const s1 = i
    const s2 = i

    // 5.1 建立新节点的key到索引的映射
    // 为剩余的 [c e b a d f] 建立映射:
    // {
    //   c: 0,
    //   e: 1,
    //   b: 2,
    //   a: 3,
    //   d: 4,
    //   f: 5
    // }
    const keyToNewIndexMap = 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)
      }
    }

    // 5.2 遍历旧节点,尝试patch匹配的节点并移除不再存在的节点
    // 遍历剩余的旧子节点 [a b c d e],尝试在新子节点中找到对应位置
    // newIndexToOldIndexMap 记录新子节点在旧子节点中的位置
    // 对于 [a b c d e] -> [c e b a d f] 的映射关系是:
    // newIndexToOldIndexMap: [3, 5, 2, 1, 4, 0]
    // 这个数组表示:
    // 索引: 0  1  2  3  4  5
    // 新节点: c  e  b  a  d  f
    // 旧位置: 3  5  2  1  4  0  (注:实际值都+1了,0表示新节点)
    let j
    let patched = 0
    const toBePatched = e2 - s2 + 1  // 需要处理的新节点数量
    let moved = false
    let maxNewIndexSoFar = 0

    const newIndexToOldIndexMap = new Array(toBePatched)
    for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0

    for (i = s1; i <= e1; i++) {
      const prevChild = c1[i]
      if (patched >= toBePatched) {
        // 所有新节点都已patch,剩余的旧节点可以直接删除
        unmount(prevChild, parentComponent, parentSuspense, true)
        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] as VNode)
          ) {
            newIndex = j
            break
          }
        }
      }

      if (newIndex === undefined) {
        // 在新子节点中找不到,需要删除
        unmount(prevChild, parentComponent, parentSuspense, true)
      } else {
        // 记录新节点在旧节点中的位置
        newIndexToOldIndexMap[newIndex - s2] = i + 1
        // 用于判断是否需要移动
        if (newIndex >= maxNewIndexSoFar) {
          maxNewIndexSoFar = newIndex
        } else {
          moved = true
        }
        // 更新节点
        patch(
          prevChild,
          c2[newIndex] as VNode,
          container,
          null,
          parentComponent,
          parentSuspense,
          namespace,
          slotScopeIds,
          optimized,
        )
        patched++
      }
    }

    // 5.3 移动和挂载
    // 最长递增子序列是 [0, 4],对应新节点序列中的c和d
    // 这意味着这两个节点可以保持相对位置不变,其他节点需要移动
    //
    // Vue从后往前遍历处理节点:
    // 1. f是新节点,直接创建并插入
    // 2. d在最长递增子序列中,保持不动
    // 3. a需要移动到d之前
    // 4. b需要移动到a之前
    // 5. e需要移动到b之前
    // 6. c在最长递增子序列中,保持不动
    const increasingNewIndexSequence = moved
      ? getSequence(newIndexToOldIndexMap)  // 获取最长递增子序列
      : EMPTY_ARR
    j = increasingNewIndexSequence.length - 1

    // 从后向前遍历,这样可以使用后面的节点作为锚点
    for (i = toBePatched - 1; i >= 0; i--) {
      const nextIndex = s2 + i
      const nextChild = c2[nextIndex] as VNode
      // 确定锚点(下一个节点的DOM元素)
      const anchor =
        nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
      
      if (newIndexToOldIndexMap[i] === 0) {
        // 新节点,需要挂载
        // 例如例子中的节点f
        patch(null, nextChild, container, anchor, parentComponent,
          parentSuspense, namespace, slotScopeIds, optimized)
      } else if (moved) {
        // 需要移动的节点
        // 如果没有最长递增子序列,或当前节点不在最长递增子序列中
        // 例如例子中的节点e、b、a需要移动
        if (j < 0 || i !== increasingNewIndexSequence[j]) {
          move(nextChild, container, anchor, MoveType.REORDER)
        } else {
          // 当前节点在最长递增子序列中,不需要移动
          // 例如例子中的节点c和d
          j--
        }
      }
    }
  }
}

带key子节点的更新策略

  1. 快速路径处理

    • 从头部开始同步:处理相同的前缀节点
    • 从尾部开始同步:处理相同的后缀节点
    • 这两步可以快速处理序列两端相同的节点
  2. 处理剩余情况

    • 新增节点:当旧节点处理完,但新节点还有剩余
    • 删除节点:当新节点处理完,但旧节点还有剩余
    • 这两种情况的处理都相对简单直接
  3. 未知序列处理

    • 建立key到索引的映射:优化查找效率
    • 处理可复用的节点:尽可能复用已有节点
    • 最长递增子序列算法:优化节点移动

核心优化策略

  1. 双端比较算法

    • 同时从头尾开始比较
    • 快速处理节点位置相对固定的情况
    • 减少不必要的比较和移动
  2. key的作用

    • 快速定位可复用的节点
    • 避免不必要的DOM操作
    • 提高diff效率
  3. 最长递增子序列

    • 用于优化节点移动
    • 确保最少的DOM移动操作
    • 提高更新性能

总结

通过分析这两个函数,我们可以看到Vue在处理子节点更新时采用了不同的策略:

  1. 无key子节点

    • 简单的位置对应更新
    • 适用于静态或很少变化的列表
    • 性能较差,不建议在动态列表中使用
  2. 带key子节点

    • 复杂但高效的diff算法
    • 优化的节点复用策略
    • 最小化DOM操作
  3. 性能优化

    • 多级别的优化策略
    • 智能的节点复用
    • 最优的DOM操作序列

这种分层的更新策略让Vue能够在不同场景下都能提供最优的更新性能。在实际开发中,我们应该:

  1. 总是为动态列表提供key
  2. 避免使用索引作为key
  3. 优先考虑稳定且唯一的值作为key

至此,我们已经分析完了Vue更新子节点的核心逻辑。在这个过程中,patch函数会被递归调用,直到完成整个组件树的更新。但这只是Vue更新系统的一部分,要完整理解Vue的更新机制,我们还需要探索:

  1. 响应式系统

    • ReactiveEffect的实现原理
    • 依赖收集和触发更新的机制
    • 计算属性和监听器的实现
  2. 调度系统

    • 更新队列的管理
    • 异步更新的实现
    • 性能优化策略

这些内容将在后续文章中详细分析。