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

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

上一章我们实现了双端对比的逻辑,完成了左端和右端的对比,经过双端对比的筛选后,我们就得到了新旧children中间的差异部分,接下来就是算法的核心,如何处理这些差异部分?

更新逻辑无非就是增加、删除和修改以及位置的变更,本节我们先来实现删除和结点内容的更新修改,下一节再来实现增加以及位置变更的逻辑

中间对比

中间对比主要涉及元素的删除、修改以及位置变化 中间元素涉及删除和修改以及位置变化.png

删除和更新元素

原理

以上图为例,我们的思路是这样的,经过前面的左端和右端对比逻辑处理之后,现在i === 2e1 ===4e2 === 4 只要在中间部分中,找出新children中不存在于旧children中的元素,就可以将其删除,如果找到了则调用patch去递归地更新结点

那么如何在新的children数组中判断一个vnode是否存在于旧children数组呢?如果直接暴力使用双层for循环遍历,那复杂度就比较高了

别忘了我们的结点是有key的,我们可以先遍历新children数组,建立一个映射表,以结点的key作为key,索引作为value的一个映射表

建立起映射表后,我们再去遍历旧children数组,判断每一个元素是否能够在映射表中查找到,这个查找操作的时间复杂度是O(1)的,从而能够让复杂度降为O(n)

  • 如果能够找到,则将对应的索引存在newIndex数组中,表示同一个结点在新children数组中的位置,比如上图中旧children数组中有结点c,新children中也有它,那么newIndex的值就为 4,由于newIndex存在,所以会对该节点调用patch函数,对它的children进行递归地更新
  • 又比如结点d在旧children数组中存在,但是在新children数组中不存在,所以newIndex === undefined,那么就意味着要将结点d对应的元素移除,调用hostRemove函数实现

实现的代码如下:

// 右端对比
while (i <= e1 && i <= e2) {
  // ...
}

if (i > e1) {
  // 新的比旧的多 -- 创建结点
  if (i <= e2) {
    // 确定插入位置
    const nextPos = e2 + 1;
    // 确定锚点 -- 在锚点之前插入新增结点
    const anchor = nextPos < c2.length ? c2[nextPos].el : null;
    while (i <= e2) {
      patch(null, c2[i], container, parentComponent, anchor);
      i++;
    }
  }
} else if (i > e2) {
  // 旧的比新的多 -- 删除结点
  while (i <= e1) {
    hostRemove(c1[i].el);
    i++;
  }
} else {
  // 中间对比
  // s1 和 s2 指向新旧 children 左端第一个不相同位置
  let s1 = i;
  let s2 = i;

  // 遍历新 children 建立 key 到 index 的映射
  // 便于在旧 children 中进行查找
  const keyToNewIndexMap = new Map();
  for (let i = s2; i <= e2; i++) {
    const nextChild = c2[i];
    keyToNewIndexMap.set(nextChild.key, i);
  }

  // 遍历旧 children 判断结点是否也在新 children 中
  // 在的话就找出在新 children 中的索引 -- newIndex
  for (let i = s1; i < e1; i++) {
    const prevChild = c1[i];

    let newIndex;
    // prevChild.key != null 包括了对 null 和 undefined 的判断
    if (prevChild.key != null) {
      newIndex = keyToNewIndexMap.get(prevChild.key);
    } else {
      // 用户未给结点设置 key 属性 -- 通过 isSameVNodeType 判断结点是否相同
      for (let j = s2; j <= e2; j++) {
        if (isSameVNodeType(prevChild, c2[j])) {
          newIndex = j;
          break;
        }
      }
    }

    if (newIndex === undefined) {
      // newIndex 不存在说明 prevChild 在新 children 中已经消失 应当移除对应元素
      hostRemove(prevChild.el);
    } else {
      // 存在则进行打补丁 递归更新 prevChild 的 children
      // 由于不涉及新增 所以不需要传入锚点 anchor
      patch(prevChild, c2[newIndex], container, parentComponent, null);
    }
  }
}

测试

测试一下是否可以删除和更新元素,创建一个和上图情况相同的环境进行测试

// ==================== Case7: 新的比旧的多 -- 中间对比进行删除和更新 ====================
const prevChildrenCase7 = [
  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 nextChildrenCase7 = [
  h('p', { key: 'A' }, 'A'),
  h('p', { key: 'B' }, 'B'),
  h('p', { key: 'E' }, 'new E'),
  h('p', { key: 'H' }, 'H'),
  h('p', { key: 'C' }, 'new C'),
  h('p', { key: 'F' }, 'F'),
  h('p', { key: 'G' }, 'G'),
];

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

    return {
      toggleChildrenCase7,
    };
  },
  render() {
    return this.toggleChildrenCase7
      ? h('div', {}, prevChildrenCase7)
      : h('div', {}, nextChildrenCase7);
  },
};

更新前的children image.png 更新后的children image.png 可以看到确实是进行了删除和更新操作,D元素被删除了,而C变成了new CE也变成了new E 由于我们还没有实现添加和移动元素的逻辑,所以这里的结果并不完全和前面案例图中的结果一致,但是没关系,至少目前需要实现的功能已经实现了


