mini-vue3实现记录-diff算法

104 阅读8分钟

渲染器的diff算法是为了解决当新旧vnode的子节点都为一组节点时,达到相对最小性能开销并实现更新的效果,其本质上还是为了减少操作DOM元素。

这篇文章将介绍vue2使用的双端diff以及vue3使用的快速diff,具体代码可见我的mini-vue3项目commit记录( github.com/4noth1ng/my… )

前置

最简单的更新

如果新旧vnode的子节点都是一组节点,最简单的更新方式是什么? 我们只需要卸载所有旧节点,挂载新节点即可。那么为了减少性能开销,可以如何优化呢?

优化思路


事实上,如果新旧节点的子节点全部不同(包括tag和content),这就是最坏情况,我们只能进行卸载全部旧节点,挂载新节点的操作。但如果存在可复用节点,我们就无需进行卸载挂载操作,而是直接进行复用,并将其移动到正确的位置。
那么我们优化的方向就有两个:

  • 找到可以复用的vnode
  • 移动尽可能少的次数达到更新效果

可复用的dom元素

首先我们要知道vnode上的三个属性:

  • type: 可以是标签的tag(即element类型),也可以是一个component
  • children: 子节点
  • key:也就是我们使用v-for时绑定的key

我们将两个type以及key相同的vnode定义为相同的虚拟节点,这样的两个虚拟节点可以进行复用。

对dom元素的操作

我们对dom元素的操作基本为:移动、添加、删除,我们会引入一个锚点节点anchor,作为dom元素移动的相对坐标。
对于不可复用的dom元素,我们需要卸载这些旧的dom元素,并将新的dom元素挂载上。

双端diff

双端diff指的是:在新旧两组子节点的四个端点之间分别进行比较,并试图找到可复用的节点。下面我们假设所有节点的type相同

双端比较流程

我们定义四个索引:oldStartIdx, oldEndIdx, newStartIdx, newEndIdx,分别对应四个节点oldStartVNode, oldEndVNode, newStartVNode, newEndVNode,并进行四步的循环,循环条件为oldStartIdx < = oldEndIdx && newStartIdx <= newEndIdx

  1. 比较oldStartVNodenewStartVNode的key是否相同,如果相同,即可复用,进行patch打补丁,并将oldStartIdxnewStartIdx向后移, 否则进行第2步

  2. 比较oldEndVNodenewEndVNode的key是否相同,如果相同,即可复用,进行patch打补丁,并将oldEndVNodenewEndVNode向后移, 否则进行第3步

  3. 比较oldStartVNodenewEndVNode的key是否相同,如果相同,即可复用,进行patch打补丁, 并将oldStartVNode对应的dom元素移动到oldEndVNode对应dom元素之后(因为这一步可复用意味着oldStartVNode为当前遍历过的节点里的newEnd), 并相应移动指针, 否则进行第4步

  4. 比较oldEndVNodenewStartVNode的key是否相同,如果相同,即可复用,进行patch打补丁, 并将oldEndVNode对应的dom元素移动到oldStartVNode对应dom元素之前(因为这一步可复用意味着oldEndVNode为当前遍历过的节点里的newStart), 并相应移动指针

非理想情况

在一部分情况下,可能出现上述四种都没有命中的情况,我们将这些剩余的情况称为非理想情况。对于非理想情况,我们采用如下方法: 拿着新子节点的头部节点去旧的一组子节点中寻找可复用元素。如果找到了,意味着找到的这个节点应该是当前的头节点,即将这个旧子节点中的节点oldNode移动到oldStartVNode之前(当然也要patch),并且由于操作后该节点在oldChildren属于被遍历过,所以oldchidren内该索引设为undefined, 并将newStartIdx往后移动。

当然,上述方法也不一定就能在oldChildren内找到可复用节点,如果找不到意味着什么呢?说明newStartVNode为新增节点,那么我们就需要将newStartVNode对应dom元素添加到oldStartVNode对应dom元素前

循环结束后的剩余节点

我们循环的条件是oldStartIdx < = oldEndIdx && newStartIdx <= newEndIdx,那么在退出上述循环后,并非所有节点都会被遍历,如果oldChildren内还有剩余节点,即当循环结束后oldStartIdx <= oldEndIdx, 则将这些节点全部卸载, 同理,如果newChildren内有剩余节点,即当循环结束后newStartIdx <= newEndIdx,则将这些节点全部添加,对应的锚点为newChildren[newEndIdx + 1]。至此,diff结束。下面贴出关键部分代码

