Vue3 - 快速 Diff 算法理解

Vue 版本:以 vue@3.x 代码为参考,主要梳理 Diff 算法的核心流程。

Vue 3 的 Diff 算法借鉴了纯文本 diff 算法的思想,参考了 viv 和 inferno 框架的实现,只对需要处理的节点本身进行 Diff 操作。通过预处理和最长递增子序列(LIS)算法,Vue 3 能够显著减少不必要的 DOM 操作,提升渲染性能。

核心策略:先头后尾找公共,新增删除看剩余,中间乱序靠 key 映射 + 最长递增子序列

Diff 算法流程

1. 同步头部节点(Sync from Start)

从前往后比较新旧节点列表,跳过相同的节点,直到遇到第一个不同的节点。

  • 比较 c1[i]c2[i],如果相同则继续后移 i
  • 遇到不同节点时停止,此时 i 即为需要处理的起始位置

示例

旧列表:(a b) c
新列表:(a b) d e
结果:i = 2,跳过前两个相同节点

2. 同步尾部节点(Sync from End)

从后往前比较新旧节点列表,跳过相同的节点,直到遇到第一个不同的节点。

  • 比较 c1[e1]c2[e2],如果相同则继续前移 e1e2
  • 遇到不同节点时停止,此时 e1e2 即为需要处理的结束位置

示例

旧列表:a (b c)
新列表:d e (b c)
结果:e1 = 0, e2 = 1,跳过后两个相同节点

3. 处理新增节点(Common Sequence + Mount)

如果 i > e1 && i <= e2,说明新列表中有新增节点,需要挂载这些节点。

示例

旧列表:(a b)
新列表:c (a b)
情况:i = 0, e1 = -1, e2 = 0
操作:在位置 0 处新增节点 c

4. 处理删除节点(Common Sequence + Unmount)

如果 i > e2 && i <= e1,说明旧列表中有多余节点,需要卸载这些节点。

示例

旧列表:a (b c)
新列表:(b c)
情况:i = 0, e1 = 0, e2 = -1
操作:删除节点 a

5. 处理乱序节点(Unknown Sequence)

如果上述情况都不满足,说明中间部分存在乱序,需要执行移动和更新操作。

处理流程

  1. 建立 key 到索引的映射:遍历新节点列表,建立 key -> newIndex 的映射表,方便快速查找旧节点在新列表中的位置。

  2. 遍历旧节点,建立新索引到旧索引的映射

    • 查找每个旧节点在新节点列表中的位置。
    • 建立 newIndexToOldIndexMap 数组,记录新节点索引对应的旧节点索引(偏移 +1,0 表示新增节点)。
    • 通过比较 newIndexmaxNewIndexSoFar 判断节点是否需要移动。
  3. 计算最长递增子序列:如果检测到节点移动,使用 LIS 算法找出不需要移动的节点序列。

  4. 执行移动和挂载操作

    • 从后往前遍历新节点列表。
    • 如果节点不在 LIS 中,执行移动操作。
    • 如果节点是新增的(newIndexToOldIndexMap[i] === 0),执行挂载操作。

示例