优化删除逻辑

再看看下面这个场景 删除逻辑的优化.png 不难发现,其实在遍历旧children的时候,如果发现当前要打补丁的结点数量已经超过了新节点中应当打补丁的数量的时候,就可以把后面的结点删除了,没必要再递归给它们打补丁 比如这里我们应当给D C这两个结点打补丁,那么在遍历旧children,查询keyToNewIndex表的时候就可以判断一下当前已经给几个结点打了补丁,如果已经超过了 2 个,那么没必要继续给剩下的旧children打补丁了,直接把它们移除即可,因为打补丁是一个递归操作,进行多次没必要的递归调用就太没必要了

那么这个优化逻辑怎么实现呢?我们可以先根据s2 e2这两个指针来得出应当给几个结点打补丁,比如上图中双端对比完毕之后,s2 === 2e2 === 3,需要给e2 - s2 + 1 === 2个结点,也就是D C结点打补丁,这个计数就存放到一个名为toBePatched的变量中 而遍历旧结点的时候,维护一个计数器变量patched,每打一个补丁,就让计数器加一,当计数器大于等于toBePatched的时候就没必要再进行遍历了

// 中间对比
// s1 和 s2 指向新旧 children 左端第一个不相同位置
let s1 = i;
let s2 = i;
+ // 统计已打补丁的节点数
+ let patched = 0;
+ // 约束最多能给几个结点打补丁
+ const toBePatched = e2 - s2 + 1;

// 遍历新 children 建立 key 到 index 的映射
// 便于在旧 children 中进行查找
const keyToNewIndexMap = new Map();
for (let i = s2; i <= e2; i++) {
  const nextChild = c2[i];
  keyToNewIndexMap.set(nextChild.key, i);
}

// 遍历旧 children 判断结点是否也在新 children 中
// 在的话就找出在新 children 中的索引 -- newIndex
for (let i = s1; i <= e1; i++) {
  const prevChild = c1[i];

+  // base case: 判断 patched 是否已经达到最大需要打补丁数量 是的话后续结点直接移除,不需要打补丁
+  if (patched >= toBePatched) {
+    hostRemove(prevChild.el);
+    // 后续的打补丁操作不用继续了 直接进入下一层循环将后续旧结点删除
+    continue;
+  }

  let newIndex;
  // prevChild.key != null 包括了对 null 和 undefined 的判断
  if (prevChild.key != null) {
    newIndex = keyToNewIndexMap.get(prevChild.key);
  } else {
    // 用户未给结点设置 key 属性 -- 通过 isSameVNodeType 判断结点是否相同
    for (let j = s2; j < e2; j++) {
      if (isSameVNodeType(prevChild, c2[j])) {
        newIndex = j;
        break;
      }
    }
  }

  if (newIndex === undefined) {
    // newIndex 不存在说明 prevChild 在新 children 中已经消失 应当移除对应元素
    hostRemove(prevChild.el);
  } else {
    // 存在则进行打补丁 递归更新 prevChild 的 children
    // 由于不涉及新增 所以不需要传入锚点 anchor
    patch(prevChild, c2[newIndex], container, parentComponent, null);
+    patched++;
  }
}

检验优化效果

看看优化过后是否有影响到已实现的删除逻辑,以上图这个情况设置一个测试场景 image.png 然后进行更新 image.png 可以看到仍然没问题,那么我们是否真的优化了呢?打个断点进入调试模式看看吧

} else {
  // 存在则进行打补丁 递归更新 prevChild 的 children
  // 由于不涉及新增 所以不需要传入锚点 anchor
  patch(prevChild, c2[newIndex], container, parentComponent, null);
+ debugger;
  patched++;
}

image.png 可以看到toBePatched确实和预想的一样是 2,并且当前的patched是 0,原来的children中,顺序是C D,新的children中顺序是D C,发生了位置交换,并且元素的内容更新了,所以来到打补丁这里,则说明当前正遍历到旧children中发生了位置交换的第一个元素C 那么patch之后就应当让patched++

下一次循环先进入base case判断一下是否需要继续打补丁 image.png 因为patched <= toBePatched,所以不会直接移除后续结点,仍然会继续判断是否需要打补丁(当前遍历的旧children结点在新children中仍然存在时就需要打补丁对其进行更新),所以这个判断并不会进去 image.png 当前遍历的结点是旧children中的D,其key在新children中存在,所以会进行打补丁操作,可以看到打完补丁后D变成了new D(位置没和C交换是因为还没实现交换逻辑,下一章会讲解) 之后又会增加patched计数器 image.png 这次再进来的时候,我们的优化逻辑就起作用了,对于旧children来说,还有一个结点E没有遍历,但是由于我们已经达到了最大打补丁数了,所以直接将它删了就行,没必要再对其递归打补丁了 image.png 可以看到成功进入了if语句,调用hostRemove将结点E引用的DOM删除

至此我们的diff算法的中间对比逻辑的删除和更新功能就完成了,下一章我们会讲解如何处理元素的移动问题

猜你喜欢

转载自juejin.im/post/7112820783503638558