Vue3源码学习6——Diff算法

237 阅读13分钟

在之前的render渲染器实现中,当新旧节点都是数组的时候,如果想提升更新的性能,那就需要对新旧节点做对比,这个对比方法就是Diff算法

前置知识

key属性

在判断VNode是否相同的isSameVNodeType方法中,除了对比节点的type,还对比了key这个属性

function isSameVNodeType(oldVNode: VNode, newVNode: VNode) {
  return oldVNode.type === newVNode.type && oldVNode.key === newVNode.key;
}

我们知道,在使用Vue做列表渲染的时候,会给节点添加一个key属性,这个属性就是用来标记VNode唯一性

const vnode = h("li", { key: 1 }, "a");

源码中需要在创建VNode的时候添加这个key属性,来自于props

function createBaseVNode(type, props, children, shapeFlag) {
  const vnode = {
    ......
    key: props?.key || null,
  } as VNode;

  ......
}

最长递增子序列

即:一个数组中可以找到的最长的递增序列,这个序列在原数组中不要求连续

const arr = [1, 3, 2, 4, 5, 6]

// 递增子序列包括[1, 2, 4, 5, 6] [1, 3, 4, 5, 6]等等
// 最长递增子序列为[1, 3, 4, 5, 6](不唯一)

意义

最长递增子序列的意义在于:找到一个最长的“不变序列”,其他的新节点围绕这个“不变序列”做移动即可,保证了我们移动次数的最小化

求解算法

最长递增子序列的求解算法用到了贪心算法二分查找

需要注意的是,这个算法最后获取到的是最长递增子序列的下标,而非原数组

最长递增子序列求解逻辑.png

/**
 * 1.先拿到当前元素
 * 2.看当前元素是否比之前结果的最后一个大
 * 2.1 是,存储
 * 2.2 不是,用当前的替换刚才的(用二分查找实现)
 */
// 获取最长递增子序列的下标
function getSequence(arr: number[]): number[] {
  // 生成arr的浅拷贝
  const p = arr.slice();
  // 最长递增子序列下标
  const result = [0]; // 暂时把第一项存入最后结果
  let i, j, u, v, c;
  for (i = 0; i < arr.length; i++) {
    // 拿到每一个元素
    const arrI = arr[i];
    // 这里不为零是因为会不停改变数组的值,0表示的是下标,不具备比较的意义
    if (arrI !== 0) {
      // j是目前拿到的最长递增子序列最后一项的值(即原数组中下标)
      j = result[result.length - 1];
      // 如果result中最后一项比当前元素值小,则应该把当前值存起来
      if (arr[j] < arrI) {
        // result变化前,记录result更新前最后一个索引的值是多少
        p[i] = j;
        result.push(i);
        continue;
      }
      // 针对result开始二分查找,目的是找到需要变更的result的下标
      u = 0;
      v = result.length - 1;
      while (u < v) {
        // 平分,向下取整,拿到对比位置的中间位置(例如0和1拿到0)
        c = (u + v) >> 1;
        // 看当前中间位的arr值是否小于当前值,是的话,向右侧继续去比对
        if (arr[result[c]] < arrI) {
          u = c + 1;
        }
        // 如果不是,右侧缩窄到中间位置,再去做二分(说明右侧数字都比arrI大)
        else {
          v = c;
        }
      }
      // 在result中,用更大值的下标,替换原来较小值的下标
      if (arrI < arr[result[u]]) {
        if (u > 0) {
          p[i] = result[u - 1];
        }
        result[u] = i;
      }
    }
  }
  // 回溯
  u = result.length;
  v = result[u - 1];
  while (u-- > 0) {
    result[u] = v;
    v = p[v];
  }
  return result;
}

Diff算法

Diff算法在源码中用的是patchKeyedChildren方法,和其他的patch方法类似,也接收四个参数:新节点旧节点容器锚点

const patchChildren = (oldVNode, newVNode, container, anchor) => {
  ......
  if (newShapeFlag & ShapeFlags.TEXT_CHILDREN) {
    ......
  }
  else {
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      if (newShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        // diff计算
        patchKeyedChildren(c1, c2, container, anchor);
      }
      ......
    }
    else {
      ......
    }
  }
};

