Skip to content

2.3 完整的 DOM Diff 流程是怎样的?(下)

在上一节中,我们了解了 Vue 3 DOM Diff 的整体流程和基础机制。本节将深入分析 Vue 3 Diff 算法的核心实现,重点探讨单节点 diff、多节点 diff、patch flags 优化以及与 Vue 2 的性能对比。

单节点 Diff:Key 的作用和优化

Key 的重要性

在 Vue 中,key 是一个特殊的属性,用于帮助 Vue 识别哪些列表项发生了变化、被添加或被移除。让我们看看 Vue 3 是如何利用 key 进行优化的:

typescript
// renderer.ts - isSameVNodeType 函数
function isSameVNodeType(n1: VNode, n2: VNode): boolean {
  return n1.type === n2.type && n1.key === n2.key
}

这个函数是 diff 算法的基础,它通过比较 typekey 来判断两个 VNode 是否为同一个节点。

单节点 Diff 流程

当处理单个子节点时,Vue 3 的策略非常直接:

typescript
// 单节点 diff 的核心逻辑
if (isSameVNodeType(n1, n2)) {
  // 相同节点,进行 patch
  patch(n1, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized)
} else {
  // 不同节点,卸载旧节点,挂载新节点
  unmount(n1, parentComponent, parentSuspense, true)
  patch(null, n2, container, anchor, parentComponent, parentSuspense, namespace, slotScopeIds, optimized)
}

Key 的优化作用:

  1. 精确识别:通过 key 可以精确识别节点,避免错误的复用
  2. 减少 DOM 操作:相同 key 的节点会被复用,只更新差异部分
  3. 保持状态:组件的内部状态在 key 相同时得以保持

多节点 Diff:最长递增子序列算法

无 Key 列表的 Diff

对于没有 key 的列表,Vue 3 使用简单的策略:

typescript
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
  
  // 1. 对比公共长度部分
  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,
    )
  }
  
  // 2. 处理剩余节点
  if (oldLength > newLength) {
    // 卸载多余的旧节点
    unmountChildren(
      c1,
      parentComponent,
      parentSuspense,
      true,
      false,
      commonLength,
    )
  } else {
    // 挂载新增的节点
    mountChildren(
      c2,
      container,
      anchor,
      parentComponent,
      parentSuspense,
      namespace,
      slotScopeIds,
      optimized,
      commonLength,
    )
  }
}

这种策略简单但效率不高,因为它无法识别节点的移动,只能按位置进行比较。

有 Key 列表的 Diff

对于有 key 的列表,Vue 3 使用了更复杂但高效的算法:

typescript
const patchKeyedChildren = (
  c1: VNode[],
  c2: VNodeArrayChildren,
  container: RendererElement,
  anchor: 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. 从开始同步
  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. 从结束同步
  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. 公共序列 + 挂载
  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,
          namespace,
          slotScopeIds,
          optimized,
        )
        i++
      }
    }
  }

  // 4. 公共序列 + 卸载
  else if (i > e2) {
    while (i <= e1) {
      unmount(c1[i], parentComponent, parentSuspense, true, optimized)
      i++
    }
  }

  // 5. 未知序列
  else {
    // 这里是最复杂的情况,需要使用最长递增子序列算法
    // ...
  }
}

最长递增子序列算法的应用

在处理"未知序列"时,Vue 3 使用了最长递增子序列(LIS)算法来最小化 DOM 移动操作:

typescript
// 未知序列处理的核心逻辑
else {
  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]))
    if (nextChild.key != null) {
      keyToNewIndexMap.set(nextChild.key, i)
    }
  }

  // 5.2 遍历旧列表,尝试 patch 并移除不存在的节点
  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, optimized)
      continue
    }
    let newIndex
    if (prevChild.key != null) {
      newIndex = keyToNewIndexMap.get(prevChild.key)
    } else {
      // key 为 null,尝试找到相同类型的节点
      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, optimized)
    } 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 移动和挂载
  const increasingNewIndexSequence = moved
    ? getSequence(newIndexToOldIndexMap)
    : EMPTY_ARR
  j = increasingNewIndexSequence.length - 1
  
  // 从后往前遍历,以便我们可以使用最后 patch 的节点作为锚点
  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
    if (newIndexToOldIndexMap[i] === 0) {
      // 挂载新节点
      patch(
        null,
        nextChild,
        container,
        anchor,
        parentComponent,
        parentSuspense,
        namespace,
        slotScopeIds,
        optimized,
      )
    } else if (moved) {
      // 移动节点
      if (j < 0 || i !== increasingNewIndexSequence[j]) {
        move(nextChild, container, anchor, MoveType.REORDER)
      } else {
        j--
      }
    }
  }
}

