10张图、10分钟,包你学会 Vue3 的双端对比 diff 算法

732 阅读3分钟

10张图、10分钟,包你学会 Vue3 的双端对比 diff 算法

话说在射雕三部曲中论哪门武功比较有意思,笔者认为左右互博应该可以加入群聊。你也可以尝试一下左手画圆、右手画方,看看是不是歪七扭八的。咳咳,不说废话。咱们还是讨论一下 Vue3 的双端对比 diff 算法吧!

image.png

声明:左右互博 ≈ 双端对比

进入正题之前,笔者认为有必要复习虚拟 dom 和 diff 算法,来,请接招~

前置知识

  • 虚拟 dom
    • 是一个对象,我们将真实的 dom 通过某种方式转换成一个 对象
    • 对象的好处就是跨平台,如今的 json 就是最好的例子
    • 操作虚拟 dom 和 操作真实 dom 哪个快(有时间可以阅读一下 尤大的知乎回答),简言之,纯看速度真实DOM > 虚拟DOM,后者需要对比得出差异才更新。但是基于性能来说差异更新要比全量更新产生的代价更便宜

image.png

  • diff 算法
    • Vue3 是通过双端对比+最长递增子序列算法得出最小的更新消耗
    • 双端对比:两个指针一个从前面开始,一个从后面开始,得出中间改变的部分
    • 最长递增子序列:中间乱序部分,通过新老对比得出新的节点中元素与老节点中相对位置不需要改变的序列

亮剑:常见的更新问题

image.png

为了节约大家的时间以及 diff 算法主要运用在第四种情况,所以在本文笔者与你一起讨论 old array => new array。

举个🌰

image.png

如图所示,变化无非有以下三种:

  • 移动,c、d、e 位置不一样了
  • 删除,f 不存在了
  • 新增,e 是新加的

那么,我们要怎样确定那些元素变动了呢?又是从哪个元素开始变动的?

任务拆解

  • 确定左边开始变动的位置 => 左序遍历
  • 确定右边开始变动的位置 => 右序遍历

Vue 提供了一种方案 => 双端对比算法,也就是咱们开头说的左右互博,具体的看下图:

image.png

三个指针 i、e1、e2,i 表示从左边开始变动的位置,e1 和 e2 分别表示新老节点从右边开始变动的位置。通过循环 new tree 的节点,来确定变动位置,最终我们会得如图所示的结果:

(ps:有疑问❓一定要用三个指针,不能用两个指针吗?例如使用 j 指针从 new tree 末端开始,看似没啥毛病,仔细一想。如果新老节点数相等的时候,确实可以只用两个,当新老树节点不等的时候,一个指针就不能正确的表示差异节点的初始位置了~)

image.png

接下来,咱们具体的看下左序遍历和右序遍历的实现方式。

左序遍历

image.png

咱们的目的是要确定 i 的位置,首先得清楚循环条件,什么时候该退出循环。因为新老节点都是数组,所以 i 要小于或等于 e1(老节点的最后一位)e2(新节点的最后一位),代码如下:

// 比较函数 c1 为老的虚拟节点 c2 为新的虚拟节点
// c 为 children 的简写,e 为 element 的简写
function patchKeyedChildren(c1, c2){
    const len2 = c2.length // 后面多次用到,提取
    // 定义三个指针
    let i = 0  // 从新的节点开始
    let e1 = c1.length - 1 // 老的最后一个 索引值
    let e2 = len2 - 1 // 新的最后一个 索引值
    // 移动 i 指针
    while (i <= e1 && i <= e2) {
      const n1 = c1[i];
      const n2 = c2[i];
      if (isSomeVNodeType(n1, n2)) {
        // ... 在循环的比较此节点内的节点
        // patch
      } else {
        break;
      }
      i++;
    }
    // 粗略的比较,实际对比要更复杂
    function isSomeVNodeType(n1, n2) {
      // 对比节点是否相等 可以通过 type 和 key
      return n1.type === n2.type && n1.key === n2.key
    }
}

左序算法,我们主要做了以下几件事:

  • 循环 i ,拿到 c1[i] 和 c2[i]
  • 如果相等,就继续循环比较,对比到头,全都一样的,就 i++,移动指针
  • 如果不相等,就结束比较,停止移动指针

左边变动的位置确定后,接下来就确定右边变动的位置,这就是任务分解。接下来咱们看下右序遍历是如何实现的呢?

右序遍历

image.png

咱们从右边开始遍历,那循环条件是什么呢?是不是也只需要 i <= e1 和 i <= e2 就行了呀!i 的位置确定了,临界值无非是 i = e1 或 i = e2 的情况。e1 和 e2 分别是老节点和新节点的最后一个的索引值,实现代码如下:

function patchKeyedChildren(c1, c2){
    const len2 = c2.length 
    let i = 0  // 从新的节点开始
    let e1 = c1.length - 1 // 老的最后一个 索引值
    let e2 = len2 - 1 // 新的最后一个 索引值
    // 左序遍历
    while (i <= e1 && i <= e2) {
      ...
      i++;
    }
    // 右序遍历
    while (i <= e1 && i <= e2) {
      const n1 = c1[e1];
      const n2 = c2[e2];
      if (isSomeVNodeType(n1, n2)) {
        // ... 在循环的比较此节点内的节点
        // patch
      } else {
        break;
      }
      e1--;
      e2--;
    }
    // 粗略的比较,实际对比要更复杂
    function isSomeVNodeType(n1, n2) {
      // 对比节点是否相等 可以通过 type 和 key
      return n1.type === n2.type && n1.key === n2.key
    }
}

细看代码,右序遍历其实就是拿到老节点和新节点的最后一个值对比,相等的话,e1--、e2-- 往前移动,不相等就停止移动。

中间乱序部分

经过左序遍历和右序遍历,我们得出了以下的结果,圈出来的就是乱序的部分。

image.png

因为此部分篇幅较长,涉及到最长递增子序列算法,咱们可以移步 => 传送门 一起讨论。

写在最后

最后,一图帮你再看一下左序遍历和右序遍历。

image.png

另外,如果你想学习 Vue3 源码,推荐先入手 mini-vue,带你实现 Vue3 最简模型。