实现mini-vue -- runtime-core模块(十八)双端diff算法(下)

124 阅读8分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第26天,点击查看活动详情

本节是双端diff算法的最后一节,会讲解中间元素的移动和新增逻辑,主要是讲解移动的逻辑,在移动的逻辑完成的前提下,新增的逻辑很容易就能实现

相信大家都有刷力扣的经历,那么你肯定有刷过最长递增子序列这样的问题吧?本节实现元素移动的逻辑时就要用到这个算法!或许你会很好奇为啥它能派上用场,别着急,接着看就知道啦~

所以我们首先就来看看移动的逻辑吧

1. 元素移动

首先看一下下面这个图: 元素移动.png 如果现在左右双端已经对比完毕,指针i来到了索引2的位置,指针e1e2都来到了索引4的位置,明显中间的元素位置上发生了变化,如果让你来实现这个元素位置移动的逻辑你会怎么想呢?

2. 一个简单的移动元素思路

一个简单的想法就是不管三七二十一,直接遍历ie2,依次把DOM插入到B后面,也就是说:

  1. 先把i指向的E结点对应的vnode.el指向的DOM结点插入到B的后面
  2. 再把i + 1指向的C结点对应的DOM插入到E的后面
  3. 再把i + 2指向的D结点对应的DOM插入到C的后面

这样就完成了元素位置上的移动,但是这样简单粗暴的办法,执行了三次DOM插入操作,大家都知道到当初我们引入vnode就是为了最大程度上减少DOM操作的,可是现在这样做根本没有怎么减少DOM操作,相当于只是套了一层vnode的皮,最终仍然是以最原始的手段操作DOM

所以我们需要想一个高效一点的算法


3. 明确何时需要移动元素

我们把上面的图换一种方式展现,方便观察移动前后元素的索引变化 元素移动前后索引对比图.png 可以看到,元素移动之前,它们的索引依次数下来是2 3 4,而移动之后却变成了4 2 3

其中2 3是保持相对位置不变的,也就是C在新旧children中都是位于D的前面

4的位置却发生了变化,由原本在3的后面变成了在3的前面,这种时候就意味着需要进行移动了


4. 高效地移动元素

根据以上的分析我们可以知道,我们需要在新children中得到元素在旧children中的索引,然后根据索引的相对位置是否发生变化,或者说是否有出现这种索引本来是递增排序的,后来却有个别元素出现了不是递增排序的情况,对于这些不是递增排序的元素,就是我们的目标,我们需要移动它们

所以我们就需要借助一个最长递增子序列算法帮助我们实现,找出最长递增的子序列,让它们保持不变,而让不是递增的子序列结点元素进行移动即可,这样就能够最大程度上利用vnode的特性,减少DOM的操作次数

4.1 初始化新 children 到旧 children 的下标索引映射

那么我们至少应当先建立一个映射,这个映射用于让我们在新children中获取对应节点在旧children中的下标索引,所以应当以新children中的结点下标作为key,以对应的旧children中相应结点的key作为value,由于我们只关注新结点的中间结点(也就是双端已经对比过的就不需要处理了),所以我们在新children中的下标应当从0开始计算

说这么多有点太抽象了,直接上图! 元素移动前后索引对比图.png 就是最右边的那个映射,因为新children中我们只关注E C D这三个双端对比结束之后的剩余结点,所以我们以E开始遍历,其下标索引为0,找到它在旧children中的下标,也就是4,类似地,再把CD的对应索引也找到

找到这个索引之后,我们用肉眼能够观察到最长递增子序列为2 3,也就是说4不在最长递增子序列范围内,所以4对应的结点,也就是EE.el指向的真实DOM元素应当发生移动

搞明白了这个映射存在的意义之后,我们就来先实现这个映射吧,就叫它为newIndexToOldIndexMap(很直白的命名,vue3源码中也是这样命名的)

