Vue3源码阅读笔记-diff算法

229 阅读2分钟

diff算法

在讲diff算法之前,先创建一些需要测试的数据

// h函数,用来创建虚拟dom
function h(type, props, children, key) {
  return {
    type,
    props,
    children,
    key,
  };
}
// 测试数据// 旧子节点
const v1 = [
  h("div", null, ["a"]),
  h("div", null, ["b"]),
  h("div", null, ["c"]),
  h("div", null, ["d"]),
];
// 新子节点
const v2 = [
  h("div", null, ["d"]),
  h("div", null, ["a"]),
  h("div", null, ["b"]),
  h("div", null, ["c"]),
  h("div", null, ["e"]),
];

没有添加key时的diff算法

  • 这里我对vue的原码做了一些修改把一些参数给删除了,把一些操作改成了打印,便于理解。
// 当没有key时,会使用该方法进行diff
const patchUnkeyedChildren = (c1, c2) => {
// c1旧节点数组,c1新节点数组
  c1 = c1 || []
  c2 = c2 || []
  const oldLength = c1.length
  const newLength = c2.length
  // 获取两个数组长度较小值,作为循环结束条件
  const commonLength = Math.min(oldLength, newLength)
  let i;
  for (i = 0; i < commonLength; i++) {
  // 对象新旧数组相同下标的数组进行patch
    console.log(`patch ${JSON.stringify(c1[i]) + " " + JSON.stringify(c2[i])}`)
  }
  
  if (oldLength > newLength) {
    // 旧数组比较长
    // 移除旧数组后面的元素
    console.log(`ummount ${JSON.stringify(c1.slice(i))}`)
  } else {
    // 新数组比较长,添加新数组的节点
    console.log(`mountChildren ${JSON.stringify(c2.slice(i))}`)
  }
}
// 使用patchUnkeyedChildren该方法测试
patchUnkeyedChildren(v1, v2)
// 打印结果
patch {"type":"div","props":null,"children":["a"]} {"type":"div","props":null,"children":["d"]}
patch {"type":"div","props":null,"children":["b"]} {"type":"div","props":null,"children":["a"]}
patch {"type":"div","props":null,"children":["c"]} {"type":"div","props":null,"children":["b"]}
patch {"type":"div","props":null,"children":["d"]} {"type":"div","props":null,"children":["c"]}
mountChildren [{"type":"div","props":null,"children":["e"]}]
  • 可以看出没有使用key时,diff算法只会对下标相同的新旧节点进行patch。如果是在数组后方添加或者删除元素,那么是没有什么问题的,可是如果是移动子节点的位置,那么无法做到让相同的节点进行patch,效率比较低。

有key时的diff算法

  • 同样的我也对有key时的diff算法进行了简化
// 在diff算法中用到的,用来判断两个节点是否满足SameVNodeType
// 如果节点的type和key都相同,那么就返回true,否则返回false
function isSameVNodeType(n1, n2) {
  return n1.type === n2.type && n1.key === n2.key;
}

