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 算法

相关推荐
一颗花生米。2 小时前
深入理解JavaScript 的原型继承
java·开发语言·javascript·原型模式
学习使我快乐012 小时前
JS进阶 3——深入面向对象、原型
开发语言·前端·javascript
bobostudio19952 小时前
TypeScript 设计模式之【策略模式】
前端·javascript·设计模式·typescript·策略模式
勿语&3 小时前
Element-UI Plus 暗黑主题切换及自定义主题色
开发语言·javascript·ui
黄尚圈圈3 小时前
Vue 中引入 ECharts 的详细步骤与示例
前端·vue.js·echarts
浮华似水4 小时前
简洁之道 - React Hook Form
前端
正小安6 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
_.Switch8 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光8 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   8 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发