旧列表:[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
操作:处理中间乱序部分 [c d e] -> [e d c h]

最长递增子序列(LIS)算法

300. 最长递增子序列 - 力扣(LeetCode)

最长递增子序列算法是 Vue 3 Diff 算法的核心优化,找出不需要移动的节点序列,实现最小化 DOM 操作。

算法原理

核心思想:贪心算法 + 二分查找 + 回溯列表

算法创建一个数组 result 用于记录递增序列的索引下标,通过贪心策略和二分查找找到局部最优解,然后通过回溯指针数组还原出真正的递增子序列。

算法步骤详解

示例数组[1, 30, 100, 200, 300, 50, 60]

第一步:贪心查找递增序列
  1. 预设初始索引 result = [0](对应值 1)

  2. 贪心策略

    • 比较 30 > 1,符合递增,result[1] = 1(对应值 30)
    • 比较 100 > 30,符合递增,result[2] = 2(对应值 100)
    • 依次类推,直到 300,此时 result = [0, 1, 2, 3, 4]
  3. 遇到不满足递增的元素

    • 比较 50 < 300,不满足递增
    • 通过二分查找(在有序递增数组 result 中),找到首个比 50 大的数的索引位置
    • 这里找到的是索引 2(对应值 100)
    • result[2] 替换为 50 的索引 5,即 [0, 1, 2, 3, 4] 变为 [0, 1, 5, 3, 4]
    • 同样处理 60,最终得到 [0, 1, 5, 6, 4]

问题:此时的结果并不满足原有数组的递增子序列,索引值并非递增(局部最优解导致全局最优解出现偏差)。50 和 60 跑到了 300 前面,违反了序列的原则。

第二步:回溯修正

解决方案:给每个当前队列中的索引添加指向前置节点的指针,即代码里的 p 数组。在完成遍历后,使用指针从尾部开始回溯,还原出真正的递增子序列。

  • 前置指针和当前 arr 存储的索引位置对应,代表当前 arr 索引的值指向的前置索引
  • 在遍历过程中,p 前置索引列表中,数值 50 和 100 的前置指针都指向 30

回溯过程

  1. 从后往前遍历 result 进行回溯
  2. 首先是索引 4(对应值 300),其 p 的前置指针为索引 3(对应值 200),所以修正为 3
  3. 索引 3(对应值 200)的前置为索引 2(对应值 100),修正为 2,这里把 result 中的索引 5(对应值 50)顶替掉
  4. 继续下去,100 > 30 > 1,最终修正为 [0, 1, 2, 3, 4],而非局部贪心得到的 [0, 1, 5, 6, 4]

最终结果[0, 1, 2, 3, 4] 对应值 [1, 30, 100, 200, 300],而非 [1, 30, 50, 60, 300],符合递增子序列的原则。

代码实现

// https://en.wikipedia.org/wiki/Longest_increasing_subsequence
function getSequence(arr: number[]): number[] {
  // 从原有数组拷贝出支持回溯的指针数组
  const p = arr.slice();
  // 定义数组索引存储结果
  const result = [0];

  let i, j, u, v, c;
  const len = arr.length;

  // 1. 遍历,第一次递增子序列查找
  for (i = 0; i < len; i++) {
    // 获取当前数组项
    const arrI = arr[i];
    // arrI 为 0 表示新增节点,无需参与后续步骤
    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;
        // 假如当前c处索引值的数组项 < 当前数组项,往后找:因为要找到最近最小于的项才行
        if (arr[result[c]] < arrI) {
          u = c + 1;
        } else {
          // 假如当前c处索引值的数组项 > 当前数组项,往前找
          v = c;
        }
      }
      // 假如找到的u处索引数组项 > 当前项且 u > 0,即有完成二分查找过程
      if (arrI < arr[result[u]]) {
        if (u > 0) {
          // 定义当前索引的前置指针指向前一个值
          p[i] = result[u - 1];
        }
        // 在当前索引处插入索引项,完成递增子序列处理
        result[u] = i;
      }
    }
  }
  // 2. 第二次遍历,根据指针列表回溯结果
  u = result.length;
  v = result[u - 1];
  while (u-- > 0) {
    // 从头往尾部,将序列还原为正常的递增子序列结果
    result[u] = v;
    v = p[v];
  }
  // 返回结果
  return result;
}

