2026.01.11

Renderer 更新 -> 双端 Diff

在上一章中,我们实现了 patchChildren 的大部分逻辑,但当新旧节点的子节点都是数组时,我们留下了一个 TODO。这种场景需要使用 diff 算法来高效地对比和更新子节点。本章将实现双端 diff 算法,这是 Vue 渲染器中最核心的优化之一。

在上一章中,我们实现了 patchChildren 的大部分逻辑,但当新旧节点的子节点都是数组时,我们留下了一个 TODO。这种场景需要使用 diff 算法来高效地对比和更新子节点。本章将实现双端 diff 算法,这是 Vue 渲染器中最核心的优化之一。

数组子节点的挑战

为什么需要 diff 算法

当新旧子节点都是数组时,我们不能简单地卸载所有旧节点再挂载所有新节点。这样虽然功能正确,但会导致大量不必要的 DOM 操作。diff 算法的目标就是找出新旧子节点之间的差异,并只对变化的部分进行最少的 DOM 操作。

function patchChildren(n1, n2) {
  // ... 前面的逻辑

  // 新旧子节点都是数组,使用 diff 算法
  if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    // 老的是数组
    if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      // 新的也是数组,进入全量 diff
      patchKeyedChildren(n1.children, n2.children, el)
    }
    else {
      // 新的不是数组,卸载老的数组
      unmountChildren(n1.children)
    }
  }

  // ...
}

双端 Diff 算法

核心思想

双端 diff 算法的核心思想是从两端同时向中间遍历,优先处理头部和尾部的相同节点。这种策略可以快速处理常见的场景,如在列表头部或尾部添加/删除元素。

实现步骤

function patchKeyedChildren(c1, c2, container) {
  /**
   * 双端 diff 算法流程:
   * 1. 从头部开始对比,找出相同的前缀节点
   * 2. 从尾部开始对比,找出相同的后缀节点
   * 3. 根据对比结果处理剩余节点
   */

  let i = 0 // 当前对比的起始索引
  const e1 = c1.length - 1 // 旧子节点数组的最后一个元素索引
  const e2 = c2.length - 1 // 新子节点数组的最后一个元素索引

  // 1. 从头部开始对比(sync from start)
  /*
   * 场景示例:在尾部新增节点
   * 旧节点 (c1): [a, b]
   * 新节点 (c2): [a, b, c]
   *
   * 对比过程:
   * - 初始状态: i = 0, e1 = 1, e2 = 2
   * - 第一轮: a === a,patch(a, a),i++ -> i = 1
   * - 第二轮: b === b,patch(b, b),i++ -> i = 2
   * - 第三轮: i > e1,退出循环
   * - 结果: i = 2, e1 = 1, e2 = 2
   */
  while (i <= e1 && i <= e2) {
    const n1 = c1[i]
    const n2 = c2[i]

    // 判断节点类型是否相同
    if (isSameVNodeType(n1, n2)) {
      // 类型相同,进行对比更新
      patch(n1, n2, container)
    }
    else {
      // 类型不同,停止头部对比
      break
    }

    i++
  }

  // 2. 从尾部开始对比(sync from end)
  /*
   * 场景示例:在头部新增节点
   * 旧节点 (c1): [a, b]
   * 新节点 (c2): [c, a, b]
   *
   * 对比过程:
   * - 头部对比后: i = 0, e1 = 1, e2 = 2(头部不同,立即退出)
   * - 第一轮: b === b,patch(b, b),e1--, e2-- -> e1 = 0, e2 = 1
   * - 第二轮: a === a,patch(a, a),e1--, e2-- -> e1 = -1, e2 = 0
   * - 第三轮: e1 < i,退出循环
   * - 结果: i = 0, e1 = -1, e2 = 0
   */
  while (i <= e1 && i <= e2) {
    const n1 = c1[e1]
    const n2 = c2[e2]

    if (isSameVNodeType(n1, n2)) {
      // 类型相同,进行对比更新
      patch(n1, n2, container)
    }
    else {
      // 类型不同,停止尾部对比
      break
    }

    e1--
    e2--
  }

  // 3. 处理对比后的剩余节点
  /*
   * 根据 i、e1、e2 的关系,判断需要进行的操作:
   * - i > e1 && i <= e2: 旧节点已全部对比完,新节点还有剩余 -> 需要挂载新节点
   * - i > e2 && i <= e1: 新节点已全部对比完,旧节点还有剩余 -> 需要卸载旧节点
   */

  // 3.1 需要挂载新节点
  if (i > e1) {
    // 确定锚点位置:新增节点应该插入到哪个位置
    // 如果 e2 + 1 超出数组长度,说明是在末尾追加,anchor 为 null
    // 否则,新节点应该插入到 c2[e2 + 1] 之前
    const nextPos = e2 + 1
    const anchor = nextPos < c2.length ? c2[nextPos].el : null

    // 挂载 i 到 e2 范围内的所有新节点
    while (i <= e2) {
      patch(null, c2[i], container, anchor)
      i++
    }
  }
  // 3.2 需要卸载旧节点
  else if (i > e2) {
    // 卸载 i 到 e1 范围内的所有旧节点
    while (i <= e1) {
      unmount(c1[i])
      i++
    }
  }
}

