『手写Vue3』Diff算法

312 阅读5分钟

关于Diff算法,大家可能早就知道Vue和React使用vnode,并且使用diff算法完成更新,最大限度减少浏览器对dom的操作,但是估计知道具体实现原理的人并不多,起码我以前一直是懵懂状态,在面试中也讲不出来。

关于Vue3的Diff,使用双端对比,并且结合了“求最长递增子序列”来减少移动的次数。

双端对比

更新前后的children:

const prevChildren = [
  h("p", { key: "A" }, "A"),
  h("p", { key: "B" }, "B"),
  h("p", { key: "C" }, "C"),
];
const nextChildren = [
  h("p", { key: "A" }, "A"),
  h("p", { key: "B" }, "B"),
  h("p", { key: "D" }, "D"),
  h("p", { key: "E" }, "E"),
];

props中可以提供key值,然后把key保存到vnode上:

  const vnode = {
    type,
    props,
    children,
    // props也可以不传key,此时key === undefined
    key: props && props.key,
    shapeFlag: getShapeFlags(type),
    el: null
  };

假设有两个序列,每个元素都是一个vnode,此处的字母代表其key值:

// 指针移动前
A   B |  C   D   E   Z |  F   G
      |                |      e1
      |                |
A   B |  D   C   Y   E |  F   G
i                             e2

一共设置了三个指针,e1指向old children末尾,e2指向new children末尾,i初始化为0。移动指针,目的是找到两个序列中间不一样的部分。

// 指针移后
A   B |  C   D   E   Z  |  F   G
      |              e1 |
      |                 |
A   B |  D   C   Y   E  |  F   G
         i           e2

先按左侧移动,再按右侧移动。当判断指针指向的两个结点相同时,指针移动,并且要递归patch这两个相同的结点,之后就会比较它们的props、children。

function patchKeyedChildren(
    c1,
    c2,
    container,
    parentComponent,
    parentAnchor // anchor用于移动操作,之后会说
  ) {
    const l1 = c1.length;
    const l2 = c2.length;

    let e1 = l1 - 1;
    let e2 = l2 - 1;
    let i = 0;

    // type和key一致,表明结点相同
    function isSameVNodeType(n1, n2) {
      return n1.type === n2.type && n1.key === n2.key;
    }

    // 左侧相同
    while (i <= e1 && i <= e2) {
      const n1 = c1[i];
      const n2 = c2[i];

      if (isSameVNodeType(n1, n2)) {
        patch(n1, n2, container, parentComponent, parentAnchor);
      } else {
        break;
      }
      i++;
    }

    // 右侧相同
    while (i <= e1 && i <= e2) {
      const n1 = c1[e1];
      const n2 = c2[e2];

      if (isSameVNodeType(n1, n2)) {
        patch(n1, n2, container, parentComponent, parentAnchor);
      } else {
        break;
      }
      e1--;
      e2--;
    }
  }

两端的增加和删除

新的比老的长,需要新增结点;老的比新的长,需要删除结点。并且新增/删除的结点可能位于左侧,也可能位于右侧。

增加

// 情况一(新增在右侧)
a    b
     e1

a    b    c    d
          i    e2 // e2 + 1 >= 4,anchor = null, 插入到末尾

// 情况二(新增在左侧)
    a    b
e1(-1)

    d    c    a    b
    i    e2 // e2 + 1 === 2,anchor = a

可见,[i, e2]区间内的结点是需要新增的。

第一种情况,插入操作直接用append到末尾没有问题,但是第二种情况得插入在前面,显然不能用append。所以insert需要使用insertBefore:

// 当anchor === null时,插入到末尾,相当于append
function insert(child, parent, anchor) {
  parent.insertBefore(child, anchor || null);
}

在进行新增操作时,需要计算此时的anchor结点,从情况二的图可知,anchorIndex = e2 + 1,即会选择e2右边的那个结点作为锚点,锚点始终不变,第一次在a的前面插入d,第二次在a的前面插入c。