Diff算法的对比方法

阅读源码可以知道,Diff算法大致分了3种对比方式,从中细分为5种,包括:

  • 新旧节点数量一致
    • 自前向后比对
    • 自后向前比对
  • 新旧节点数量不一致
    • 新节点多于旧节点比对
    • 旧节点多于新节点比对
  • 乱序比对

节点数量一致时的对比方法

细分为自前向后比对自后向前比对

两者的目的在于从头尾两侧发现相同节点,执行打补丁操作,剩下不一样的节点后面再处理

自前向后比对

自前向后比对.png

const vnode = h("ul", [
  h("li", { key: 1 }, "a"),
  h("li", { key: 2 }, "b"),
  h("li", { key: 3 }, "c"),
]);

render(vnode, document.querySelector("#app"));

setTimeout(() => {
  const vnode2 = h("ul", [
    h("li", { key: 1 }, "a"),
    h("li", { key: 2 }, "b"),
    h("li", { key: 3 }, "d"),
  ]);

  render(vnode2, document.querySelector("#app"));
}, 2000);

对比的逻辑简单,我们从前往后去拿到新旧子节点,如果是同一个节点(typekey相同),则打补丁,如果不同,则跳出循环不再比对

const patchKeyedChildren = (
  oldChildren,
  newChildren,
  container,
  parentAnchor
) => {
  let i = 0;
  const newChildrenLength = newChildren.length;

  let oldChildrenEnd = oldChildren.length - 1;
  let newChildrenEnd = newChildrenLength - 1;

  // 场景1: 自前向后
  while (i <= oldChildrenEnd && i <= newChildrenEnd) {
    const oldVNode = oldChildren[i];
    const newVNode = normalizeVNode(newChildren[i]);
    if (isSameVNodeType(oldVNode, newVNode)) {
      patch(oldVNode, newVNode, container, null);
    } else {
      break;
    }
    i++;
  }
};
自后向前比对

自后向前比对.png

下面的示例之所以对第一个节点的key修改,是为了跳出第一个自前向后的比对,进入自后向前比对中

const vnode = h("ul", [
  h("li", { key: 4 }, "a"),
  h("li", { key: 2 }, "b"),
  h("li", { key: 3 }, "c"),
]);

render(vnode, document.querySelector("#app"));

setTimeout(() => {
  const vnode2 = h("ul", [
    h("li", { key: 1 }, "a"),
    h("li", { key: 2 }, "b"),
    h("li", { key: 3 }, "d"),
  ]);

  render(vnode2, document.querySelector("#app"));
}, 2000);

自后向前比对的逻辑也类似,即从后往前拿到新旧子节点,如果是同一个节点(typekey相同),则打补丁,否则跳出循环不再比对

const patchKeyedChildren = (
  oldChildren,
  newChildren,
  container,
  parentAnchor
) => {
  let i = 0;
  const newChildrenLength = newChildren.length;

  let oldChildrenEnd = oldChildren.length - 1;
  let newChildrenEnd = newChildrenLength - 1;

  ......

  // 场景2: 自后向前
  while (i <= oldChildrenEnd && i <= newChildrenEnd) {
    const oldVNode = oldChildren[oldChildrenEnd];
    const newVNode = normalizeVNode(newChildren[newChildrenEnd]);
    if (isSameVNodeType(oldVNode, newVNode)) {
      patch(oldVNode, newVNode, container, null);
    } else {
      break;
    }
    oldChildrenEnd--;
    newChildrenEnd--;
  }
};

节点数量不一致时的对比方法

新节点多于旧节点

这里的多可能在前,也可能在后

新节点多于旧节点.png

const vnode1 = h("ul", [
  h("li", { key: 1 }, "a"),
  h("li", { key: 2 }, "b"),
]);

render(vnode1, document.querySelector("#app"));

// 新节点在后面
setTimeout(() => {
  const vnode2 = h("ul", [
    h("li", { key: 1 }, "a"),
    h("li", { key: 2 }, "b"),
    h("li", { key: 3 }, "c"),
  ]);

  render(vnode2, document.querySelector("#app"));
}, 2000);