const patchKeyedChildren = (c1, c2) => {
  let i = 0;
  const l2 = c2.length; // 新节点数组的长度

  let e1 = c1.length - 1; // 旧节点的最后一个元素
  let e2 = l2 - 1; // 新节点的最后一个元素

  // 1. 前面的节点是相同的
  //  例如:(a b) c 变为 (a b) d e
  while (i <= e1 && i <= e2) {
    const n1 = c1[i];
    const n2 = c2[i];
    if (isSameVNodeType(n1, n2)) {
    // 如果新旧节点满足type和key都相同,则对这两个节点进行patch
      console.log(`patch ${JSON.stringify(n1) + " " + JSON.stringify(n2)}`);
    } else {
      // 如果type或者key有一个不相同,跳出while循环
      break;
    }
    // 改变i的值
    i++;
  }

  // 2.后面节点是相同的
  // 例如 a (b c) 变为 d e (b c)
  while (i <= e1 && i <= e2) {
    const n1 = c1[e1];
    const n2 = c2[e2];
    if (isSameVNodeType(n1, n2)) {
    // 如果新旧节点满足type和key都相同,则对这两个节点进行patch
      console.log(`patch ${JSON.stringify(n1) + JSON.stringify(n2)}`);
    } else {
    // 如果type或者key有一个不相同,跳出while循环
      break;
    }
    // 这里是让旧节点数组和新节点数组的最后一个元素的下标--
    e1--;
    e2--;
  }

  if (i > e1) {
    if (i <= e2) {
    // 如果只是在最后添加节点,那么会来到这里,只需要挂载新增的节点即可
      while (i <= e2) {
        console.log(`mount ${JSON.stringify(c2[i])}`);
        i++;
      }
    }
  }

  else if (i > e2) {
  // 如果只是删除后面的节点,那么会来到这里,只需要卸载不需要的节点即可。
    while (i <= e1) {
      console.log(`unmount ${JSON.stringify(c1[i])}`);
      i++;
    }
  }

  // 如果是比较复杂的修改
  // 例如从 a b [c d e] f g 变成 a b [e d c h] f g
  // 那么经过上面的操作还是无法结束diff
  else {
    const s1 = i; // 还没被处理过的旧节点数组的第一个节点下标
    const s2 = i; // 还没被处理过的新节点数组的第一个节点下标

    // 创建一个map,用来记录新数组还未被处理过的节点的 key 和 index 值
    const keyToNewIndexMap = new Map();
    for (i = s2; i <= e2; i++) {
    // 循环新数组,将 key 和 index的对应关系保存到keyToNewIndexMap中
      const nextChild = c2[i];
      if (nextChild.key != null) {
        keyToNewIndexMap.set(nextChild.key, i);
      }
    }

    let j;
    let patched = 0;   // 已经patched过的节点数
    const toBePatched = e2 - s2 + 1;  // 还剩多少个节点需要被patched
    let moved = false;  // 记录是否需要移动节点的位置,默认是不需要的。
    // maxNewIndexSoFar用来追踪是否需要move
    let maxNewIndexSoFar = 0;
    
    // 创建一个长度为需要被patch的次数,该数组保存了新数组节点和旧数组节点的关系
    // 该数组的index表示了新数组中没被处理过的节点的下标 减去s2 ,也就是说没被处理过的节点的下标 从0开始计算
    // 该数组的value表示旧数组中还没被处理过的节点的下标 加1,注意:0 表示旧数组中没有该节点
    const newIndexToOldIndexMap = new Array(toBePatched);
    // 一开始将newIndexToOldIndexMap的所有项都置为0
    for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0;
    // 遍历 旧数组 还 未被处理过的节点
    for (i = s1; i <= e1; i++) {
      const prevChild = c1[i];
      if (patched >= toBePatched) {
      // 所有新节点都被patch过了,直接unmount即可
        console.log(`unmount ${JSON.stringify(prevChild)}`);
        continue;
      }
      let newIndex;
      if (prevChild.key != null) {
      // 当前节点是有key的
      // 从根据key从map表中获取该key对应的节点在新数组的下标,保存到newIndex中
        newIndex = keyToNewIndexMap.get(prevChild.key);
      } else {
      // 当前节点没有key,这里不做过多解释
        // key-less node, try to locate a key-less node of the same type
        for (j = s2; j <= e2; j++) {
          if (
            newIndexToOldIndexMap[j - s2] === 0 &&
            isSameVNodeType(prevChild, c2[j])
          ) {
            newIndex = j;
            break;
          }
        }
      }
      if (newIndex === undefined) {
      // 如果newIndex是undefined,也就是说新数组中没有该key对应的节点,那么直接将该节点unmount即可。
        console.log(`unmount ${JSON.stringify(prevChild)}`);
      } else {// 新数组中有该key对应的节点
      // 修改newIndexToOldIndexMap数组里的值
        newIndexToOldIndexMap[newIndex - s2] = i + 1;
        if (newIndex >= maxNewIndexSoFar) {
        // 如果newIndex大于maxNewIndexSoFar,说明节点不需要进行移动
        // 因为maxNewIndexSoFar保存的是遍历过程中,新数组中出现的最大index。
        // 如果newIndex一直比maxNewIndexSoFar大,就说明节点的先后顺序是不变的,可以能中间插入了其他节点,但是总体顺序是不会变的
        // 将newIndex赋值给maxNewIndexSoFar
          maxNewIndexSoFar = newIndex;
        } else {
        // 如果newIndex > maxNewIndexSoFar,那么说明旧数组中节点的位置需要在新数组中进行移动
        // 将moved变量变为true
          moved = true;
        }
        // 对这两个key相同的节点进行patch
        console.log(
          `patch ${
            JSON.stringify(prevChild) + " " + JSON.stringify(c2[newIndex])
          }`
        );
        patched++;
      }
    }
// 遍历完旧数组之后,只需要对新数组的节点进行移动以及挂载即可
// 如果需要移动,也就是说moved为true
// 那么会newIndexToOldIndexMap数组的最长递增子序列
// 也就是说找出最长的递增子序列,然后以该序列为基础进行移动
    const increasingNewIndexSequence = moved
      ? getSequence(newIndexToOldIndexMap)
      : [];
      // j保存最长递增子序列的长度-1
    j = increasingNewIndexSequence.length - 1;
    for (i = toBePatched - 1; i >= 0; i--) {
    // 从新数组还未被patch部分的后面开始进行处理
      const nextIndex = s2 + i;
      const nextChild = c2[nextIndex];
      if (newIndexToOldIndexMap[i] === 0) {
        // 如果旧数组中没有该节点,mount
        console.log(`mount ${JSON.stringify(nextChild)}`);
      } else if (moved) {
      // moved为true
      // 如果 j < 0 或者 i 不等于 increasingNewIndexSequence[j],则需要进行移动
        if (j < 0 || i !== increasingNewIndexSequence[j]) {
          console.log(`move ${JSON.stringify(nextChild)}`);
        } else {
        // j--
          j--;
        }
      }
    }
  }
};
// 测试有key的diff算法
// 数据
const v1 = [
  h("div", null, ["a"], 'a'),
  h("div", null, ["b"], 'b'),
  h("div", null, ["c"], 'c'),
  h("div", null, ["d"], 'd'),
  h("div", null, ["e"], 'e'),
  h("div", null, ["f"], 'f'),
  h("div", null, ["g"], 'g'),
];