// 初始化 newIndexToOldIndexMap 由于只需要处理 toBePatched 个元素
// 所以使用定长数组的方式实现映射会更合适且性能更好
const newIndexToOldIndexMap = new Array(toBePatched);
// 初始化为 0 表示还未找到对应元素在原 children 中的索引
for (let i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0;

这里有两点值得注意的:

  1. 使用数组而不是Map实现映射,因为本身就只是一个下标到下标的映射,而且又是从0开始,所以用定长数组是最合适的了
  2. 映射表的元素初始化为0表示还未找到目标映射结果

4.2 完善 newIndexToOldIndexMap 映射

注意到一个问题,前面初始化的时候我们用0表示映射关系还未建立,但是如果遇到了映射后就是在下标索引0的位置怎么办呢?这里我们采取映射结果加1的方案

以上面的映射结果为例,0 1 2原本应当映射为4 2 3,但是考虑到有可能映射结果就本该是0,这与初始化的逻辑冲突了,所以我们让映射结果全都加1,也就是映射成5 3 4

if (newIndex === undefined) {
  // newIndex 不存在说明 prevChild 在新 children 中已经消失 应当移除对应元素
  hostRemove(prevChild.el);
} else {
  // 建立 newIndex 到 oldIndex 的映射
  // 减去 s2 是因为我们要让映射的下标从 0 开始计算
  newIndexToOldIndexMap[newIndex - s2] = i + 1;
  // 存在则进行打补丁 递归更新 prevChild 的 children
  // 由于不涉及新增 所以不需要传入锚点 anchor
  patch(prevChild, c2[newIndex], container, parentComponent, null);
  patched++;
}

在前面寻找newIndex的逻辑中实现映射关系的建立,注意由于我们的映射的下标索引是从0开始的,所以这里要用找到的newIndex减去s2确保从0开始建立映射,并且映射结果应为i + 1

现在映射就算建立好了,那么接下来我们就要开始使用到这个映射了


4.3 使用最长递增子序列算法找出需要移动的元素

由于我们只关注移动的逻辑,最长递增子序列算法的实现逻辑并不是我们关心的重点,这里我们直接去vue3源码中将最长递增子序列的算法代码copy过来使用即可,感兴趣的小伙伴可以自行研究~

// https://en.wikipedia.org/wiki/Longest_increasing_subsequence
function getSequence(arr: number[]): number[] {
  const p = arr.slice();
  const result = [0];
  let i, j, u, v, c;
  const len = arr.length;
  for (i = 0; i < len; i++) {
    const arrI = arr[i];
    if (arrI !== 0) {
      j = result[result.length - 1];
      if (arr[j] < arrI) {
        p[i] = j;
        result.push(i);
        continue;
      }
      u = 0;
      v = result.length - 1;
      while (u < v) {
        c = (u + v) >> 1;
        if (arr[result[c]] < arrI) {
          u = c + 1;
        } else {
          v = c;
        }
      }
      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;
}

直接把它放到renderer.ts的末尾即可,之后就可以调用这个算法获取到最长递增子序列

// 获取 newIndexToOldIndexMap 的最长递增子序列
const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap);

注意:这里得到的最长递增子序列并不是**5 3 4**中的**3 4**,而是**1 2**,因为我们需要的是递增子序列的下标,而不是它们的值,这样就可以方便我们后面进行比较

// 获取 newIndexToOldIndexMap 的最长递增子序列
const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap);
// j 用于遍历最长递增子序列
let j = increasingNewIndexSequence.length - 1;

for (let i = 0; i < toBePatched; i++) {
  if (i !== increasingNewIndexSequence[j]) {
    console.log('移动元素');
  } else {
    j--;
  }
}

也就是increasingNewIndexSequence中如果存放的是3 4的话,并不方便我们和i进行对比,因为i是从0开始遍历的,所以我们的最长递增子序列也要取到下标才方便对比


4.4 如何移动元素?

根据上面的逻辑,我们只需要在console.log('移动元素')这个代码块中处理元素的移动即可,也就是说要调用渲染器接口hostInsert,将要移动的元素插入到合适的锚点前面完成移动的逻辑

那么问题就来了,我们现在的条件能够找到这个合适的锚点吗? 元素移动的指针图.png 比如这里我们应当将E对应的真实DOM移动到C的前面,如果是现在这样正向遍历的话,我们只能够是将E插到B的后面,但是这样一来就没有利用到最长递增子序列的优势

而如果换成反向遍历,从D开始往前遍历,由于D是最长递增子序列中的元素,所以不需要对它的真实DOM进行移动,继续遍历下一个结点

遍历到结点C,由于结点C也是最长递增子序列中的一个结点,所以也不需要进行真实DOM操作

最后遍历到E,由于结点E并不在最长递增子序列中,所以需要对其进行移动,锚点很自然就是结点C,所以我们可以写出下面的代码:

// 获取 newIndexToOldIndexMap 的最长递增子序列
const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap);
// j 用于遍历最长递增子序列
let j = increasingNewIndexSequence.length - 1;