// 新节点在前面
setTimeout(() => {
  const vnode3 = h("ul", [
    h("li", { key: 3 }, "c"),
    h("li", { key: 1 }, "a"),
    h("li", { key: 2 }, "b"),
  ]);

  render(vnode3, document.querySelector("#app"));
}, 2000);

我们知道,按照前面的从前到后/从后到前比对,原来一致的节点能够正常渲染,现在唯一要处理的就是这个多出来的节点,难点只是要知道这个节点插入在哪里

在源码中,新节点多于旧节点的逻辑是在自前向后/自后向前逻辑之后的,也就是说,这里会把首尾已经验证一致的节点渲染好,剩下的就是找到新增节点的位置即可

新节点多于旧节点-在后.png

新节点多于旧节点-在前.png

const patchKeyedChildren = (
  oldChildren,
  newChildren,
  container,
  parentAnchor
) => {
  let i = 0;
  const newChildrenLength = newChildren.length;

  let oldChildrenEnd = oldChildren.length - 1;
  let newChildrenEnd = newChildrenLength - 1;

  ......

  // 场景3:新节点多于旧节点
  // i移动到了最后一个位置,如果两条都通过,说明旧节点数量少于新节点
  if (i > oldChildrenEnd) {
    if (i <= newChildrenEnd) {
      const nextPos = newChildrenEnd + 1;
      // 下一个插入的位置
      // 1. 插入在后,则新节点的末尾下标+1 >= 新节点数量,插入位置应该是父节点anchor(最后一个)
      // 2. 插入在前,因为场景2,新节点末尾下标挪到0了,所以新节点末尾下标+1 < 新节点数量,插入的位置就应该是新节点末尾下标+1这个地方之前
      const anchor = nextPos < newChildrenLength ? newChildren[nextPos].el : parentAnchor;
      while (i <= newChildrenEnd) {
        patch(null, normalizeVNode(newChildren[i]), container, anchor);
        i++;
      }
    }
  }
};
旧节点多于新节点

这里的多,同样可能在前,也可能在后

旧节点多于新节点.png

const vnode1 = h("ul", [
  h("li", { key: 1 }, "a"),
  h("li", { key: 2 }, "b"),
  h("li", { key: 3 }, "c"),
]);

render(vnode1, document.querySelector("#app"));

// 删除的是最后一个节点
setTimeout(() => {
  const vnode2 = h("ul", [
    h("li", { key: 1 }, "a"),
    h("li", { key: 2 }, "b"),
  ]);

  render(vnode2, document.querySelector("#app"));
}, 2000);

// 删除的是第一个节点
setTimeout(() => {
  const vnode3 = h("ul", [
    h("li", { key: 2 }, "b"),
    h("li", { key: 3 }, "c"),
  ]);

  render(vnode3, document.querySelector("#app"));
}, 2000);

同样,按照前面的从前到后/从后到前比对,原来一致的节点能够正常渲染,现在要新处理的只是把这些不用的节点卸载,核心是找对卸载的节点的位置

旧节点多于新节点-在后.png

旧节点多于新节点-在前.png

const patchKeyedChildren = (
  oldChildren,
  newChildren,
  container,
  parentAnchor
) => {
  let i = 0;
  const newChildrenLength = newChildren.length;

  let oldChildrenEnd = oldChildren.length - 1;
  let newChildrenEnd = newChildrenLength - 1;

  ......

  // 场景4:旧节点多于新节点
  else if (i > newChildrenEnd) {
    while (i <= oldChildrenEnd) {
      unmount(oldChildren[i]);
      i++;
    }
  }
};

乱序比对

实际开发中,以上两大种情况并不能真实涵盖所有场景,例如下面的这个例子

乱序更新.png

可以发现,开头和结尾的两个节点是更新,2和3是交换,C4删除了,C6新增了,这种情况下之前四种方法不够用了

但是如果先按照之前四种方法做对比,我们最后可以得到这样的结果

前四个场景处理后.png

所以最后要对剩下的节点做四种处理,这其实也是通用的节点处理,包括:

  • 更新
  • 交换
  • 挂载
  • 删除

