Vue3 提升 Diff 算法性能的关键是什么?

Vue3 的快速 Diff 算法中使用了最长递增子序列 来提升 Diff 的效率,因为最长递增子序列 可以最大程度减少 DOM 移动的次数,这也是 Vue3 的快速 Diff 算法比 Vue2 的双端 Diff 算法效率高的原因。因此掌握如何求取最长递增子序列也相当于掌握了快速 Diff 算法的精髓。

求取最长递增子序列是一个经典的算法题,有三种方法可以完成,分别是暴力法、动态规划、贪心 + 二分。

暴力法

暴力法的核心思想是:找到所有的递增子序列,然后从中找到长度最长的那一个。

代码实现:

js 复制代码
function getSequence(arr) {
  let maxLength = 0;
  let longestSeq = [];

  // 递归函数,找到以当前位置结尾的最长递增子序列
  function findSubsequence(index, subSeq) {
    let currentNum = arr[index];
    let newSeq = [...subSeq, currentNum];  // 将当前数字加入当前子序列

    // 遍历当前数字之后的所有数字
    for (let i = index + 1; i < arr.length; i++) {
      // 如果之后的数字大于当前数字,则调用递归函数,寻找更长的递增子序列
      if (arr[i] > currentNum) {
        findSubsequence(i, newSeq);
      }
    }

    // 更新最长递增子序列和长度
    if (newSeq.length > maxLength) {
      maxLength = newSeq.length;
      longestSeq = newSeq;
    }
  }

  // 对每个位置调用递归函数
  for (let i = 0; i < arr.length; i++) {
    findSubsequence(i, []);
  }

  return longestSeq;
}

const list = [3, 7, 22, 4, 8, 13, 9, 11, 12]
getSequence(list) // [3, 7, 8, 9, 11, 12]

上面的代码,通过循环+递归,找到数组中所有的递增子序列,然后在循环+递归的过程中找到最长的那一个就是最长递增子序列。

暴力法虽然效率低下,但是我们不能不会。

动态规划

dp[i] 表示以 nums[i] 这个数结尾的最长递增子序列。

设 dp[j] 为 [0, i - 1] 中的最长递增子序列,则 dp[i] 为当 num[i] 大于 num[j] 时,最长的 dp[j] 序列加上 num[i] 元素。即[...dp[j], num[i]]

代码实现:

js 复制代码
function getSequence(arr) {
  // 初始化最大序列长度为0
  let maxLength = 0;
  // 初始化最大序列为空数组
  let maxSeq = [];
  // 创建一个与输入数组相同长度的数组,每个位置都初始化为一个空数组
  let sequences = new Array(arr.length).fill().map(() => []);

  // 遍历输入数组
  for (let i = 0; i < arr.length; i++) {
    // 创建一个以当前元素为起始的新序列
    let seq = [arr[i]];
    // 遍历之前的元素,查找比当前元素小的元素并构建新的序列
    for (let j = 0; j < i; j++) {
      if (arr[j] < arr[i]) {
        // 使用之前存储的序列拼接当前元素
        seq = sequences[j].concat(arr[i]);
      }
    }
    // 将得到的序列存储起来
    sequences[i] = seq;
    // 更新最大序列
    if (seq.length > maxLength) {
      maxLength = seq.length;
      maxSeq = seq;
    }
  }
  // 返回最大序列
  return maxSeq;
}

const list = [3, 7, 22, 4, 8, 13, 9, 11, 12]
getSequence(list) // [3, 4, 8, 9, 11, 12]

注意,一个数组的最长递增子序列不一定是唯一的,所以,虽然用动态规划得到的结果与暴力法得到的结果不一样,但是也是正确的。

上述代码可能有点抽象,具体图解如下:

贪心 + 二分

贪心 + 二分为 Vue3 采用的方法,Vue3 中获取最长递增子序列的源码如下:

ts 复制代码
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
}

👆上面的代码摘自 Vue.js 3.2.45 版本

不过与上面的实现不同的是,这里返回的是最长递增子序列的索引。不过没关系,最后遍历一遍索引数组,也是可以获得最终的最长递增子序列的。

我们要求取一个数组的最长递增子序列,则要想办法让这个子序列尽量增长缓慢(贪心算法的思想)。当我们遍历数组的时候,发现当前值比前面的值大,则直接将当前值 push 进序列中,这是没错的,如果当前值比前一个值小,则在子序列中使用二分法找到比当前值大的最小的那个数,然后使用当前值替换该值。由下图所示,经过二分法的操作,序列 [6, 8, 14] 比 [6, 8, 15] 增长得更缓慢。

但是,可以发现二分法中的替换操作,改变了子序列的性质,因为在原数组中,14 的位置在 20 的后面,替换后 14 跑到 20 前面去了,虽然最长递增子序列的长度仍然是对的,但是实际的最长递增子序列是错误的。这显然是不对的,在 Vue3 中使用了回溯法纠正了这个错误。

回溯的关键在于定义了额外的 p 数组存储比当前值小的索引,即 p[i] 存的永远是比 arr[i] 小的那个值所在的位置。

js 复制代码
// result 数组的最后一个元素为当前找到的最大值的索引
j = result[result.length - 1]
// 当前值大于最大值
if (arr[j] < arrI) {
  // 因为 arr[j] < arrI ,j 为比当前值小的索引,则记录到 p 中
  p[i] = j
  // 将当前值的索引 push 到结果数组中
  result.push(i)
  continue
}
js 复制代码
// 开始二分
// 左指针初始值为 0
u = 0
// 右指针初始值为数组长度-1,也就是最大索引
v = result.length - 1 