Diff 算法主要实现

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
  // 获取结束索引
  // e1 是 oldVNodes,e2 是 newVNode
  let e1 = c1.length - 1 // prev ending index
  let e2 = l2 - 1 // next ending index

  // 1. sync from start
  // (a b) c
  // (a b) d e
  // 首次循环,从前往后比较,直到出现节点不一致
  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. sync from end
  // a (b c)
  // d e (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. common sequence + mount
  // (a b)
  // c (a b)
  // i = 0, e1 = -1, e2 = 0
  // 假如 i > oldEnd && i <= newEnd,证明有节点需要新增
  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. common sequence + unmount
  // a (b c)
  // (b c)
  // i = 0, e1 = 0, e2 = -1
  // 假如 i > newEnd && i <= oldEnd,证明有节点需要删除
  else if (i > e2) {
    while (i <= e1) {
      unmount(c1[i], parentComponent, parentSuspense, true)
      i++
    }
  }

  // 5. unknown sequence
  // [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
  else {
    // 定义新的开始坐标
    const s1 = i // prev starting index
    const s2 = i // next starting index newStart

    // 5.1 build key:index map for newChildren
    //  建立在新节点中的列表索引,后续遍历旧节点时,通过 key 快速查找旧节点在新节点中的位置
    const keyToNewIndexMap: Map<PropertyKey, 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 loop through old children left to be patched and try to patch
    // matching nodes & remove nodes that are no longer present
    // 遍历旧节点列表部分,进行复用和删除
    let j
    let patched = 0
    // 获取需要 patch 的节点数量
    const toBePatched = e2 - s2 + 1
    let moved = false
    // used to track whether any node has moved
    let maxNewIndexSoFar = 0
    // works as Map<newIndex, oldIndex>
    // Note that oldIndex is offset by +1
    // and oldIndex = 0 is a special value indicating the new node has
    // no corresponding old node.
    // used for determining longest stable subsequence
    // 初始化新索引到旧索引的映射数组
    const newIndexToOldIndexMap = new Array(toBePatched)
    for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0

    for (i = s1; i <= e1; i++) {
      const prevChild = c1[i]
      // 已经超过 patch 范围,说明节点可以被移除,证明新节点处理完,旧的卸载
      if (patched >= toBePatched) {
        // all new children have been patched so this can only be a removal
        unmount(prevChild, parentComponent, parentSuspense, true)
        continue
      }
      let newIndex
      // 查找旧节点在新节点中的索引位置
      if (prevChild.key != null) {
        newIndex = keyToNewIndexMap.get(prevChild.key)
      } else {
        // 若没有key,尝试定位相同且无key的节点索引
        // key-less node, try to locate a key-less node of the same type
        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 {
        // 记录新列表LIS部分的相对索引(偏移+1,0表示新增节点)
        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 move and mount
    // generate longest stable subsequence only when nodes have moved
    const increasingNewIndexSequence = moved
      // 递增子序列处理位置
      ? getSequence(newIndexToOldIndexMap)
      : EMPTY_ARR
    // 从列表末尾开始遍历
    // 倒序遍历可以利用「下一个节点的 el 作为锚点」
    // 避免移动节点后影响后续节点的锚点位置,保证 DOM 操作的正确性。
    j = increasingNewIndexSequence.length - 1
    // looping backwards so that we can use last patched node as anchor
    for (i = toBePatched - 1; i >= 0; i--) {
      const nextIndex = s2 + i
      const nextChild = c2[nextIndex] as VNode
      const anchorVNode = c2[nextIndex + 1] as VNode
      const anchor =
        nextIndex + 1 < l2
          ? // #13559, fallback to el placeholder for unresolved async component
            anchorVNode.el || anchorVNode.placeholder
          : parentAnchor
      // 为 0 代表新增节点
      if (newIndexToOldIndexMap[i] === 0) {
        // mount new
        patch(
          null,
          nextChild,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          namespace,
          slotScopeIds,
          optimized,
        )
      } else if (moved) {
        // move if:
        // There is no stable subsequence (e.g. a reverse)
        // OR current node is not among the stable sequence
        // 不在最长递增子序列中,说明节点需要移动
        if (j < 0 || i !== increasingNewIndexSequence[j]) {
          move(nextChild, container, anchor, MoveType.REORDER)
        } else {
          // 在 LIS 中,无需移动
          j--
        }
      }
    }
  }
}

快速 Diff 算法优势

  • 预处理优化:先处理头部和尾部的相同节点,然后快速缩小需要 diff 的范围,减少不必要的比较操作。

  • 最长递增子序列优化:通过 LIS 算法找出不需要移动的节点,完成后只移动真正需要移动的节点,最小化 DOM 操作次数。

  • Key 映射优化:建立 key 到索引的映射表,实现 O(1) 时间复杂度的节点查找,避免嵌套循环查找带来的 O(n²) 复杂度。

  • 倒序遍历优化:在处理节点移动时,从后往前遍历可以利用下一个节点的 DOM 元素作为锚点,避免移动节点后影响后续节点的锚点位置,保证 DOM 操作的正确性。

思考

1. Vue 3 的快速 Diff 算法相比 Vue 2 双端 Diff 有哪些改进?

Vue 3 引入了最长递增子序列算法,能够更精确地识别不需要移动的节点,在复杂列表场景下能够减少更多的 DOM 操作。虽然 LIS 是 O(n log n) 复杂度,但是整体压缩了要移动的 DOM 节点个数,解决了 Vue 2 无法确定最小化移动次数的问题。且在编译阶段下借助 patchFlag 优化单个节点的更新粒度,标记节点动态部分,运行时只更新标记部分,在编译阶段实现类似剪枝的操作,后续运行时避免不必要的 Diff 和全量属性对比。

2. 为什么需要回溯指针数组?

贪心算法在寻找递增子序列时,可能会用较小的值替换较大的值,导致结果不满足真正的递增子序列,通过回溯指针数组,可以从尾部开始还原出真正的递增子序列,保证算法过程准确。

3. 为什么倒序遍历新节点列表?

倒序遍历可以利用下一个节点的 DOM 元素作为锚点,避免移动节点后影响后续节点的锚点位置,如果是正序遍历,移动节点后可能会改变后续节点的锚点,导致 DOM 操作错误。

总结

  • Diff 算法流程:Vue 3 的 Diff 算法分为五个步骤:同步头部节点、同步尾部节点、处理新增节点、处理删除节点、处理乱序节点。通过预处理快速缩小范围,然后通过 key 映射和 LIS 算法处理复杂的节点移动场景。
  • 最长递增子序列算法:使用贪心算法 + 二分查找 + 回溯列表的方式,找出不需要移动的节点序列。通过贪心策略和二分查找找到局部最优解,然后通过回溯指针数组还原出真正的递增子序列,最小化 DOM 操作次数。
  • 关键优化点:预处理优化快速缩小范围;LIS 算法精确识别不需要移动的节点;key 映射实现 O(1) 查找;倒序遍历保证 DOM 操作的正确性。

参考内容

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值