算法优化的场景

双端 diff 算法特别适合处理以下常见场景:

  1. 尾部追加[a, b][a, b, c]
    • 头部对比后,i = 2, e1 = 1, e2 = 2
    • 满足 i > e1,直接挂载新节点 c
  2. 头部插入[a, b][c, a, b]
    • 尾部对比后,i = 0, e1 = -1, e2 = 0
    • 满足 i > e1,在头部挂载新节点 c
  3. 尾部删除[a, b, c][a, b]
    • 头部对比后,i = 2, e1 = 2, e2 = 1
    • 满足 i > e2,卸载节点 c
  4. 头部删除[a, b, c][b, c]
    • 尾部对比后,i = 0, e1 = 0, e2 = -1
    • 满足 i > e2,卸载节点 a

关键点解析

1. 为什么从两端对比

从两端同时对比可以快速"消化掉"头部和尾部的相同节点,减少需要处理的复杂情况。大多数实际场景中,变化往往集中在列表的某一端,双端对比能够高效处理这些场景。

2. 锚点(anchor)的作用

在挂载新节点时,需要确定插入位置。anchor 参数指定了新节点应该插入到哪个现有节点之前:

  • 如果 anchornull,表示在末尾追加
  • 如果 anchor 是某个 DOM 元素,表示插入到该元素之前

3. 索引关系的判断

通过 ie1e2 三个索引的关系,可以准确判断对比后的状态:

  • i > e1:旧节点已全部对比完,新节点还有剩余
  • i > e2:新节点已全部对比完,旧节点还有剩余
  • i <= e1 && i <= e2:两端都还有未对比的节点(本章暂未处理,需要更复杂的 diff 算法)

总结

通过本章的学习,我们实现了双端 diff 算法的基础版本,了解了:

  1. diff 算法的必要性
    • 新旧子节点都是数组时,需要高效的对比策略
    • 避免简单粗暴地卸载重建,减少 DOM 操作
    • 找出最小的变化集合,提升渲染性能
  2. 双端对比策略
    • 从头部开始对比,处理相同的前缀节点
    • 从尾部开始对比,处理相同的后缀节点
    • 通过两端对比,快速定位变化区域
  3. 处理对比结果
    • i > e1:挂载新增的节点
    • i > e2:卸载多余的节点
    • 使用 anchor 确定插入位置,保证节点顺序正确
  4. 优化的场景
    • 头部/尾部追加或删除节点
    • 这些是实际应用中最常见的列表变化场景
    • 双端 diff 能以 O(n) 的时间复杂度处理这些情况
  5. 算法的局限性
    • 当前实现只能处理头尾变化的简单场景
    • 中间节点的移动、替换等复杂情况还未处理

双端 diff 算法是 Vue 渲染器优化的重要一环,虽然还有更复杂的场景需要处理,但它已经能够高效应对大部分常见的列表更新操作了。