如果e2 + 1 >= newChildren.length,说明插入点在末尾,那么anchor赋值为null即可。

    if (i > e1) {
      if (i <= e2) {
        const nextPos = e2 + 1;
        // l2就是newChildren.length
        const anchor = nextPos < l2 ? c2[nextPos].el : null;

        while (i <= e2) {
          // 给patch新增参数anchor,之后还要修改一系列函数签名
          patch(null, c2[i], container, parentComponent, anchor);
          i++;
        }
      }
    }

删除

// 情况一(删除在右侧)
a    b    c    d
               e1

a    b
     e2   i

// 情况二(删除在左侧)
     a    b    c    d
          e1

     c    d
e2   i

可见,[i, e1]区间内的结点是需要删除的。

删除的逻辑比较简单。

    if (i > e1) { 
      // 新增操作
    } else if (i > e2) 
      // 删除操作
      while (i <= e1) {
        hostRemove(c1[i].el);
        i++;
      }
    } else {
      // 处理中间部分
    }

处理中间部分

当i、e1和e2移动,最终脱离while循环,停在某个位置上,此时[i, e1]和[i, e2]就分别是两个孩子数组的中间部分。

// 指针移后
A   B |  C   D   E   Z  |  F   G
      |              e1 |
      |                 |
A   B |  D   C   Y   E  |  F   G
         i           e2

比如上面的例子,竖线之内的就是“中间部分”。中间部分会进行增加、移动、删除三种操作。

处理中间部分,涉及到以下数据结构:

  • toBePatched:oldChildren中需要处理的中间部分的长度,如上即CDEZ的长度。
  • keyToNewIndexMap:是map,key是oldChildren的结点的key,value是该结点在newChildren中的下标。
  • newIndexToOldIndexMap:是定长数组,初始化为全0,表示newChildren中的结点在oldChildren中的下标(并且加1),加1是为了和初始化的0值区分开,之后newIndexToOldIndexMap[i] === 0 表示该结点在oldChildren中找不到,需要新增。
  • increasingNewIndexSequence:将newIndexToOldIndexMap传入getSequence(计算最长递增子序列的函数),得到的返回值,表示构成最长递归子序列的数对应的下标。比如[5,2,3,4]会求得[1,2,3],表示下标为1、2、3的数构成最长递增子序列。

以下是初始化keyToNewIndexMap和newIndexToOldIndexMap的逻辑,并且也包含删除操作的处理,有以下两种方式:

  1. 每次确认能在newChildren中找到oldChildren中的结点prevChild的时候,patched就会自增,当patched >= toBePatched时,表示中间区域内所有新结点都被处理过了,那么剩下的老结点就要被删除。
  2. 在newChildren中找不到prevChild,该结点需要被删除。
// 在else块中
      let s1 = i;
      let s2 = i;
      let patched = 0;
      let moved = false;
      let maxIndexSoFar = 0;
      // 等于newChildren中间部分的长度
      const toBePatched = e2 - s2 + 1; 
      const keyToNewIndexMap = new Map();
      const newIndexToOldIndexMap = Array(toBePatched).fill(0);

      // 初始化keyToNewIndexMap
      for (let i = s2; i <= e2; i++) {
        const nextChild = c2[i];
        keyToNewIndexMap.set(nextChild.key, i);
      }
      // 遍历oldChildren
      for (let i = s1; i <= e1; i++) {
        const prevChild = c1[i];
        
        // patched统计当前patch的次数
        // 删除情况二:如果newChildren所有结点都被patch了,那么剩下的老结点肯定要被删除
        if (patched >= toBePatched) {
          hostRemove(prevChild.el);
          continue;
        }

        // 原数组的结点 在新数组中的下标
        let newIndex;

        if (prevChild.key !== null) {
          newIndex = keyToNewIndexMap.get(prevChild.key);
        } else {
          // 防止用户没有传key
          // 可见,没有key的时候,不能直接靠map拿到下标,增加了时间复杂度
          for (let j = s2; j <= e2; j++) {
            if (isSameVNodeType(prevChild, c2[j])) {
              newIndex = j;
              break;
            }
          }
        }

        if (newIndex === undefined) {
          // 删除情况一:原数组的结点 在新数组中找不到,删除
          hostRemove(prevChild.el);
        } else {
          if (newIndex > maxIndexSoFar) {
            maxIndexSoFar = newIndex;
          } else {
            moved = true;
          }
          // 原数组的结点 在新数组能找到
          patch(prevChild, c2[newIndex], container, parentComponent, null);
          patched++;
          newIndexToOldIndexMap[newIndex - s2] = i + 1;
        }
      }