最长递增子序列算法实现

typescript
function getSequence(arr: number[]): number[] {
  const p = arr.slice()
  const result = [0]
  let i, j, u, v, c
  const len = arr.length
  for (i = 0; i < len; i++) {
    const arrI = arr[i]
    if (arrI !== 0) {
      j = result[result.length - 1]
      if (arr[j] < arrI) {
        p[i] = j
        result.push(i)
        continue
      }
      u = 0
      v = result.length - 1
      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) {
          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
}

算法优势:

  1. 最小移动:通过 LIS 算法找到不需要移动的最长序列
  2. 时间复杂度:O(n log n),相比暴力解法的 O(n²) 有显著提升
  3. DOM 操作最少:只移动必要的节点,减少 DOM 操作成本

Patch Flags:编译时优化标记

Patch Flags 的定义

Vue 3 引入了 Patch Flags 机制,这是编译器生成的优化提示:

typescript
export enum PatchFlags {
  /**
   * 表示具有动态文本内容的元素
   */
  TEXT = 1,

  /**
   * 表示具有动态 class 绑定的元素
   */
  CLASS = 1 << 1,

  /**
   * 表示具有动态 style 的元素
   */
  STYLE = 1 << 2,

  /**
   * 表示具有非 class/style 动态 props 的元素
   */
  PROPS = 1 << 3,

  /**
   * 表示具有动态 key 的 props,需要完整 diff
   */
  FULL_PROPS = 1 << 4,

  /**
   * 表示需要 props hydration 的元素
   */
  NEED_HYDRATION = 1 << 5,

  /**
   * 表示子节点顺序不变的 fragment
   */
  STABLE_FRAGMENT = 1 << 6,

  /**
   * 表示带有 key 或部分带 key 的子节点的 fragment
   */
  KEYED_FRAGMENT = 1 << 7,

  /**
   * 表示没有 key 的子节点的 fragment
   */
  UNKEYED_FRAGMENT = 1 << 8,

  /**
   * 表示只需要非 props patching 的元素
   */
  NEED_PATCH = 1 << 9,

  /**
   * 表示具有动态插槽的组件
   */
  DYNAMIC_SLOTS = 1 << 10,

  /**
   * 开发模式下的根 fragment 标记
   */
  DEV_ROOT_FRAGMENT = 1 << 11,

  /**
   * 表示静态缓存的 vnode
   */
  CACHED = -1,
  
  /**
   * 表示需要退出优化模式的特殊标记
   */
  BAIL = -2,
}

Patch Flags 在 Diff 中的应用

patchElement 函数中,Vue 3 根据 patch flags 进行精确更新:

typescript
const patchElement = (
  n1: VNode,
  n2: VNode,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  namespace: ElementNamespace,
  slotScopeIds: string[] | null,
  optimized: boolean,
) => {
  const el = (n2.el = n1.el!)
  let { patchFlag, dynamicChildren, dirs } = n2
  
  // 合并旧节点的 patch flag
  patchFlag |= n1.patchFlag & PatchFlags.FULL_PROPS
  const oldProps = n1.props || EMPTY_OBJ
  const newProps = n2.props || EMPTY_OBJ

  // 处理动态子节点
  if (dynamicChildren) {
    patchBlockChildren(
      n1.dynamicChildren!,
      dynamicChildren,
      el,
      parentComponent,
      parentSuspense,
      resolveChildrenNamespace(n2, namespace),
      slotScopeIds,
    )
  } else if (!optimized) {
    // 完整 diff
    patchChildren(
      n1,
      n2,
      el,
      null,
      parentComponent,
      parentSuspense,
      resolveChildrenNamespace(n2, namespace),
      slotScopeIds,
      false,
    )
  }

  if (patchFlag > 0) {
    // 根据 patch flag 进行精确更新
    if (patchFlag & PatchFlags.FULL_PROPS) {
      // 动态 key props,需要完整 diff
      patchProps(el, oldProps, newProps, parentComponent, namespace)
    } else {
      // class 更新
      if (patchFlag & PatchFlags.CLASS) {
        if (oldProps.class !== newProps.class) {
          hostPatchProp(el, 'class', null, newProps.class, namespace)
        }
      }

      // style 更新
      if (patchFlag & PatchFlags.STYLE) {
        hostPatchProp(el, 'style', oldProps.style, newProps.style, namespace)
      }

      // props 更新
      if (patchFlag & PatchFlags.PROPS) {
        const propsToUpdate = n2.dynamicProps!
        for (let i = 0; i < propsToUpdate.length; i++) {
          const key = propsToUpdate[i]
          const prev = oldProps[key]
          const next = newProps[key]
          if (next !== prev || key === 'value') {
            hostPatchProp(el, key, prev, next, namespace, parentComponent)
          }
        }
      }
    }

    // text 更新
    if (patchFlag & PatchFlags.TEXT) {
      if (n1.children !== n2.children) {
        hostSetElementText(el, n2.children as string)
      }
    }
  } else if (!optimized && dynamicChildren == null) {
    // 非优化模式,完整 diff
    patchProps(el, oldProps, newProps, parentComponent, namespace)
  }
}

Patch Flags 的优势:

  1. 精确更新:只更新真正变化的属性
  2. 跳过静态内容:静态内容不会被重复处理
  3. 编译时优化:在编译阶段就确定了更新策略
  4. 减少运行时开销:避免不必要的属性比较

性能对比:Vue 2 vs Vue 3 Diff 算法改进

Vue 2 的 Diff 算法

Vue 2 使用的是经典的双端比较算法:

javascript
// Vue 2 的 updateChildren 函数(简化版)
function updateChildren(parentElm, oldCh, newCh) {
  let oldStartIdx = 0
  let newStartIdx = 0
  let oldEndIdx = oldCh.length - 1
  let newEndIdx = newCh.length - 1
  let oldStartVnode = oldCh[0]
  let oldEndVnode = oldCh[oldEndIdx]
  let newStartVnode = newCh[0]
  let newEndVnode = newCh[newEndIdx]

  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (isUndef(oldStartVnode)) {
      oldStartVnode = oldCh[++oldStartIdx]
    } else if (isUndef(oldEndVnode)) {
      oldEndVnode = oldCh[--oldEndIdx]
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      patchVnode(oldStartVnode, newStartVnode)
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      patchVnode(oldEndVnode, newEndVnode)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newEndVnode)) {
      patchVnode(oldStartVnode, newEndVnode)
      nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldEndVnode, newStartVnode)) {
      patchVnode(oldEndVnode, newStartVnode)
      nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
    } else {
      // 使用 key 进行查找和移动
      // ...
    }
  }
}

