vue3子节点diff算法分析

314 阅读5分钟

众所周知,vue3相比于vue2在diff算法这一块更新也很大,并且还引入了最长自增子序列算法。这篇文章主要会通过文字+代码注释的方式,分析一下vue3子节点我认为最复杂的一部分,也就是源码中的patchKeyedChildren方法,是如何通过diff算法更新的。对于最长自增子序列算法,我不会在这篇文章中详细解释,但是会根据我自己的理解,讲解一下这个算法怎么应用在dom更新上。如果读者想深入了解最长自增子序列算法,可以阅读我的另外一篇文章

patchKeyedChildren的方法在@runtime-core/src/renderer.ts中,如果不想看我文章中的代码节选的话,可以打开源码对照。源码中patchKeyedChildren分成了5个部分,所以我也会拆分成5部分进行分析。

先通过注释看看函数参数和声明的变量

const patchKeyedChildren = (
    c1: VNode[],  // 旧节点集合
    c2: VNodeArrayChildren,  // 新节点集合
    container: RendererElement, // 父容器
    parentAnchor: RendererNode | null, // 锚点
    parentComponent: ComponentInternalInstance | null, // 以下参数和diff无关
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    slotScopeIds: string[] | null,
    optimized: boolean
  ) => {
    let i = 0
    const l2 = c2.length // 新子节点长度
    let e1 = c1.length - 1 // prev ending index
    let e2 = l2 - 1 // next ending index
  1. 这一步和vue2 diff一样,先从两个子节点的头索引逐步向下👇 比较,直到两个节点不是同一个节点的时候break。从注释可以看出,指针会一直下移通过a、b,然后发现c和d不是同个节点,于是退出这一次的while循环。
// 1. sync from start
// (a b) c
// (a b) d e
while (i <= e1 && i <= e2) {
    const n1 = c1[i];
    const n2 = (c2[i] = optimized
        ? cloneIfMounted(c2[i])
        : normalizeVNode(c2[i]));
    if (isSameVNodeType(n1, n2)) {
        patch(n1, n2, container, null, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
    }
    else {
        break;
    }
    i++;
        
  1. 这一步还是和vue2 diff一样,从两个子节点的尾索引逐步向上👆 比较。
// 2. sync from end
// a (b c)
// d e (b c)
while (i <= e1 && i <= e2) {
  const n1 = c1[e1];
  const n2 = (c2[e2] = optimized
      ? cloneIfMounted(c2[e2])
      : normalizeVNode(c2[e2]));
  if (isSameVNodeType(n1, n2)) {
      patch(n1, n2, container, null, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
  }
  else {
      break;
  }
  e1--;
  e2--;
}
  1. 完成了头头/尾尾比较后,此时会判断一下,因为这时候可能新节点遍历完了剩下老节点,或者老节点遍历完了剩下新节点。第3⃣️ 步判断的是剩下新节点的情况,这时候patch第一个参数是null,直接把新节点挂载上去就可以了。
// 3. common sequence + mount
// (a b)
// (a b) c
// i = 2, e1 = 1, e2 = 2
// (a b)
// c (a b)
// i = 0, e1 = -1, e2 = 0
if (i > e1) {
    if (i <= e2) {
        const nextPos = e2 + 1;
        const anchor = nextPos < l2 ? c2[nextPos].el : parentAnchor;
        while (i <= e2) {
            patch(null, (c2[i] = optimized
        ? cloneIfMounted(c2[i])
                : normalizeVNode(c2[i])), container, anchor, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized);
            i++;
        }
    }
}
  1. 这一步判断的就是剩余老节点的情况,处理方式非常简单,把剩余的老节点直接移除即可。
// 4. common sequence + unmount
// (a b) c
// (a b)
// i = 2, e1 = 2, e2 = 1
// a (b c)
// (b c)
// i = 0, e1 = 0, e2 = -1
else if (i > e2) {
    while (i <= e1) {
        unmount(c1[i], parentComponent, parentSuspense, true);
        i++;
    }
}
  1. 到最后一步了,这一步也是vue3变化最大的一步。先来看看它是处理什么情况的
// a b [c f g h] x y
// a b [f h g c] x y 

这里可以看出,经过头头尾尾移动指针缩窄范围后,剩下[]里面的四个元素还要对比,如果是vue,是会新后旧前/新前旧后再次移动指针。而vue3对这一块又细分成了三部分,先来看看声明的变量

const s1 = i; // prev starting index
const s2 = i; // next starting index

5.1 先声明了一个keyToNewIndexMap,先看看这个map的结构便于阅读,就是新节点的key和index的映射。

//[c f g h] old
//[f h g c] new
0: {"f" => 0}
1: {"h" => 1}
2: {"g" => 2}
3: {"c" => 3}
// 5.1 build key:index map for newChildren
  const keyToNewIndexMap = new Map();
  for (i = s2; i <= e2; i++) {
      const nextChild = (c2[i] = optimized
          ? cloneIfMounted(c2[i])
          : normalizeVNode(c2[i]));
      if (nextChild.key != null) {
					// 熟悉的警告⚠️
          if ((process.env.NODE_ENV !== 'production') && keyToNewIndexMap.has(nextChild.key)) {
              warn(`Duplicate keys found during update:`, JSON.stringify(nextChild.key), `Make sure keys are unique.`);
          }
          keyToNewIndexMap.set(nextChild.key, i);
      }
  }

5.2 这一块的代码会有点多,主要就是判断了一下是否需要移动子节点,并且声明了一个数组,记录了新节点在旧节点数组的位置,如果子节点需要移动,那这个数组就可以为后续服务。我们可以先结合注释来看,举个例子🌰

// a b [c f g h] x y
// a b [f h g c] x y 
// s1 = s2 = 头指针位置  这里=2 指向新节点[]第一个元素
// e2 = 尾指位置 这里=5 指向新节点[]最后一个元素

      let j
       // 记录已经patch过的数量
      let patched = 0
       // 需要patched的总数量 所以这里就是新节点[]里面的长度=4
      const toBePatched = e2 - s2 + 1
       // 是否需要移动 稍后会举例说明什么情况是不需要移动的
      let moved = false
      // 用来判断是否需要移动 也会举例说明
      let maxNewIndexSoFar = 0
       // 这个newIndexToOldIndexMap初始化长这样 [0,0,0,0] 
       // 用来记录新节点在旧节点[]的位置
      const newIndexToOldIndexMap = new Array(toBePatched)
      for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
	// 开始循环旧的节点 
      for (i = s1; i <= e1; i++) {
        const prevChild = c1[i]
        // 每patch一个新节点 patched++ 这里说明新节点已经patch完了 
        if (patched >= toBePatched) {
            // 所以直接把旧节点移除
          unmount(prevChild, parentComponent, parentSuspense, true)
          continue
        }
        let newIndex
	// 根据key 从新节点里面获取对应的索引
	// 比如旧节点的c在第一位 新节点为[f h g c] 获取到的nexIndex就是3
        if (prevChild.key != null) {
          newIndex = keyToNewIndexMap.get(prevChild.key)
        } else {
	// 没有key的情况 略
        //......
        }

        if (newIndex === undefined) {
            // 旧节点在新节点的map中找不到 直接删除
            // 比如 x[c f]y x[f]y c就会删除
          unmount(prevChild, parentComponent, parentSuspense, true)
        } else {
            // 找到了 记录一下 注意这里i+1了 也就是
            // [c f g h] [f h g c]生成的newIndexToOldIndexMap是
            // [2,4,3,1] 再次注意⚠️ +1了
          newIndexToOldIndexMap[newIndex - s2] = i + 1
            // 这里稍后详解 可以看到是这里操作了move
          if (newIndex >= maxNewIndexSoFar) {
            maxNewIndexSoFar = newIndex
          } else {
            moved = true
          }
          patch(
            prevChild,
            c2[newIndex] as VNode,
            container,
            null,
            parentComponent,
            parentSuspense,
            isSVG,
            slotScopeIds,
            optimized
          )
          patched++
        }
      }

这里还是需要举例说明一下maxNewIndexSoFar和move的左右


 if (newIndex >= maxNewIndexSoFar) {
    maxNewIndexSoFar = newIndex
  } else {
    moved = true
  }
// maxNewIndexSoFar 默认=0 maxNewIndexSoFar其实就是记录上一个newIndex
// 先来看看不需要移动的场景 xy[ab]z x[ab] 
// nexIndex是新节点在旧节点[]的位置 可以看出 遍历旧节点[]的时候 
// 如果节点顺序不变 那么nexIndex总是递增的 a是0 b是1 所以每一次循环 newIndex总是
// 大于上一个的newIndex

// 如果是 xy[ab]z x[ba]
// 遍历到旧节点b的时候 newIndex是0,但是上一次newIndex也就是maxNewIndexSoFar是1
// 所以move改为true

5.3 上面步骤判断了新子节点是否需要move,那么这一步就是决定新子节点怎么样move

//🌰
// [c f g h] old
// [f h g c] new

// 详细逻辑需要看另外一篇文章 开头又注明 或者通过其他文章了解
// 这里返回[0,2] 注意这是索引 也就是[f g]是最长自增子序列
const increasingNewIndexSequence = moved
// getSequence就是获取最长自增子序列
  ? getSequence(newIndexToOldIndexMap)
  : EMPTY_ARR
// 最长自增子序列的长度
j = increasingNewIndexSequence.length - 1
// 从后向前开始遍历
for (i = toBePatched - 1; i >= 0; i--) {
  const nextIndex = s2 + i
    // 新节点
  const nextChild = c2[nextIndex] as VNode
    // parentAnchor暂时不清楚是什么逻辑生成 但是如果当前节点不是最后一个节点
    // 就用不上parentAnchor anchor取的则是当前节点的下一个节点
  const anchor =
    nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
  if (newIndexToOldIndexMap[i] === 0) {
    // newIndexToOldIndexMap默认填充了0 如果到这里还是0 说明新节点不在旧节点里面
    // 直接新增
    patch(
      null,
      nextChild,
      container,
      anchor,
      parentComponent,
      parentSuspense,
      isSVG,
      slotScopeIds,
      optimized
    )
  } else if (moved) {
    //需要移动
    // 1. j<0 说明increasingNewIndexSequence遍历完了 还有剩余的新节点 
    // 2. i 不在increasingNewIndexSequence里面 i是需要移动的
    // 3. 如果当前节点在increasingNewIndexSequence里面,那么不做任何操作
    if (j < 0 || i !== increasingNewIndexSequence[j]) {
      move(nextChild, container, anchor, MoveType.REORDER)
    } else {
      j--
    }
  }
}

举例说明

// [c f g h] old
// [f h g c] new
// increasingNewIndexSequence = [0,2]

我们肉眼观看可知,旧节点的fg不需要移动,就可以实现新节点的位置,这就是最长自增子序列的作用,实现dom的最小移动次数。要移动的,只有gc两个节点。

// 第一次移动 拿到的是c节点 因为c节点后面没有节点 所以参考节点是parentAnchor 
// 实际效果是c节点插入到了父容器最后 页面变成 [f g h c]
//第二次移动 拿到g节点 在increasingNewIndexSequence里面,什么也不干
// 第三次移动 拿到h节点 参考节点拿当前节点后一个 就是g节点 
// move方法其实就是insertBefore 所以h节点移动到了g节点前面
// 页面变成 [f h g c ]
// 同第二次移动 什么也不干 页面还是 [f h g c] 和新节点vnode顺序对应上了

总体感觉,vue3 diff算法较难理解的点在最长自增子序列的计算,并且应用在dom移动上,还有就是是否需要move的判断。源码阅读体验还是比vue2n个if else清晰直观,并且源码里面也有对应场景的注释。 最后总结一下:

  1. 头头比较,目的是向下移动指针缩小diff范围
  2. 尾尾比较,目的同上
  3. 经过1、2步骤后,剩余了新节点就直接插入新节点
  4. 经过1、2步骤后,剩余了老节点就直接删除新节点
  5. 以上场景都不符合,则进行:
  • 新子节点建立 key 与索引的映射关系,keyToNewIndexMap
  • 判断新子节点是否需要移动,并且找到新子节点在旧子节点中的位置 newIndexToOldIndexMap
  • 遍历剩余新子节点,如果不在newIndexToOldIndexMap里面则新增,如果在并且需要move的话,根据获取到最长自增子序列进行移动。