function patchKeyedChildren(oldChildren, newChildren, container, parentComponent) {
        // 新旧CHILDREN首尾指针
        let oldStartIdx = 0;
        let oldEndIdx = oldChildren.length - 1;
        let newStartIdx = 0;
        let newEndIdx = newChildren.length - 1;
        // 对应VNode
        let oldStartVNode = oldChildren[oldStartIdx];
        let oldEndVNode = oldChildren[oldEndIdx];
        let newStartVNode = newChildren[newStartIdx];
        let newEndVNode = newChildren[newEndIdx];
        debugger;
        // 新旧Children diff操作
        while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
            if (!oldStartVNode) {
                oldStartVNode = oldChildren[++oldStartIdx];
            }
            else if (!oldEndVNode) {
                oldEndVNode = oldChildren[--oldEndIdx];
            }
            else if (oldStartVNode.key === newStartVNode.key) {
                // 第一步:新旧头节点比较
                patch(oldStartVNode, newStartVNode, container, parentComponent);
                oldStartVNode = oldChildren[++oldStartIdx];
                newStartVNode = newChildren[++newStartIdx];
            }
            else if (oldEndVNode.key === newEndVNode.key) {
                // 第二步:新旧尾节点比较
                patch(oldEndVNode, newEndVNode, container, parentComponent);
                oldEndVNode = oldChildren[--oldEndIdx];
                newEndVNode = newChildren[--newEndIdx];
            }
            else if (oldEndVNode.key === newStartVNode.key) {
                // 第三步:旧尾节点与新头节点比较
                patch(oldEndVNode, newStartVNode, container, parentComponent);
                // oldEndVNode 应该移动到 oldStartVNode 前
                const anchor = oldStartVNode.el;
                hostInsert(oldEndVNode.el, container, anchor);
                oldEndVNode = oldChildren[--oldEndIdx];
                newStartVNode = newChildren[++newStartIdx];
            }
            else if (oldStartVNode.key === newEndVNode.key) {
                // 第四步:旧头节点与新尾节点比较
                patch(oldStartVNode, newEndVNode, container, parentComponent);
                // oldStartVNode 应该移动到 oldEndVNode 后
                const anchor = oldEndVNode.el.nextSibling;
                hostInsert(oldStartVNode.el, container, anchor);
                oldStartVNode = oldChildren[++oldStartIdx];
                newEndVNode = newChildren[--newEndIdx];
            }
            else {
                // 第五步:前四种都未匹配
                // 遍历OldChildren,寻找与 newStartVNode 具有相同 key的节点, 找到这个节点,就意味着这个节点需要移到当前oldStartVNode之前,找不到,就将它作为新的头节点
                // idxInOld 就是newStartVNode在OldChildren内的索引值
                const idxInOld = oldChildren.findIndex((node) => node && node.key === newStartVNode.key); // 如果没有,会返回 -1
                if (idxInOld > 0) {
                    const vnodeToMove = oldChildren[idxInOld];
                    // 移动前先递归进行patch
                    patch(vnodeToMove, newStartVNode, container, parentComponent);
                    // 移到oldStartVNode 之前
                    const anchor = oldStartVNode.el;
                    hostInsert(vnodeToMove.el, container, anchor);
                    // 由于该节点已经被移动,所以此处设为undefined,并在最前增加两个对于oldChildren内undefined节点的判断
                    oldChildren[idxInOld] = undefined;
                    newStartVNode = newChildren[++newStartIdx];
                }
                else {
                    // 旧节点中不存在
                    patch(null, newStartVNode, container, parentComponent);
                    hostInsert(newStartVNode.el, container, oldStartVNode.el);
                }
                newStartVNode = newChildren[++newStartIdx];
            }
        }
        // 如果newChildren内有遗留的节点,进行添加
        if (oldStartIdx > oldEndIdx && newStartIdx <= newEndIdx) {
            for (let i = newStartIdx; i <= newEndIdx; i++) {
                const anchor = newChildren[newEndIdx + 1]
                    ? newChildren[newEndIdx + 1].el
                    : null;
                // [newStartIdx, newEndIdx]内为遗留的节点, 按照在 newChildren内的顺序, 我们需要将这些节点全部插入到newChildren[newEndIdx + 1]前
                // 如果newChildren[newEndIdx + 1]不存在, 说明全部插入至结尾
                patch(null, newChildren[i], container, parentComponent);
                hostInsert(newChildren[i].el, container, anchor);
            }
        }
        // 如果oldChildren内有遗留的节点,进行删除
        if (newStartIdx > newEndIdx && oldStartIdx <= oldEndIdx) {
            for (let i = oldStartIdx; i <= oldEndIdx; i++) {
                hostRemove(oldChildren[i].el);
            }
        }
    }

