Vue3 Diff 优化:最长递增子序列 LIS

前言

学完 Vue3 Diff 头尾双指针、乱序 Map 复用、倒序插入后,卡在最后一个核心难点:最长递增子序列 LIS

最大的疑惑永远是这两个:

  • 节点明明已经 patch 复用更新 完了,为什么还要移动 DOM?

  • LIS 到底优化了什么?不加 LIS 代码不也能跑吗?

本文结合 Vue3 真实 Diff 流程、完整案例、源码对照,彻底讲透 LIS,看完直接搞定面试高频考点。

一、先理清 Diff 两个核心误区

1. patch 只更新内容,不更新位置

乱序 Diff 中,patch(n1, n2, el) 只做三件事:

  • 复用旧 DOM 节点

  • 更新属性、文本、子节点

  • 不会改变 DOM 在页面中的排列顺序

也就是说:内容是新的,站位还是旧的

2. VNode 顺序 ≠ DOM 顺序

新 VNode 数组是 JS 对象,顺序是理想的新顺序;但真实 DOM 是物理渲染节点,会停留在旧顺序。

想要 DOM 和 VNode 顺序一致,必须手动移动 DOM。

二、没有 LIS 的 Diff 有什么问题?

我们之前手写的简易 Diff,逻辑是:

所有复用节点,全部统一移动一遍

哪怕有些节点相对顺序根本没变、完全不需要动 ,也会被强制执行 hostInsert,造成多余 DOM 操作,性能浪费。

举个真实案例

旧节点:[a, b, c, d, e]

新节点:[a, b, e, c, f, d]

头尾比对后,乱序区间:

旧乱序:[c, d, e]

新乱序:[e, c, f, d]

最终生成复用标记数组:

newIndexToOldMap = [4, 3, 0, 5]

剔除新增节点 0,得到新列表对应的旧下标序列[4, 3, 5]

简易版 Diff(无 LIS)

e、c、d 三个复用节点 全部移动,三次 DOM 操作。

Vue3 完整版 Diff(有 LIS)

找出无需移动的节点,只移动必须动的节点,仅一次 DOM 操作。

三、LIS 核心原理

最长递增子序列 = 找出乱序中「相对顺序完全正确、无需移动」的最大节点集合

递增 = 旧下标越来越大 = 节点相对位置没变 = 无需移动

案例推演

序列:[4, 3, 5]

最长递增子序列为:[3, 5]

对应节点:c、d

这两个节点在新旧列表中相对顺序一致,原地不动

只有 e 节点顺序错乱,仅移动 e 即可

优化结果:3次DOM移动 → 1次DOM移动

四、LIS 在 Diff 中的完整执行流程

结合 Vue3 源码,LIS 优化插在「标记数组生成后、倒序移动前」。

步骤1:生成旧下标映射序列

根据 newIndexToOldMap,剔除 0(新增节点),得到纯复用节点的旧下标序列。

步骤2:贪心+二分求 LIS

Vue3 采用 O(nlogn) 算法,快速算出最长递增子序列,得到无需移动的节点下标

步骤3:倒序遍历,跳过稳定节点

这是 Vue3 源码原版最长递增子序列函数,采用贪心+二分,性能最优:

倒序遍历新节点:

  • 在 LIS 中:跳过,不移动

  • 不在 LIS 中:执行 hostInsert 移动位置

  • 标记为 0:新增节点,直接挂载

五、Vue3 源码 LIS 算法实现

TypeScript 复制代码
// Vue3 源码原版:贪心+二分 求最长递增子序列下标(O(nlogn))
// 作用:接收新节点对应的旧下标序列,返回【无需移动的节点下标集合】
// 核心:只保留相对顺序不变的稳定节点,最大程度减少 DOM 移动
function getSequence(arr) {
  // p 数组:记录每个节点的【前驱节点下标】,用于最后回溯还原完整 LIS 路径
  const p = arr.slice()
  // result 数组:贪心过程存储的 LIS 下标,初始默认第一个元素为起始节点
  const result = [0]
  // 声明循环变量
  let i, j, u, v, c
  const len = arr.length

  // 遍历整个下标序列,贪心构建最长递增子序列
  for (i = 0; i < len; i++) {
    // 当前遍历的节点对应的旧数组下标
    const arrI = arr[i]
    // 过滤 0:0 代表新增节点,无需参与 LIS 计算(无旧 DOM 可复用)
    if (arrI !== 0) {
      // 获取当前 LIS 末尾节点的下标
      j = result[result.length - 1]

      // 情况1:当前值大于 LIS 末尾值,满足递增
      // 直接追加到 LIS 末尾,更新前驱节点
      if (arrI > arr[j]) {
        p[i] = j
        result.push(i)
        continue
      }

      // 情况2:当前值不大于末尾值,二分查找替换位置(贪心优化,让序列更"小",后续更容易更长)
      u = 0
      v = result.length
      // 二分查找:找到第一个大于当前值的位置
      while (u < v) {
        // 位运算等价于 Math.floor((u + v) / 2)
        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
      }
    }
  }

  // 回溯 p 前驱数组,还原出【完整、真实的最长递增子序列下标】
  // 前面贪心得到的 result 只是长度最优,路径不完整,需要回溯修正
  u = result.length
  v = result[u - 1]
  while (u-- > 0) {
    result[u] = v
    v = p[v]
  }

  // 返回结果:所有无需移动的节点【相对下标】集合
  return result
}

返回值:无需移动的节点下标集合

六、加入 LIS 后的完整 Diff 移动逻辑

TypeScript 复制代码
// 得到无需移动的节点下标
const lis = getSequence(newIndexToOldMap)
// 存储是否是稳定节点(无需移动)
const isStable = new Set(lis)

// 倒序遍历新节点:新增 / 按需移动
for (let i = toBePatched - 1; i >= 0; i--) {
  const newIndex = s2 + i;
  const n2 = c2[newIndex];
  const anchor = c2[newIndex + 1]?.el || null;

  if (newIndexToOldMap[i] === 0) {
    // 全新节点:挂载
    patch(null, n2, el, anchor);
  } else {
    // LIS 优化:稳定节点直接跳过,不移动
    if(!isStable.has(i)){
      hostInsert(n2.el, el, anchor);
    }
  }
}
相关推荐
颜酱1 小时前
07 | 把字段与指标同步到 Qdrant(生成阶段)
前端·人工智能·后端
用户059540174462 小时前
把AI长期记忆测试从手动验证换成pytest,2天揪出11个隐藏Bug
前端·css
Larcher2 小时前
从“加载模型”界面到端侧推理:拆解一个 React + WebGPU 大模型 Demo
javascript·后端
Larcher2 小时前
从状态快照到惰性初始化:读懂 React useState 的三个关键场景
javascript·人工智能·后端
lichenyang4533 小时前
从 Vite 空项目到 AIGC 图片工作台:我如何打通生图、任务轮询、生成库与 Canvas 动态特效
前端·人工智能
泡沫冰@3 小时前
上章节中文件的讲解
前端·网络·nginx
进击的丸子3 小时前
APP人脸识别增值版Harmony Demo实操与关键代码解析
前端·程序员·harmonyos
a1117764 小时前
唯美花朵风格的黑胶唱片音乐播放器
前端·css·css3
Hilaku4 小时前
工作 5 年后,决定你薪资上限的究竟是什么?
前端·javascript·程序员