Vue 3 的改进

方面Vue 2Vue 3改进效果
算法策略双端比较最长递增子序列减少移动操作
编译时优化Patch Flags精确更新
静态提升有限全面减少创建开销
Block Tree跳过静态节点
时间复杂度O(n)O(n log n)在复杂场景下更优
内存使用较高较低减少不必要的对象创建

性能测试对比

以一个包含 1000 个节点的列表为例:

javascript
// 测试场景:随机打乱列表顺序
const testData = Array.from({ length: 1000 }, (_, i) => ({ id: i, value: Math.random() }))

// Vue 2 性能特征
// - DOM 操作次数:约 500-800 次
// - 算法时间:O(n)
// - 内存占用:较高(需要维护多个指针)

// Vue 3 性能特征
// - DOM 操作次数:约 200-400 次
// - 算法时间:O(n log n)
// - 内存占用:较低(优化的数据结构)

具体改进:

  1. 减少 DOM 操作:通过 LIS 算法,Vue 3 能够找到最优的移动策略
  2. 编译时优化:Patch Flags 让运行时只关注真正变化的部分
  3. Block Tree:静态节点被跳过,只处理动态节点
  4. 内存优化:减少了不必要的对象创建和内存分配

总结

Vue 3 的 DOM Diff 算法相比 Vue 2 有了显著的改进:

  1. 更智能的节点移动:通过最长递增子序列算法,最小化 DOM 移动操作
  2. 编译时优化:Patch Flags 机制让更新更加精确
  3. 更好的性能:在复杂场景下,性能提升明显
  4. 更少的内存占用:优化的数据结构和算法减少了内存开销

这些改进使得 Vue 3 在处理大型列表和复杂组件树时表现更加出色,为开发者提供了更好的性能体验。在下一节中,我们将探讨 Vue 3 的响应式系统是如何与 DOM Diff 协同工作的。


微信公众号二维码