快速diff

快速diff是vuejs3采用的diff方法,测试表明其效率会比双端diff要高,实质上是一个最长递增子序列算法。

比较流程

  1. 我们定义索引j, 并开启一个while循环,用于处理oldChildrennewChildren的相同前置节点,对于相同的前置节点,我们无需移动节点,但仍需patch打补丁并向前移动j,当oldChildren[j]newChildren[j]不同时退出循环。

  2. 由于新旧两组子节点的数量可能不同,我们定义两个索引oldEndnewEnd指向末尾节点,从后往前遍历两组节点,同上我们不需要移动节点但需要patch,并移动索引,不同时退出

  3. 上述两步进行后,我们已经将相同的前置后置节点处理完毕。那么接下来有三种情况:1. 旧节点处理完毕、新节点有剩余;2. 旧节点有剩余,新节点处理完毕;3. 旧节点新节点都有剩余。我们分情况讨论。

  4. 旧节点处理完毕,新节点有剩余。即当j > oldEnd && j <= newEnd时,这时候newChildren内[j, newEnd]为需要处理的节点,我们只需要以newChildren[newEnd+1]为锚点将这些节点插入即可。

  5. 旧节点有剩余,新节点处理完毕。即当j <= oldEnd && j > newEnd时,这时候oldChildren内[j, newEnd]为需要处理的节点,我们只需要卸载这些节点即可

  6. 新旧节点都有剩余

新旧节点都有剩余

新旧节点都有剩余的情况,我们就需要计算出一个最长递增子序列,也就是一个相对位置不变的最长旧虚拟节点序列,用于辅助完成DOM移动的操作。

首先,我们定义一个source数组,这个数组的长度为newEnd-j+1,即新的一组子节点中未处理的节点数量,source数组用来存储新的一组子节点中的节点 在旧的一组子节点中的 位置索引。之后,我们进行填充source数组操作,我们将所有位置初始化为-1,并用新的子节点在旧的子节点中寻找,如果找到了就存放对应的索引值。

填充后,对于值为-1的虚拟节点,我们需要进行卸载,那么对于其他有对应可复用节点的元素,我们就需要考虑:1. 如何判断这个节点需要移动?2. 如何移动元素?

如何判断这个节点需要移动

我们定义pos=0, 代表遍历旧节点时遇到的最大索引值,当pos呈现递增时,说明无需移动节点, 我们定义k为当前newVNodeoldChildren对应的索引,如果k < pos,说明当前索引比最大索引要小,即在oldChildren中当前newVNode靠前, 需要移动。 除此之外,我们还要维护一个变量patched用于表示已经更新的节点数量,patched的值应该小于等于新的一组子节点需要更新节点的数量。

如何移动节点

首先,我们根据source数组计算出其最长递增子序列,定义为seq,最长递增子序列对应的元素意味着,这些元素的相对位置是无需改变的,对于这些节点,我们只需要进行patch打补丁即可,而无需移动元素;对于剩下的元素,我们则需要按照一定位置进行移动。


为了完成节点的移动,我们定义两个索引值isi指向新的一组子节点中最后一个元素,s指向最长递增子序列的最后一个元素。

我们开启一个for循环,使得i从后往前遍历,当source[i] === -1时,说明该节点为新节点,直接挂载;当节点索引i等于seq[s]的值时,只需要s--即可;当二者不等时,说明该节点需要移动。如何进行移动呢?我们先获取i对应节点的真实索引i+newStart,也就得到了newVNode,那么我们将newVNode插入到i+newStart+1前即可,其实挂载新节点也是相似的思路。下面贴出具体代码:

function patchKeyedChildren(

    oldChildren,

    newChildren,

    container,

    parentComponent

  ) {

    // 1. 处理相同前缀 定义索引j指向新旧两组子节点的开头

    let j = 0;

    let oldVNode = oldChildren[j];

    let newVNode = newChildren[j];

    while (oldVNode.key === newVNode.key) {

      patch(oldVNode, newVNode, container, parentComponent);

      j++;

      oldVNode = oldChildren[j];

      newVNode = newChildren[j];

    }

    // 2. 处理相同后缀,由于新旧两组子节点不同,所以定义两个指针

    let oldEnd = oldChildren.length - 1;

    let newEnd = newChildren.length - 1;

    oldVNode = oldChildren[oldEnd];

    newVNode = newChildren[newEnd];


    while (oldVNode.key === newVNode.key) {

      patch(oldVNode, newVNode, container, parentComponent);

      oldVNode = oldChildren[--oldEnd];

      newVNode = newChildren[--newEnd];

    }


    //3. 处理完前缀后缀,如果新节点数组仍有剩余,则需插入, 如何判断有剩余? 易得:j > oldEnd说明旧节点处理完毕, j <= newEnd 说明新节点未处理完毕,则当二者符合时,满足条件

    if (j > oldEnd && j <= newEnd) {

      // 将[j, newEnd]内的所有节点插入到 newEnd的后一个节点之前

      const anchorIdx = newEnd + 1;

      const anchor =

        anchorIdx < newChildren.length ? newChildren[anchorIdx].el : null;

      while (j <= newEnd) {

        patch(null, newChildren[j], container, parentComponent);

        hostInsert(newChildren[j++].el, container, anchor);

      }

    }

    // 4. 如果旧节点数组仍有剩余,则需卸载,同上,当 j > newEnd说明新节点处理完毕, 当 j <= oldEnd 说明旧节点未处理完毕

    else if (j > newEnd && j <= oldEnd) {

      // 卸载 [j, oldEnd] 之间的节点

      while (j <= oldEnd) {

        hostRemove(oldChildren[j++].el);

      }

    }

    // 5. 新旧都有剩余

    else {

      // 构建source数组,用于存放新的一组子节点在旧的一组子节点的索引

      const count = newEnd - j + 1; // 需要更新的新节点数量

      const source = Array(count).fill(0);

      source.fill(-1);

      // oldStart 和 newStart 分别为起始索引,即j

      const oldStart = j;

      const newStart = j;

      let moved = false; // 代表是否需要移动节点

      let pos = 0; // 代表遍历旧节点时遇到的最大索引值,当pos呈现递增时,说明无需移动节点

      // 构建索引表, key为新节点VNode的key,value为下标索引值, 用来寻找具有相同key的可复用节点

      const keyIndex = {};

      for (let i = newStart; i <= newEnd; i++) {

        keyIndex[newChildren[i].key] = i;

      }

      // 代表更新过的节点数量

      let patched = 0;

      // 遍历旧的一组子节点中剩余未处理的节点

      for (let i = oldStart; i <= oldEnd; i++) {

        oldVNode = oldChildren[i];

        if (patched <= count) {

          const k = keyIndex[oldVNode.key];

         if (typeof k !== "undefined") {

            // 存在可复用节点

            newVNode = newChildren[k];

            patch(oldVNode, newVNode, container, parentComponent);

            patched++;

            source[k - newStart] = i;

            if (k < pos) {

              // 当前索引比最大索引要小,即在oldChildren中当前newVNode靠前, 需要移动
              moved = true;
            } else {
              pos = k;
            }

          } else {

            // 该旧节点不存在于新节点数组中,则直接卸载
            hostRemove(oldVNode.el);
          }

        } else {
          // patched > count 即新节点已经更新完毕, 剩余旧节点需要进行卸载
          hostRemove(oldVNode.el);
        }
      }

      if (moved) {

        const seq = getSequence(source);

        let s = seq.length; // s 指向递增子序列的最后一个元素

        let i = count - 1; // i + newStart 指向需要更新的新节点序列最后一个元素

        for (i; i >= 0; i--) {

          if (source[i] === -1) {

            // 旧节点数组中不存在该元素,直接进行挂载

            const pos = i + newStart;

            const newVNode = newChildren[pos];

            const nextPos = pos + 1;

            const anchor =

              nextPos < newChildren.length ? newChildren[nextPos].el : null;

            patch(null, newVNode, container, parentComponent);

            hostInsert(newVNode.el, container, anchor);

          } else if (i !== seq[s]) {

            // 当前新节点不属于递增子序列的部分,所以该节点需要进行移动

            const pos = i + newStart;

            const newVNode = newChildren[pos];

            const nextPos = pos + 1;

            // 移动到他在newChildren的后一个节点之前

            const anchor = nextPos < newChildren.length ? newChildren[nextPos].el : null;
             
            hostInsert(newVNode.el, container, anchor);
          } else {
            // 存在于递增子序列中,无需移动, s向前移动
            s--;
          }
        }
      }
    }
  }

last

需要具体实现的朋友可以看我的mini-vue3项目~( github.com/4noth1ng/my… )