【vue3】diff - PatchKeyedChildren

78 阅读6分钟

无论是vue或者react,diff的核心都是放在对 子节点 的diff环节中。

在vue中,子节点diff的入口函数为: patchChildren

在该函数内部,针对新节点是否有key,又分为patchUnkeyedChildren(处理不具备key的子节点),patchKeyedChildren(处理携带key的子节点);

i.e. 新节点中有10个子节点,10个子节点都没有key,则使用patchUnkeyedChildren函数。10个子节点中只要有一个子节点有key则使用patchKeyedChildren方法。

下面是对 patchKeyedChildren方法的解析

本文仅关注该函数的处理流程,代码有做删减处理

/**
* @param c1 旧子节点数组
* @param c2 新子节点数组
*/
function patchKeyedChildren(c1, c2, container, parentAnchor) {
  let i = 0;
  const l2 = c2.length;
  let e1 = c1.length - 1; // 旧节点数组最后一项下标
  let e2 = l2 - 1; // 新节点数组最后一项下标

  // (a b) c 
  // (a b) d e
  // 1、开始位置比较
  while(i <= e1 && i <= e2) {
    const n1 = c1[i];
    const n2 = c2[i];
    if (isSameVNodeType(n1, n2)) {
      // 类型相同
      // patch(n1, n2, container, null)
    } else {
      // 类型不同
      break;
    }
    i++;
  }

  // b e (c d)
  // a (c d)
  // 2、结尾位置比较
  while(i <= e1 && i <= e2) {
    const n1 = c1[e1];
    const n2 = c2[e2];
    if (isSameVNodeType(n1, n2)) {
      // 相同
      // patch(n1, n2, container, null)
    } else {
      // 不同
      break;
    }
    e1--;
    e2--;
  }

  // 3、理想情况
  // 3-1、旧节点处理完毕,新节点有剩余
  // (a b)
  // (a b) c
  if (i > e1) {
    if (i <= e2) {
      const anchor = e2 + 1 < l2 ? c2[e2 + 1].el : parentAnchor
      while(i <= e2) {
        const n2 = c2[i]
        // 新增节点
        // patch(null, n2, container, anchor)
        i++
      }
    }
  }

  // 3-2、新节点处理完毕,旧节点有剩余
  // (a b) c
  // (a b)
  else if (i > e2) {
    while(i <= e1) {
      const n1 = c1[i];
      // 卸载
      // unmount(n1)
      i++;
    }
  }

  // 4、非理想情况
  // a b [c e d] f g
  // a b [d c e h] f g
  // i = 2; e1 = 4; e2 = 5;
  else {
    // 将之前的处理进度进行存储
    const s1 = i;
    const s2 = i;

    // 构建 剩余新节点 key: index Map映射关系
    const keyToNewIndexMap = new Map();
    for(i = s2; i <= e2; i++) {
      const n2 = c2[i];
      if (n2.key !== null) {
        // 存在key
        keyToNewIndexMap.set(n2.key, i);
      }
    }

    let patched = 0; // 新节点已处理个数
    const toBePatched = e2 - s2 + 1; // 剩余的新节点数量
    let moved = false; // 需要移动标识
    let maxNewIndexSoFar = 0; // 遍历过程中 遇到的 最大的新节点下标,用于判断是否需要移动
    // 新节点在 旧节点数组中的位置
    const newIndexToOldIndexMap = new Array(toBePatched).fill(0);
    let j;

    for(i = s1; i <= e1; i++) {
      const n1 = c1[i];
      if (patched >= toBePatched) {
        // toBePatched 是剩余需要处理的新节点的个数,当patched >= toBePatched时 意味着 剩余新节点都处理完毕
        // 因为当前处于 旧节点数组的 循环中, 存在一种可能, 剩余的旧节点 多余 剩余新节点的个数, 意味着有些旧节点需要被卸载掉
        // 卸载无用的旧节点
        // unmount(n1)
        continue
      }

      let newIndex; // 当前旧节点在新节点数组中的位置
      if (n1.key !== null) {
        // 旧节点 有key
        newIndex = keyToNewIndexMap.get(n1.key);
      } else {
        // 旧节点 无key
        for(j = s2; i <= e2; j++) {
          if (newIndexToOldIndexMap[j - s2] === 0 && isSameVNodeType(n1, c2[j])) {
            // newIndexToOldIndexMap[j - s2] === 0 的作用是 当其 !== 0 时 意味着在之前的遍历中对应位置已经建立映射关系,本次遍历便可跳过去比较后续节点
            newIndex = j;
            break;
          }
        }
      }

      if(newIndex === undefined) {
        // 未找到 当前 旧节点 在 新节点中映射位置,该旧节点 无法复用
        // 卸载旧节点
        // unmount(n1)
      } else {
        // 建立 新旧节点位置映射关系
        newIndexToOldIndexMap[newIndex - s2] = i + 1;

        // 判断是否需要移动
        if (newIndex >= maxNewIndexSoFar) {
          // 
          maxNewIndexSoFar = newIndex
        } else {
          // 需要移动
          moved = true;
        }

        // 将新旧节点 进行递归patch
        // patch(n1, c2[newIndex], container, null)
        // 处理进度递增
        patched++;
      }
    }

    // 经历过上面这一步后,所有的旧节点都已经被处理完毕 (该复用的复用,该卸载的卸载)
    // 接下来遍历剩余的新节点,将所有新节点都进行处理
    // 剩余新节点处理 有两种可能
    // 一种是 纯粹的新增节点
    // 另一种是 经过复用得到的新节点 其位置或许需要移动(moved标识意味着上面的处理 是否遇到了需要移动的节点, 该种情况处理的是 patched++上方调用的patch()函数产生的新节点
    // 如果moved = false; 即无需移动则 上方调用的patch()函数生成的新节点其位置已然满足需要,否则就需要进行移动)

    // 接下来 就是 经典的 最长递增子序列
    const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : [];
    j = increasingNewIndexSequence.length - 1
    for(i = toBePatched - 1; i >= 0; i--) {
      const n2Index = s2 + i;
      const n2 = c2[n2Index];
      if (newIndexToOldIndexMap[i] === 0) {
        // 纯粹的新增节点
        // patch(null, n2, container, anchor)
      } else if (moved) {
        // 需要移动
        if (j < 0 || i !== increasingNewIndexSequence[j]) {
          // 当前节点移动
          // move(n2, container, anchor)
        } else {
          // 当前节点不需要移动
          j--;
        }
      }
    }
  }
}
伪代码验证:

// 旧:a b [c d e] f g
// 新:a b [d e c h] f g

// 前置比较:a b 复用
// 后置比较:f g 复用
// 进入非理想情况
// newIndexToOldIndexMap = [3, 4, 2, 0]
// increasingNewIndexSequence = [0, 1]
// 从后向前遍历: newIndexToOldIndexMap
// i = 3; newIndexToOldIndexMap[i] = 0; - 新增节点 nextChild = h; anchor = f; 将h 挂载在 f节点前
// 旧: a b [c d e] h f g
// i = 2; j = 1; increasingNewIndexSequence[j] = 1; nextChild = c; anchor = h;
// 将 c 移动到 h 前
// 旧: a b [d e c] h f g
// i = 1; j = 1; increasingNewIndexSequence[j] = 1; increasingNewIndexSequence[j] = i;
// j--; j = 0;
// i = 0; j = 0; increasingNewIndexSequence[j] = 0; increasingNewIndexSequence[j] = i;
// j--; j = -1;
// 最终 新 旧 节点达成一致
总结:
  1. 前置比较:从前向后依次比较对应新旧节点是否能复用,找出能复用的节点

  2. 后置比较:从后向前依次比较对应新旧节点是否能复用,找出能复用的节点

  3. 理想情况:

    1. 旧节点全复用,新节点有剩余 - 将剩余新节点挂载
    2. 新节点全被处理完毕,旧节点有剩余 - 将剩余旧节点卸载
  4. 非理想情况

    1. 构建keyToNewIndexMap; 方便通过旧节点的key快速找到新节点的位置

    2. 通过剩余未处理新节点数量,构建 newIndexToOldIndexMap;通过getSequence计算最长递增子序列,最长递增子序列的目的就是寻求对dom进行最小操作(通过最少次对旧dom的操作达成新旧子节点次序一致)

    3. 开启对剩余旧节点的遍历

      1. 当前旧节点有key,通过keyToNewIndexMap获取对应新节点所处位置newIndex
      2. 当前旧节点无key,在剩余新节点中依次比对找到一个满足 newIndexToOldIndexMap[j - s2] === 0 && isSameVNodeType(n1, c2[j])的新节点下标newIndex
      3. 根据 上述获取的newIndex,更新newIndexToOldIndexMap对应位置,构建剩余新旧子节点映射关系
    4. 根据是否需要移动moved = true,计算 increasingNewIndexSequence

    5. 反向遍历剩余的新节点

      1. newIndexToOldIndexMap对应位置的旧节点位置如果为0,代表当前新子节点需要mounted
      2. 否则 根据 moved = true 根据increasingNewIndexSequence中对应的下标,同当前遍历下标进行比对是否相同, 不同 则需要移动, 针对 j < 0 的情况,为increasingNewIndexSequence长度为0时(i.e. 没有最长递增子序列)此种情况
    6. 经过上述步骤 - 最终达成 新、旧子节点次序一致

-- End --