for (let i = toBePatched; i >= 0; i--) {
  const nextIndex = i + s2;
  const nextChild = c2[nextIndex];
  // 必要时才需要锚点,如果超出下标索引的话就意味着在最后插入元素
  const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : null;

  if (j < 0 || i !== increasingNewIndexSequence[j]) {
    // 当 j < 0 的时候说明最长递增子序列已经遍历完了
    // 那么接下来如果遇到要打补丁的元素都肯定是要修改位置的了

    // 将元素插入到锚点前面
    hostInsert(nextChild.el, container, anchor);
  } else {
    j--;
  }
}

OK,现在我们已经可以实现元素的移动了,通过一个案例来测试一下吧


4.5 测试

以上面示例图中的场景进行测试

// ==================== Case8: 新的比旧的多 -- 中间对比进行位置修改 ====================
const prevChildrenCase8 = [
  h('p', { key: 'A' }, 'A'),
  h('p', { key: 'B' }, 'B'),
  h('p', { key: 'C' }, 'C'),
  h('p', { key: 'D' }, 'D'),
  h('p', { key: 'E' }, 'E'),
  h('p', { key: 'F' }, 'F'),
  h('p', { key: 'G' }, 'G'),
];

const nextChildrenCase8 = [
  h('p', { key: 'A' }, 'A'),
  h('p', { key: 'B' }, 'B'),
  h('p', { key: 'E' }, 'new E'),
  h('p', { key: 'C' }, 'new C'),
  h('p', { key: 'D' }, 'new D'),
  h('p', { key: 'F' }, 'F'),
  h('p', { key: 'G' }, 'G'),
];

export const ArrayToArrayCase8 = {
  name: 'ArrayToArrayCase8',
  setup() {
    const toggleChildrenCase8 = ref(true);
    window.toggleChildrenCase8 = toggleChildrenCase8;

    return {
      toggleChildrenCase8,
    };
  },
  render() {
    return this.toggleChildrenCase8
      ? h('div', {}, prevChildrenCase8)
      : h('div', {}, nextChildrenCase8);
  },
};

变更之前是这样的: image.png 现在开始更新元素: image.png 可以看到位置移动成功!


5. 优化移动逻辑 -- 增加判断是否需要移动逻辑

是否一定要调用最长递增子序列算法来进行元素移动呢?试想一下,如果整个中间结点全都没有发生位置变更,那么我们目前的代码还是会去调用最长递增子序列算法,得到一个没有意义的递增序列数组(因为没有发生移动)

这肯定不能忍啊,都没必要移动你还要调用一个有一定时间复杂度的算法那么浪费性能么,那么如何判断何时才需要调用呢?我们只希望在元素确实发生了移动的情况下才去调用最长递增子序列算法,而没有移动的情况下则不需要管它,直接给increasingNewIndexSequence一个空数组,让后面的移动逻辑不会执行即可

5.1 使用标志变量 moved 和 maxNewIndexSoFar 提供判断逻辑

可以新增一个标志变量moved,如果需要移动的时候就将它置为true,这样一来后面进行移动逻辑的判断就可以利用这个变量了,那么问题来了,应该在哪里将moved置为true呢?

还是观察前面的那个图: 元素移动前后索引对比图.png 模拟一下寻找newIndex的过程,遍历旧children的中间结点:

  1. 遍历到C,寻找它在新children中的索引newIndex,得到3
  2. 遍历到D,寻找它在新children中的索引newIndex,得到4
  3. 遍历到E,寻找它在新children中的索引newIndex,得到2

发现规律没有?当元素发生移动,也就是这里的E元素,它的下标是会比上一次找到的最大newIndex要小的!

比如我们第一次遍历到C的时候,找到的newIndex3,就用一个变量maxNewIndexSoFar去保存它

下一次遍历到D的时候,找到的newIndex4,大于maxNewIndexSoFar,ok,说明D没有发生移动,那么就更新maxNewIndexSoFar,将其变为4

而再下一次遍历到E的时候,找到的newIndex2,比maxNewIndexSoFar小,说明发生了移动,这时候就要把moved置为true

先实现maxNewIndexSoFarmoved的更新代码