// 当左指针小于右指针时,才需要进入循环
while (u < v) {
  // 这个位置是取中间值,Vue最初的代码是 ((u + v)/ 2) | 0 后来改成了 (u+v)>>1, 
  // 更好的方式是 u+ ((v-u) >> 1) 可以避免指针越界,
  // 不过在vue中节点的数量远达不到越界的情况可暂时忽略
  c = (u + v) >> 1
  // 如果中间值的位置的值小于当前值
  if (arr[result[c]] < arrI) {
    // 那么就说明要找的值在中间值的右侧,因此左指针变为中间值 +1
    u = c + 1
  } else { // 否则就是大于等于当前值
    // 那么右指针变为中间值,再进行下一次循环
    v = c
  }
}

// 最后输出的左指针的索引一定是非小于当前值的,有可能大于也有可能等于,
// 如果当前值小于第一个非小于的值,那么就意味着这个值是大于的,排除了等于的情况。
if (arrI < arr[result[u]]) {
  // 如果 u === 0 说明当前值是最小的,不会有比它小的值,那么它前面不会有任何的值,只有 u 大于 0 时才需要存储它前面的值  
  if (u > 0) {
    // result[u - 1] 就是当前值存储位置的前一位,也就是比当前值小的那个值所在的位置,
    // 记录到 p 中
    p[i] = result[u - 1]
  }
  // 将第一个比当前值大的值替换为当前值,依次来让数组递增的更缓慢,但会导致结果不正确
  result[u] = i
}

经过二分查找后,u 仍为 0 的话,说明 arrI 比 result 中的所有数的小。将这个比 result 中所有数都小的索引 i 记录下来:

js 复制代码
// 此时 u 为 0 
result[u] = i

c = (u + v) >> 1 为位操作,等价于 Math.floor((u + v) / 2) 。更好的方式是 u + ((v-u) >> 1) 可以避免指针越界,不过在 vue 中节点的数量远达不到越界的情况可暂时忽略。

js 复制代码
// 使用二分可以找到最长的长度但是无法判断最长的序列
u = result.length
// 开始回溯倒序找到最长的序列,
// 因为 p 中当前位置存放的是上一个比当前值小的数所在的位置,所以使用倒序
v = result[u - 1]
while (u-- > 0) { // 当 u 的索引没有越界时一直循环
  // 第一次循环, v 就是 result 里面的最后一项,因为最后一项是最大值,肯定是对的
  result[u] = v
  // 最后一项在 p 中记录了它的前一项,所以取出前一项放在 result
  // 循环每取出一项,都能在 p 中找到它的前一项,
  // 从而纠正由于二分查找替换导致的错误序列
  v = p[v]
}

上面的代码说明会比较抽象,下面来看看图解说明,假设我们输入的数组为:[3, 4, 1]

由于求取的是最长递增子序,因此 result 中的元素应该是递增的, 但是被二分查找打乱了顺序,因此需要通过回溯来纠正。

大家可能还不太理解 p[i] 的意义,其实 p[i] 存的永远是比 arr[i] 小的那个值所在的位置,因此通过 p[i] ,可以找到 i - 1 那个位置的正确值是多少。

记住,如果 p[i] ≠ arr[i],则 p[i] 存的是比 arr[i] 小的那个值所在的位置。所以通过回溯 p[i] 可以纠正 result 的值。

text 复制代码
下标   0  1  2  3  4  5  6  7
arr = [2, 4, 5, 3, 1, 9, 7, 8]
  p = [2, 0, 1, 0, 1, 2, 2, 6]

p[7] 为比 arr[7] 小的位置,p[7] 等于 6 ,则 arr[6] 是第一个比 arr[7] 小的值

p[6] 为比 arr[6] 小的位置,p[6] 等于 2 ,则 arr[2] 是第一个比 arr[6] 小的值

p[5] 为比 arr[5] 小的位置,p[5] 等于 2 ,则 arr[2] 是第一个比 arr[5] 小的值

arr[4] 为 arr 中最小值,因此 p[4] 为初始值,即 arr[4]

p[3] 为比 arr[3] 小的位置,p[3] 等于 0 ,则 arr[0] 是第一个比 arr[3] 小的值

p[2] 为比 arr[2] 小的位置,p[2] 等于 1 ,则 arr[1] 是第一个比 arr[2] 小的值

p[1] 为比 arr[1] 小的位置,p[1] 等于 0 ,则 arr[0] 是第一个比 arr[1] 小的值

p[0] 等于 arr[0] 因为第一轮循环不会改变 p

总结

因此,Vue3 提升 Diff 算法性能的关键是根据新旧 dom 序列位置的变化,构造了一个最长递增子序列,从而找到更新前后相对位置不变的节点的位置,最大限度地降低了 dom 移动的次数,从而提升了 Diff 算法的性能。

有关 Vue 更多 Diff 算法的内容可看笔者写的另外一篇文章 Vue 源码解读:聊聊三种 Diff 算法

相关推荐
加班是不可能的,除非双倍日工资12 分钟前
css预编译器实现星空背景图
前端·css·vue3
wyiyiyi1 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
gnip1 小时前
vite和webpack打包结构控制
前端·javascript
excel1 小时前
在二维 Canvas 中模拟三角形绕 X、Y 轴旋转
前端
阿华的代码王国2 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
一条上岸小咸鱼2 小时前
Kotlin 基本数据类型(三):Booleans、Characters
android·前端·kotlin
Jimmy2 小时前
AI 代理是什么,其有助于我们实现更智能编程
前端·后端·ai编程
草梅友仁2 小时前
草梅 Auth 1.4.0 发布与 ESLint v9 更新 | 2025 年第 33 周草梅周报
vue.js·github·nuxt.js
ZXT2 小时前
promise & async await总结
前端
Jerry说前后端2 小时前
RecyclerView 性能优化:从原理到实践的深度优化方案
android·前端·性能优化