Vue3 Diff算法总结(图文结合)

Vue3 diff 优化点

1. 静态标记 + 非全量 Diff(Vue 3在创建虚拟DOM树的时候,会根据DOM中的内容会不会发生变化,添加⼀个静态标记。之后在与上次虚拟节点进行对比的时候,就只会对比这些带有静态标记的节点。

2. 使用最长递增子序列优化对比流程,可以最大程度的减少 DOM 的移动,达到最少的 DOM 操作

实现思路

vue3的diff算法其中有两个理念。第⼀个是相同的前置与后置元素的预处理;第⼆个则是最长递增子序列

前置与后置的预处理

我们看这两段文字

1 hello chenguanhui

2 hey chenguanhui

我们会发现,这两段文字是有⼀部分是相同的,这些文字是不需要修改也不需要移动的,真正需要进行修改中间的几个字母,所以diff就变成以下部分

1 text1: llo

2 text2: y

接下来换成 vnode我们来看下 :

图中的被绿色框起来的节点,他们是不需要移动的,只需要进⾏打补丁 patch 就可以了。我们把该逻辑写成代码。

                    

function vue3Diff(prevChildren, nextChildren, parent) {
  let j = 0,
    prevEnd = prevChildren.length - 1,
    nextEnd = nextChildren.length - 1,
    prevNode = prevChildren[j],
    nextNode = nextChildren[j];
  while (prevNode.key === nextNode.key) {
    patch(prevNode, nextNode, parent)
    j++
    prevNode = prevChildren[j]
    nextNode = nextChildren[j]
  }

  prevNode = prevChildren[prevEnd]
  nextNode = prevChildren[nextEnd]

  while (prevNode.key === nextNode.key) {
    patch(prevNode, nextNode, parent)
    prevEnd--
    nextEnd--
    prevNode = prevChildren[prevEnd]
    nextNode = prevChildren[nextEnd]
  }
}

这时候,我们需要考虑边界情况,⼀种是j > prevEnd;另⼀种是j > nextEnd。

在上图中,此时j > prevEnd且j <= nextEnd,只需要把新列表中 j 到nextEnd之间剩下的节点插⼊进去就可以了。相反, 如果j > nextEnd时,把旧列表中 j 到prevEnd之间的节点删除就可以了。

function vue3Diff(prevChildren, nextChildren, parent) {
  // ...
  if (j > prevEnd && j <= nextEnd) {
    let nextpos = nextEnd + 1,
      refNode = nextpos >= nextChildren.length
      ? null
      : nextChildren[nextpos].el;
    while(j <= nextEnd) mount(nextChildren[j++], parent, refNode)

  } else if (j > nextEnd && j <= prevEnd) {
    while(j <= prevEnd) parent.removeChild(prevChildren[j++].el)
  }
}

在while循环时,指针是从两端向内逐渐靠拢的,所以我们应该在循环中就应该去判断边界情况,我们使⽤label语法,当我们触发边界情况时,退出全部的循环,直接进入判断:

function vue3Diff(prevChildren, nextChildren, parent) {
  let j = 0,
    prevEnd = prevChildren.length - 1,
    nextEnd = nextChildren.length - 1,
    prevNode = prevChildren[j],
    nextNode = nextChildren[j];
  // label语法
  outer: {
    while (prevNode.key === nextNode.key) {
      patch(prevNode, nextNode, parent)
      j++
      // 循环中如果触发边界情况,直接break,执⾏outer之后的判断
      if (j > prevEnd || j > nextEnd) break outer
      prevNode = prevChildren[j]
      nextNode = nextChildren[j]
    }

    prevNode = prevChildren[prevEnd]
    nextNode = prevChildren[nextEnd]

    while (prevNode.key === nextNode.key) {
      patch(prevNode, nextNode, parent)
      prevEnd--
      nextEnd--
      // 循环中如果触发边界情况,直接break,执⾏outer之后的判断
      if (j > prevEnd || j > nextEnd) break outer
      prevNode = prevChildren[prevEnd]
      nextNode = prevChildren[nextEnd]
    }
  }

  // 边界情况的判断
  if (j > prevEnd && j <= nextEnd) {
    let nextpos = nextEnd + 1,
      refNode = nextpos >= nextChildren.length
      ? null
      : nextChildren[nextpos].el;
    while(j <= nextEnd) mount(nextChildren[j++], parent, refNode)

  } else if (j > nextEnd && j <= prevEnd) {
    while(j <= prevEnd) parent.removeChild(prevChildren[j++].el)
  }
}

判断是否需要移动 (最长递增子序列)

接下来,就是找到移动的节点,然后给他移动到正确的位置

当前/后置的预处理结束后,我们进⼊真正的diff环节。首先,我们先根据新列表剩余的节点数量,创建⼀个Source数组,并将数组填满-1。

function vue3Diff(prevChildren, nextChildren, parent) {
  //...
  outer: {
    // ...
  }

  // 边界情况的判断
  if (j > prevEnd && j <= nextEnd) {
    // ...
  } else if (j > nextEnd && j <= prevEnd) {
    // ...
  } else {
    let prevStart = j,
      nextStart = j,
      nextLeft = nextEnd - nextStart + 1, // 新列表中剩余的节点⻓度
      source = new Array(nextLeft).fill(-1); // 创建数组,填满-1

  }
}

Source是用来做新旧节点的对应关系的,我们将新节点在旧列表的位置存储在该数组中,我们在根据Source计算出它的最长递增子序列用于移动DOM节点。为此,先建立一个对象存储当前新列表中的节点与index的关系,再去旧列表中去找位置。

注意:如果旧节点在新列表中没有的话,直接删除就好。除此之外,我们还需要⼀个数量表示记录我们已经patch过的节点,如果数量已经与新列表剩余的节点数量⼀样,那么剩下的旧节点我们就直接删除了就可以了

function vue3Diff(prevChildren, nextChildren, parent) {
  //...
  outer: {
    // ...
  }

  // 边界情况的判断
  if (j > prevEnd && j <= nextEnd) {
    // ...
  } else if (j > nextEnd && j <= prevEnd) {
    // ...
  } else {
    let prevStart = j,
      nextStart = j,
      nextLeft = nextEnd - nextStart + 1, // 新列表中剩余的节点⻓度
      source = new Array(nextLeft).fill(-1), // 创建数组,填满-1
      nextIndexMap = {}, // 新列表节点与index的映射
      patched = 0; // 已更新过的节点的数量

    // 保存映射关系
    for (let i = nextStart; i <= nextEnd; i++) {
      let key = nextChildren[i].key
      nextIndexMap[key] = i
    }

    // 去旧列表找位置
    for (let i = prevStart; i <= prevEnd; i++) {
      let prevNode = prevChildren[i],
        prevKey = prevNode.key,
        nextIndex = nextIndexMap[prevKey];
      // 新列表中没有该节点 或者 已经更新了全部的新节点,直接删除旧节点
      if (nextIndex === undefind || patched >= nextLeft) {
        parent.removeChild(prevNode.el)
        continue
      }
      // 找到对应的节点
      let nextNode = nextChildren[nextIndex];
      patch(prevNode, nextNode, parent);
      // 给source赋值
      source[nextIndex - nextStart] = i
      patched++
    }
  }
}
DOM如何移动

判断完是否需要移动后,我们就需要考虑如何移动了。⼀旦需要进行DOM移动,我们首先要做的就是找到source的最长递增子序列。

最长递增子序列:给定⼀个数值序列,找到它的一个子序列,并且子序列中的值是递增的,序列中

的元素在原序列中不⼀定连续。

例如给定数值序列为:[ 0, 8, 4, 12 ]。

那么它的最长递增子序列就是:[0, 8, 12]。

当然答案可能有多种情况,例如:[0, 4, 12] 也是可以的。

上⾯的代码中,我们调用lis 函数求出数组source的最长递增子序列为[ 0, 1 ]。我们知道 source 数组的值为 [4,2, 3, 1, -1],很显然最长递增子序列应该是[ 2, 3 ],计算出的结果是[ 1, 2 ]代表的是最长递增子序列中的各个元素在source数组中的位置索引,如下图所示

我们根据source,对新列表进行重新编号,并找出了最长递增子序列。

我们从后向前进行遍历source每⼀项。此时会出现三种情况:

1. 当前的值为-1,这说明该节点是全新的节点,又由于我们是从后向前遍历,我们直接创建好DOM节点插入到队尾就可以了;

2. 当前的索引为最长递增子序列中的值,也就是i === seq[j],这说说明该节点不需要移动;

3. 当前的索引不是最长递增子序列中的值,那么说明该DOM节点需要移动,这里也很好理解,我们也

是直接将DOM节点插⼊到队尾就可以了,因为队尾是排好序的;

Diff更新的详细过程图

那么在第一次更新中,发现g节点当前值是-1,那么按新增节点进行处理,将其插入到新e节点前:

第二次更新,发现b不在最长递增子序列中,说明DOM节点需要移动,直接插入队尾中(因为我们是从后向前进行遍历)

在第三次更新中,发现d节点存在于最长递增子序列中,所以本次更新可以跳过:

在第四次更新中,同理发现c节点存在于最长递增子序列中,所以本次更新可以跳过:

在第五次更新,此时f节点可复用,则将其移动到新c节点前即可。

到此为止,上图的diff就结束了。

代码如下

function vue3Diff(prevChildren, nextChildren, parent) {
  //...
  if (move) {
    // 需要移动
    const seq = lis(source); // [0, 1]
    let j = seq.length - 1; // 最⻓⼦序列的指针
    // 从后向前遍历
    for (let i = nextLeft - 1; i >= 0; i--) {
      let pos = nextStart + i, // 对应新列表的index
        nextNode = nextChildren[pos], // 找到vnode
        nextPos = pos + 1, // 下⼀个节点的位置,⽤于移动DOM
      refNode = nextPos >= nextChildren.length ? null : nextChildren[nextPos].
        cur = source[i]; // 当前source的值,⽤来判断节点是否需要移动

      if (cur === -1) {
        // 情况1,该节点是全新节点
        mount(nextNode, parent, refNode)
      } else if (cur === seq[j]) {
        // 情况2,是递增⼦序列,该节点不需要移动
        // 让j指向下⼀个
        j--
      } else {
        // 情况3,不是递增⼦序列,该节点需要移动
        parent.insetBefore(nextNode.el, refNode)
      }
    }

  } else {
    //不需要移动

  }
}

说完了需要移动的情况,再说说不需要移动的情况。如果不需要移动的话,我们只需要判断是否有全新的节点给他添加进去就可以了。具体代码如下:

function vue3Diff(prevChildren, nextChildren, parent) {
  //...
  if (move) {
    const seq = lis(source); // [0, 1]
    let j = seq.length - 1; // 最⻓⼦序列的指针
    // 从后向前遍历
    for (let i = nextLeft - 1; i >= 0; i--) {
      let pos = nextStart + i, // 对应新列表的index
        nextNode = nextChildren[pos], // 找到vnode
        nextPos = pos + 1, // 下⼀个节点的位置,⽤于移动DOM
      refNode = nextPos >= nextChildren.length ? null : nextChildren[nextPos].
        cur = source[i]; // 当前source的值,⽤来判断节点是否需要移动

      if (cur === -1) {
        // 情况1,该节点是全新节点
        mount(nextNode, parent, refNode)
      } else if (cur === seq[j]) {
        // 情况2,是递增⼦序列,该节点不需要移动
        // 让j指向下⼀个
        j--
      } else {
        // 情况3,不是递增⼦序列,该节点需要移动
        parent.insetBefore(nextNode.el, refNode)
      }
    }
  } else {
    //不需要移动
    for (let i = nextLeft - 1; i >= 0; i--) {
      let cur = source[i]; // 当前source的值,⽤来判断节点是否需要移动

      if (cur === -1) {
        let pos = nextStart + i, // 对应新列表的index
          nextNode = nextChildren[pos], // 找到vnode
          nextPos = pos + 1, // 下⼀个节点的位置,⽤于移动DOM
        refNode = nextPos >= nextChildren.length ? null : nextChildren[nextPos
          mount(nextNode, parent, refNode)
      }
    }
  }
}

对比 Vue2

总所周知,Vue2中的diff算法时间复杂度为O(n),而Vue3中的diff算法基于最长递增子序列最高有O(nlogn)。那么为什么要改用算法呢?

个人觉得是为了减少对DOM的操作,虽然大多数情况下,两种算法的对DOM的操作次数几乎相等,但也有不少的情况Vue3对DOM的操作次数少于Vue2,比如下面的例子:

在上面的例子中,Vue2的操作次数为3次;Vue3的操作次数为2次。

了解vue2 Diff算法

这种DOM操作的优势在递增序列越长时更显著。

  • 21
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值