然后是移动和新增,先求出最长递增子序列(优化点:根据moved判断是否需要移动,不需要移动的时候不用求最长递增子序列),然后设置指针j指向该序列的末尾,同时倒序遍历newChildren。

因为increasingNewIndexSequence指明了 newChildren的中间部分 构成最长递增子序列的下标,为了尽可能减少移动次数,位于 被指明的下标 上的结点 无需移动。

ABCD <=> DABC -> [4, 1, 2, 3] -> [1, 2, 3] -> 在DABC中,下标1、2、3的结点无需移动。

所以我们需要遍历newChildren的中间部分,即[i, e2 - i],或者表示为[s2, toBePatched - 1],判断该结点的下标是否位于increasingNewIndexSequence中。由于中间部分的下标和 increasingNewIndexSequence 都是递增序列,只需要使用双指针即可。

又因为要把结点移动到anchor的前面,如果是从左到右遍历中间部分,前一个结点移动时,anchor还没有移动过,它是一个不稳定的结点,所以前面的结点移动时不存在稳定的基准点,会导致错误。所以从右到左遍历中间部分,这样后面的结点处理之后变得稳定了,再处理前面的结点。j指向increasingNewIndexSequence的末尾。

newIndexToOldIndexMap[i] === 0,说明newChildren中的该结点在oldChildren中不存在,需要新增。anchor已经计算好了,直接插入即可。

      const increasingNewIndexSequence = moved
            ? getSequence(newIndexToOldIndexMap)
            : [];
      // 指向最长递增子序列的末尾,指针向左移动
      let j = increasingNewIndexSequence.length - 1;

      for (let i = toBePatched - 1; i >= 0; i--) {
        const nextIndex = s2 + i;
        const nextChild = c2[nextIndex];
        const anchor = nextIndex < l2 ? c2[nextIndex + 1].el : null;

        if (newIndexToOldIndexMap[i] === 0) {
          // 需要新增
          patch(null, nextChild, container, parentComponent, anchor);
        } else if (moved) {
          // j < 0 说明已经遍历完了最长递增子序列,剩下的结点全都是要移动的
          if (j < 0 || i !== increasingNewIndexSequence[j]) {
            hostInsert(nextChild.el, container, anchor);
          }
        }
      }

现在我们回到一开始举的例子,分析diff的全流程:

先是双端对比,移动i、e1、e2指针:

// 指针移后
A   B |  C   D   E   Z  |  F   G
      |              e1 |
      |                 |
A   B |  D   C   Y   E  |  F   G
         i           e2

该例子中左侧和右侧没有需要新增或删除的结点,因为 i < e1 && i < e2。

先遍历newChildren的中间部分,得到keyToNewIndexMap:

D -> 2
C -> 3
Y -> 4
E -> 5

然后遍历oldChildren的中间部分,在map中根据key查到下标,获取newIndex。然后构造定长数组newIndexToOldIndexMap。

key        newIndex
C             3
D             2
E             5
Z          undefined // 删除 

// newIndexToOldIndexMap(在oldChildren中的下标 + 1,按newChildren顺序来)
// 0 是初始化的值,表示该结点需要新增

 D  C  Y  E  最长递增子序列 increasingNewIndexSequence
[4, 3, 0, 6]      ->               [1, 3] 

求出increasingNewIndexSequence后,开始从右往左遍历:

  • E 不移动
  • Y 插入到 E 前面
  • C 不移动
  • D 插入到 C 前面

P.S. 插入操作是直接对newChildren做的,因为newChildren中有些结点在oldChildren中也有,这些vnode上已经设置好了el属性,可以直接访问到dom,可以作为anchor。