这些处理我们可以分步骤进行,阅读源码可以发现,整个处理过程包括

  • 新节点key和下标的映射创建
    • 为了后续知道哪些节点还在,好给旧节点打补丁/删除
    • 为了知道哪些节点发生了移动/挂载,好给新节点做挂载/移动
  • 旧节点的打补丁/删除
  • 新节点的挂载/移动位置
新节点的key和下标映射

这一步就是遍历新节点,创建好map映射

新节点key和下标映射创建.png

const oldStartIndex = i;
const newStartIndex = i;
// 第一部分:创建新节点的key->index的map映射
const keyToNewIndexMap = new Map();
for (i = newStartIndex; i <= newChildrenEnd; i++) {
  const nextChild = normalizeVNode(newChildren[i]);
  if (nextChild.key != null) {
    keyToNewIndexMap.set(nextChild.key, i);
  }
}
旧节点的打补丁&删除

这一步对旧节点遍历,使用了刚才创建好的新节点key和下标映射,把相同key的新旧节点关联起来(注意这里会把已经打补丁完成的节点给忽略掉)

新旧节点创建关联.png

  1. 去看哪些旧节点还存在,存在的话打补丁
  2. 不存在的旧节点,删除

这一步处理完成后,节点完成更新/卸载操作

第二步处理后的旧节点.png

// 第二部分:循环旧节点,完成打补丁/删除(不移动)
let j;
let patched = 0; // 已经打补丁的数量
const toBePatched = newChildrenEnd - newStartIndex + 1; // 需要打补丁的数量
let moved = false; // 标记当前节点是否需要移动
let maxNewIndexSoFar = 0; // 配合moved使用,保存当前最大新节点的index

// 新节点下标到旧节点下标的map(实际使用数组,是为了后续的最长递增子序列的求解)
// 这个数组的下标是新节点的下标,每个下标的值是旧节点的对应key的元素的index+1
// 例如新节点0对应的旧节点是在1,则记录为[2]
const newIndexToOldIndexMap = new Array(toBePatched);

// 给这个map添加占位赋值0
for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0;

// 循环旧节点
for (i = oldStartIndex; i <= oldChildrenEnd; i++) {
  const prevChild = oldChildren[i];
  // 如果已经打补丁的数量超过了需要打补丁的数量,开始卸载
  if (patched >= toBePatched) {
    unmount(prevChild);
    continue;
  }
  // 新节点要存放的位置
  let newIndex;
  if (prevChild.key != null) {
    // 从之前新节点key->index的map中拿到新节点位置(注意这个位置是包括了已处理节点的)
    newIndex = keyToNewIndexMap.get(prevChild.key);
  }
  // !这里源码里面有个else,是处理那些没有key的节点的

  // 没找到新节点索引,说明旧节点应该删除了
  if (newIndex === undefined) {
    unmount(prevChild);
  }
  // 找到新节点索引,应该打补丁(先不考虑移动的事情)
  else {
    newIndexToOldIndexMap[newIndex - newStartIndex] = i + 1;
    // 新节点index和当前最大新节点index比较,如果不比它大,则应该触发移动
    if (newIndex >= maxNewIndexSoFar) {
      maxNewIndexSoFar = newIndex;
    } else {
      moved = true;
    }
    patch(prevChild, newChildren[newIndex], container, null);
    patched++;
  }
}
新节点的挂载&移动

移动就需要获取最长递增子序列了,如果旧节点中没有对应的key,则直接挂载

新节点挂载&移动逻辑.png

// 第三部分:移动和挂载
// 拿到newIndex到oldIndex这个映射数组的最长递增子序列
const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : [];
j = increasingNewIndexSequence.length - 1;

// 循环倒序,把需要patch的节点做一遍处理
for (i = toBePatched - 1; i >= 0; i--) {
  // 拿到新节点
  const nextIndex = newStartIndex + i;
  const nextChild = newChildren[nextIndex];
  // 类似场景四,做插入处理
  const anchor = nextIndex + 1 < newChildrenLength ? newChildren[nextIndex + 1].el : parentAnchor;
  
  // 新节点没有找到旧节点,插入
  if (newIndexToOldIndexMap[i] === 0) {
    patch(null, nextChild, container, anchor);
  }
  // 如果需要移动,根据最长递增子序列做处理
  else if (moved) {
    // 如果不存在最长递增子序列/当前index不是最长递增子序列的最后一个元素,做移动
    if (j < 0 || i !== increasingNewIndexSequence[j]) {
      move(nextChild, container, anchor);
    } else {
      j--;
    }
  }
}
总结