const v2 = [
  h("div", null, ["a"], 'a'),
  h("div", null, ["b"], 'b'),
  h("div", null, ["e"], 'e'),
  h("div", null, ["d"], 'd'),
  h("div", null, ["c"], 'h'),
  h("div", null, ["f"], 'f'),
  h("div", null, ["g"], 'g'),
];
patchKeyedChildren(v1, v2)
// 打印结果
patch {"type":"div","props":null,"children":["a"],"key":"a"} {"type":"div","props":null,"children":["a"],"key":"a"}
patch {"type":"div","props":null,"children":["b"],"key":"b"} {"type":"div","props":null,"children":["b"],"key":"b"}
patch {"type":"div","props":null,"children":["g"],"key":"g"} {"type":"div","props":null,"children":["g"],"key":"g"}
patch {"type":"div","props":null,"children":["f"],"key":"f"} {"type":"div","props":null,"children":["f"],"key":"f"}
unmount {"type":"div","props":null,"children":["c"],"key":"c"}
patch {"type":"div","props":null,"children":["d"],"key":"d"} {"type":"div","props":null,"children":["d"],"key":"d"}
patch {"type":"div","props":null,"children":["e"],"key":"e"} {"type":"div","props":null,"children":["e"],"key":"e"}
mount {"type":"div","props":null,"children":["c"],"key":"h"}
move {"type":"div","props":null,"children":["e"],"key":"e"}

了解了diff算法的大致思路,也就能明白为什么不推荐使用index作为key了

  • 使用index作为key,由于index是会变的只要下标一样,那么key也就一样,因为不管怎么样新旧数组长度相同的范围内index总是一致的,也就是说key是一样的,那么就会导致加key和没加key的效果是一样的

其他vue3源码阅读笔记

Vue.createApp

app.mount的过程