if (newIndex === undefined) {
  // newIndex 不存在说明 prevChild 在新 children 中已经消失 应当移除对应元素
  hostRemove(prevChild.el);
} else {
  // 找到了 newIndex -- 更新 maxNewIndexSoFar,用于确定 moved 的状态
  if (newIndex >= maxNewIndexSoFar) {
    maxNewIndexSoFar = newIndex;
  } else {
    // 出现了 newIndex 比已找到的最大 newIndex 小的,说明发生了移动
    moved = true;
  }
  // ...
}

再在所有newIndex找完之后,增加判断是否需要进行移动,也就是是否需要调用最长递增子序列的逻辑

// 获取 newIndexToOldIndexMap 的最长递增子序列
// 如果没有发生移动则不需要调用最长递增子序列算法
const increasingNewIndexSequence = moved
  ? getSequence(newIndexToOldIndexMap)
  : [];
// j 用于遍历最长递增子序列
let j = increasingNewIndexSequence.length - 1;

for (let i = toBePatched; i >= 0; i--) {
  const nextIndex = i + s2;
  const nextChild = c2[nextIndex];
  // 必要时才需要锚点,如果超出下标索引的话就意味着在最后插入元素
  const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : null;

  if (moved) {
    // 只有在需要移动的时候才进行移动
    if (j < 0 || i !== increasingNewIndexSequence[j]) {
      // 当 j < 0 的时候说明最长递增子序列已经遍历完了
      // 那么接下来如果遇到要打补丁的元素都肯定是要修改位置的了

      // 将元素插入到锚点前面
      hostInsert(nextChild.el, container, anchor);
    } else {
      j--;
    }
  }
}

这样就算优化完成啦


6. 实现新增元素逻辑

新增元素的逻辑特别简单,还记得吗?我们前面已经有了一个newIndexToOldIndexMap映射表,并且初始化为 0 代表新children中的下标在旧children中还没找到对应的下标,也就是还没有建立映射

那么如果等整个newIndexToOldIndexMap映射表建立完毕之后,还是发现有新children中的结点的映射结果仍然是0的话,这意味着什么?意味着它在旧children中不存在呀!那这不就刚好意味着它是新增元素吗?!

所以新增元素的实现很简单,就加一个判断语句处理即可

if (newIndexToOldIndexMap[i] === 0) {
  // newIndexToOldIndexMap 中仍然存在 0 的话意味着它是新增元素
  patch(null, nextChild, container, parentComponent, anchor);
} else if (moved) {
  // 只有在需要移动的时候才进行移动
  if (j < 0 || i !== increasingNewIndexSequence[j]) {
    // 当 j < 0 的时候说明最长递增子序列已经遍历完了
    // 那么接下来如果遇到要打补丁的元素都肯定是要修改位置的了

    // 将元素插入到锚点前面
    hostInsert(nextChild.el, container, anchor);
  } else {
    j--;
  }
}

然后就来测试一下:

// ==================== Case9: 新的比旧的多 -- 中间对比新增元素 ====================
const prevChildrenCase9 = [
  h('p', { key: 'A' }, 'A'),
  h('p', { key: 'B' }, 'B'),
  h('p', { key: 'C' }, 'C'),
  h('p', { key: 'E' }, 'E'),
  h('p', { key: 'F' }, 'F'),
  h('p', { key: 'G' }, 'G'),
];

const nextChildrenCase9 = [
  h('p', { key: 'A' }, 'A'),
  h('p', { key: 'B' }, 'B'),
  h('p', { key: 'E' }, 'new E'),
  h('p', { key: 'C' }, 'new C'),
  h('p', { key: 'D' }, 'D'),
  h('p', { key: 'F' }, 'F'),
  h('p', { key: 'G' }, 'G'),
];

export const ArrayToArrayCase9 = {
  name: 'ArrayToArrayCase9',
  setup() {
    const toggleChildrenCase9 = ref(true);
    window.toggleChildrenCase9 = toggleChildrenCase9;

    return {
      toggleChildrenCase9,
    };
  },
  render() {
    return this.toggleChildrenCase9
      ? h('div', {}, prevChildrenCase9)
      : h('div', {}, nextChildrenCase9);
  },
};

这个案例中新增了一个E结点,看看是否能够新增 image.png image.png 可以看到新增成功!至此我们的整个双端diff算法就完成啦!