归纳上面三个步骤,最后的乱序比对代码整理如下

const patchKeyedChildren = (
  oldChildren,
  newChildren,
  container,
  parentAnchor
) => {
  let i = 0;
  const newChildrenLength = newChildren.length;

  let oldChildrenEnd = oldChildren.length - 1;
  let newChildrenEnd = newChildrenLength - 1;

  ......

  // 场景5:乱序比对
  else {
    const oldStartIndex = i;
    const newStartIndex = i;
    
    // 第一部分:创建新节点的key->index的map映射
    const keyToNewIndexMap = new Map();
    for (i = newStartIndex; i <= newChildrenEnd; i++) {
      const nextChild = normalizeVNode(newChildren[i]);
      if (nextChild.key != null) {
        keyToNewIndexMap.set(nextChild.key, i);
      }
    }
    
    // 第二部分:循环旧节点,完成打补丁/删除(不移动)
    let j;
    let patched = 0; // 已经打补丁的数量
    const toBePatched = newChildrenEnd - newStartIndex + 1; // 需要打补丁的数量
    let moved = false; // 标记当前节点是否需要移动
    let maxNewIndexSoFar = 0; // 配合moved使用,保存当前最大新节点的index

    // 新节点下标到旧节点下标的map(实际使用数组,是为了后续的最长递增子序列的求解)
    // 这个数组的下标是新节点的下标,每个下标的值是旧节点的对应key的元素的index+1
    // 例如新节点0对应的旧节点是在1,则记录为[2]
    const newIndexToOldIndexMap = new Array(toBePatched);

    // 给这个map添加占位赋值0
    for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0;

    // 循环旧节点
    for (i = oldStartIndex; i <= oldChildrenEnd; i++) {
      const prevChild = oldChildren[i];
      // 如果已经打补丁的数量超过了需要打补丁的数量,开始卸载
      if (patched >= toBePatched) {
        unmount(prevChild);
        continue;
      }
      // 新节点要存放的位置
      let newIndex;
      if (prevChild.key != null) {
        // 从之前新节点key->index的map中拿到新节点位置(注意这个位置是包括了已处理节点的)
        newIndex = keyToNewIndexMap.get(prevChild.key);
      }
      // !这里源码里面有个else,是处理那些没有key的节点的

      // 没找到新节点索引,说明旧节点应该删除了
      if (newIndex === undefined) {
        unmount(prevChild);
      }
      // 找到新节点索引,应该打补丁(先不考虑移动的事情)
      else {
        newIndexToOldIndexMap[newIndex - newStartIndex] = i + 1;
        // 新节点index和当前最大新节点index比较,如果不比它大,则应该触发移动
        if (newIndex >= maxNewIndexSoFar) {
          maxNewIndexSoFar = newIndex;
        } else {
          moved = true;
        }
        patch(prevChild, newChildren[newIndex], container, null);
        patched++;
      }
    }
    
    // 第三部分:移动和挂载
    // 拿到newIndex到oldIndex这个映射数组的最长递增子序列
    const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : [];
    j = increasingNewIndexSequence.length - 1;

    // 循环倒序,把需要patch的节点做一遍处理
    for (i = toBePatched - 1; i >= 0; i--) {
      // 拿到新节点
      const nextIndex = newStartIndex + i;
      const nextChild = newChildren[nextIndex];
      // 类似场景四,做插入处理
      const anchor = nextIndex + 1 < newChildrenLength ? newChildren[nextIndex + 1].el : parentAnchor;
  
      // 新节点没有找到旧节点,插入
      if (newIndexToOldIndexMap[i] === 0) {
        patch(null, nextChild, container, anchor);
      }
      // 如果需要移动,根据最长递增子序列做处理
      else if (moved) {
        // 如果不存在最长递增子序列/当前index不是最长递增子序列的最后一个元素,做移动
        if (j < 0 || i !== increasingNewIndexSequence[j]) {
          move(nextChild, container, anchor);
        } else {
          j--;
        }
      }
    }
  }
};