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

539 阅读8分钟

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

前面我们实现了children的三种更新情况,分别是:

  1. array变成text
  2. text变成array
  3. text变成text

这三种情况都是比较好处理的,但是还有一种情况,就是array变成array

这种情况就有讲究了,要用到diff算法

如果不使用diff算法,那么一个简单的实现思路就是遍历更新前的vnodechildren中的所有孩子,每遍历一个就要在更新后的vnode.children中看看是否有变化,比如是否位置变化了,是否内容变化了,是否新增了,是否删除了等等,这样的话最终的时间复杂度将会是O(n^2),是非常低效,几乎不可用的算法

而今天要讲的diff算法 -- 双端diff算法,能够把时间复杂度降低到O(n),大大提高了运行效率 首先介绍一下双端diff算法的原理

1. 双端 diff 算法

双端diff算法,顾名思义,就是从vnode.children的两端去进行对比,将左右两侧相同的vnode排除,不需要发生变化,只要找出中间发生了变化的vnode 因为很多时候我们对DOM的更新只是对中间部分的更新,对两端的更新的情况是相对少一些的,如果能够利用这个特点对其作出优化,那就能够一定程度上提高我们的children的更新效率 双端diff算法原理.png 所以核心就是找出新旧children的中间的发生了变化的部分,那么这部分要怎么找呢?

2. 各种情况

首先是两种基本情况,从左端对比和从右端对比

2.1 左端对比

左端对比就是需要我们找出新旧children中从左端开始的第一个不同的位置 左端对比.png


2.2 右端对比

类似地,右端对比就是找出右端第一个不同的位置 右端对比.png 接下来还有四种简单情况

  • 新的比旧的长的时候代表要新增结点
  • 旧的比新的长的时候代表要删除结点

由于分为左端和右端对比, 所以有如下四种情况


2.3 新的比旧的长 -- 右端创建

当左端对比找到第一个不同的结点时,发现新的结点数比旧的多,并且左端都保持一样,说明是要在右端创建新的结点 新的比旧的长--右端创建.png


2.4 新的比旧的长 -- 左端创建

类似地,当从右端对比找到第一个不同结点的时候,发现新的比旧的长,说明要在左端创建新的结点 新的比旧的长--左端创建新结点.png


2.5 旧的比新的长 -- 右端删除

类似地,右端对比结束后,发现旧的比新的长,说明要在右端删除结点 旧的比新的长--右端删除结点.png


2.6 旧的比新的长 -- 左端删除

当左端对比结束后发现旧的比新的长,说明需要在左端删除结点

旧的比新的长--左端删除结点.png


2.7 双端一样,处理中间差异

以上几种情况都是比较简单的情况,其实最复杂的情况也无非就是两端一样,然后中间有不同的,并且不同的结点涉及新增、删除和修改 双端diff算法原理.png


3. 实现左端对比

首先我们根据前面描述的第一种左端对比的情况,设计一个相应的demo,然后来实现左端对比,并通过demo进行验证 由于我们要从左端开始对比,所以我们可以用一个指针i从新旧children的左端同时开始对比,一旦遇到不一样的就说明左端对比结束 为了避免指针越过新旧children数组的索引,我们还需要有两个e1e2指针,分别指向旧结点的末尾和新结点的末尾,并且严格控制i不能超过e1e2

3.1 demo 场景

创建一个和之前描述图中一样的左端对比场景,旧结点为A、B、C,新节点为A、B、D、E 如果对比成功的话:

  • i应当停在索引为2的地方
  • e1索引为2
  • e2索引为3
// ==================== Case1: 左端对比 ====================
const prevChildrenCase1 = [
  h('p', { key: 'A' }, 'A'),
  h('p', { key: 'B' }, 'B'),
  h('p', { key: 'C' }, 'C'),
];

const nextChildrenCase1 = [
  h('p', { key: 'A' }, 'A'),
  h('p', { key: 'B' }, 'B'),
  h('p', { key: 'D' }, 'D'),
  h('p', { key: 'E' }, 'E'),
];

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

    return {
      toggleChildrenCase1,
    };
  },
  render() {
    return this.toggleChildrenCase1
      ? h('div', {}, prevChildrenCase1)
      : h('div', {}, nextChildrenCase1);
  },
};

3.2 key 属性的作用

key用于确保新旧结点是否是同一个结点 比如下标索引为1的地方,尽管它们的类型是一样的,比如都是element类型的vnode,但是如果key不相同,说明已经不是同样的结点了,那么没必要再对它们的children进行对比了,因为已经是不同的结点了 如果没有key这个属性的话,仅仅通过vnode.type来判断它们的类型相同的话,我们还需要进一步递归调用检查它们的children是否也相同,不相同的时候需要发生变化 这也是为什么我们在vue中写v-for的时候需要添加一个key属性


3.3 找到双端 diff 算法的调用入口

首先我们要回到patchChildren函数,找到array -> array的地方

function patchChildren(n1, n2, container, parentComponent) {
  // n2 的 children 是 text 类型
  const prevShapeFlag = n1.shapeFlag;
  const { shapeFlag } = n2;
  const c2 = n2.children;

  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // 新 children 是 text 类型
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      // 旧 children 是 array 类型 -- 从 array 变为 text

      // 卸载 array 的内容
      unmountChildren(n1.children);

      // 挂载 text 的内容
      hostSetElementText(container, c2);
    } else {
      // 旧 children 是 text 类型 -- 从 text 变为 text
      hostSetElementText(container, c2); // 直接修改文本内容即可
    }
  } else {
    // 新 children 是 array 类型
    if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
      // 旧 children 是 text 类型 -- 从 text 变为 array

      // 清空旧结点中的文本内容
      hostSetElementText(container, '');

      // 挂载新结点中 array 的内容
      mountChildren(c2, container, parentComponent);
+   } else {
+     // 旧 children 是 array 类型 -- 从 array 变为 array
+   }
  }
}

在这里面去编写双端diff算法,作为调用入口,实现一个patchKeyedChildren函数处理有key属性的vnode,接下来我们就去实现它


3.4 处理带有 key 属性的 children

首先要在patchKeyedChildren函数中获取到三个要用到的指针 -- ie1e2

function patchKeyedChildren(c1, c2) {
  let i = 0; // 从左端开始遍历新旧 children
  let e1 = c1.length - 1; // 指向旧 children 的末尾
  let e2 = c2.length - 1; // 指向新 children 的末尾
}

然后需要从左端开始进行对比,找出第一个不一样的结点

function patchKeyedChildren(c1, c2, container, parentComponent) {
  let i = 0; // 从左端开始遍历新旧 children
  let e1 = c1.length - 1; // 指向旧 children 的末尾
  let e2 = c2.length - 1; // 指向新 children 的末尾

  /**
   * @description 判断两个结点是否是相同结点
   * @param n1 vnode1
   * @param n2 vnode2
   * @returns 结点是否是同一个结点
   */
  const isSameVNodeType = (n1, n2) => {
    return n1.type === n2.type && n1.key === n2.key;
  };

  while (i <= e1 && i <= e2) {
    const n1 = c1[i];
    const n2 = c2[i];

    if (isSameVNodeType(n1, n2)) {
      // 新旧结点是同一个结点 -- 递归处理它们的 children 看看是否有变化
      patch(n1, n2, container, parentComponent);
    } else {
      // 遇到不相同的结点 -- 左端对比结束
      break;
    }

    i++;
  }

  console.log(i);
}

当遇到不是同一个vnode的时候,说明找到了第一个从左端开始数起的不同的vnode,于是我们就可以停止遍历了,这里我们可以先输出一下i,看看和预想的结果是否一样 由于我们使用到了vnode.key,所以还需要给vnode加上key这个属性

export function createVNode(type, props?, children?) {
  const vnode = {
    type,
    props,
    children,
    shapeFlag: getShapeFlag(type),
    el: null,
    key: props?.key,
  };
  // ...
}

现在我们在控制台中将toggleChildrenCase1改成falsedemo中的children就会渲染新的children,由于新旧children都是array类型的,所以会触发我们的patchKeyedChildren函数进行对比 image.png 可以看到,对比了两次,第三次由于发现CD是不相同的,所以不会调用patch去处理它们的children了,直接break退出,结束左端对比的流程 并且输出的i === 2,与我们前面在demo场景中描述的预期情况一致,说明左端对比已经实现了


4. 实现右端对比

首先左端对比,遇到了左端第一个不相同的结点的时候,就退出循环,从右端开始对比,找出右端第一个不一样的结点 所以我们需要在左端对比的下面再用一个循环从右端开始寻找

function patchKeyedChildren(c1, c2, container, parentComponent) {
  let i = 0; // 从左端开始遍历新旧 children
  let e1 = c1.length - 1; // 指向旧 children 的末尾
  let e2 = c2.length - 1; // 指向新 children 的末尾

  /**
   * @description 判断两个结点是否是相同结点
   * @param n1 vnode1
   * @param n2 vnode2
   * @returns 结点是否是同一个结点
   */
  const isSameVNodeType = (n1, n2) => {
    return n1.type === n2.type && n1.key === n2.key;
  };

  // 左端对比
  while (i <= e1 && i <= e2) {
    const n1 = c1[i];
    const n2 = c2[i];

    if (isSameVNodeType(n1, n2)) {
      // 新旧结点是同一个结点 -- 递归处理它们的 children 看看是否有变化
      patch(n1, n2, container, parentComponent);
    } else {
      // 遇到不相同的结点 -- 左端对比结束
      break;
    }

    i++;
  }

  // 右端对比
  while (i <= e1 && i <= e2) {
    const n1 = c1[e1];
    const n2 = c2[e2];

    if (isSameVNodeType(n1, n2)) {
      patch(n1, n2, container, parentComponent);
    } else {
      break;
    }

    e1--;
    e2--;
  }

  console.log(`i: ${i}, e1: ${e1}, e2: ${e2}`);
}

以前面右端对比的情况为例: 右端对比.png 以该情况为例,首先会进行左端对比,由于第一个就已经不一样了,所以i应为0 之后就开始从右端对比,当遍历到下标为2,即第三个结点的时候,就应当让e1e2停下 测试一下是否能够完成右端对比

// ==================== Case2: 右端对比 ====================
const prevChildrenCase2 = [
  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 nextChildrenCase2 = [
  h('p', { key: 'E' }, 'E'),
  h('p', { key: 'H' }, 'H'),
  h('p', { key: 'C' }, 'C'),
  h('p', { key: 'F' }, 'F'),
  h('p', { key: 'G' }, 'G'),
];

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

    return {
      toggleChildrenCase2,
    };
  },
  render() {
    return this.toggleChildrenCase2
      ? h('div', {}, prevChildrenCase2)
      : h('div', {}, nextChildrenCase2);
  },
};

image.png 可以看到和预想的情况一样,所以右端对比也没问题


5. 新的比旧的多 -- 创建结点

当新的children比旧的多时,可能会在左端创建,也可能在右端创建

5.1 右端创建

首先处理一下在右端创建的情况 新的比旧的长--右端创建.png 根据前面的左端右端对比,最i === 2e1 === 1e2 === 2 这种时候就是i > e1 && i <= e2的情形,遇到这种情况我们直接创建结点

function patchKeyedChildren(c1, c2, container, parentComponent) {
  let i = 0; // 从左端开始遍历新旧 children
  let e1 = c1.length - 1; // 指向旧 children 的末尾
  let e2 = c2.length - 1; // 指向新 children 的末尾

  /**
   * @description 判断两个结点是否是相同结点
   * @param n1 vnode1
   * @param n2 vnode2
   * @returns 结点是否是同一个结点
   */
  const isSameVNodeType = (n1, n2) => {
    return n1.type === n2.type && n1.key === n2.key;
  };

  // 左端对比
  while (i <= e1 && i <= e2) {
    const n1 = c1[i];
    const n2 = c2[i];

    if (isSameVNodeType(n1, n2)) {
      // 新旧结点是同一个结点 -- 递归处理它们的 children 看看是否有变化
      patch(n1, n2, container, parentComponent);
    } else {
      // 遇到不相同的结点 -- 左端对比结束
      break;
    }

    i++;
  }

  // 右端对比
  while (i <= e1 && i <= e2) {
    const n1 = c1[e1];
    const n2 = c2[e2];

    if (isSameVNodeType(n1, n2)) {
      patch(n1, n2, container, parentComponent);
    } else {
      break;
    }

    e1--;
    e2--;
  }

+  // 新的比旧的多
+  if (i > e1) {
+    if (i <= e2) {
+      // 右端创建结点
+      patch(null, c2[i], container, parentComponent);
+    }
+  }

  console.log(`i: ${i}, e1: ${e1}, e2: ${e2}`);
}

现在来看看对应的demo

// ==================== Case3: 新的比旧的多 ====================
const prevChildrenCase3 = [
  h('p', { key: 'A' }, 'A'),
  h('p', { key: 'B' }, 'B'),
];

const nextChildrenCase3 = [
  h('p', { key: 'A' }, 'A'),
  h('p', { key: 'B' }, 'B'),
  h('p', { key: 'C' }, 'C'),
];

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

    return {
      toggleChildrenCase3,
    };
  },
  render() {
    return this.toggleChildrenCase3
      ? h('div', {}, prevChildrenCase3)
      : h('div', {}, nextChildrenCase3);
  },
};

在更新之前,只有A B image.png 而更新之后,则变成了A B C image.png


5.2 左端创建

如果我们不改动我们的代码,直接让其处理左端添加结点的逻辑 demo 新的比旧的长--左端创建新结点.png

// ==================== Case4: 新的比旧的多 ====================
const prevChildrenCase4 = [
  h('p', { key: 'B' }, 'B'),
  h('p', { key: 'A' }, 'A'),
];

const nextChildrenCase4 = [
  h('p', { key: 'C' }, 'C'),
  h('p', { key: 'B' }, 'B'),
  h('p', { key: 'A' }, 'A'),
];

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

    return {
      toggleChildrenCase4,
    };
  },
  render() {
    return this.toggleChildrenCase4
      ? h('div', {}, prevChildrenCase4)
      : h('div', {}, nextChildrenCase4);
  },
};

image.png 可以看到,本该在B A之前添加一个C才对,但是现在却是在B A之后添加了一个C 这明显不符合更新的逻辑,这是因为我们调用patch添加新节点的时候,是调用hostInsert,直接简单粗暴添加到数组的最后的,并没有提供插入到指定位置的功能 所以这里我们应该扩展一下hostInsert接口,增加能够插入到指定位置的特性,这个指定位置就叫做锚点(anchor) 我们的锚点定义为vnode,在数组中找到锚点vnode,在该vnode之前进行插入,从当前这个例子可以看出,我们应当在第e2 + 1这个vnode之前插入新节点,因为e2总是会指向第一个与e1不同的结点处,在e2 + 1处插入,也就是在原本和e1相同的位置之前插入新节点 比如n1是B An2是C B A,则e2指向0e1指向-1此时应当在B的前面插入新节点,也就是e2 + 1之前插入新节点,所以锚点应当定位e2 + 1处的el

// 新的比旧的多 -- 创建结点
if (i > e1) {
  if (i <= e2) {
    // 确定插入位置
    const nextPos = e2 + 1;
    // 确定锚点 -- 在锚点之前插入新增结点
    const anchor = nextPos > c2.length ? null : c2[nextPos].el;
    patch(null, c2[i], container, parentComponent, anchor);
  }
}

这里给patch函数添加了一个anchor参数,添加后需要修改用到了patch函数的地方 其中最关键的是在render中调用patch的时候需要传递该参数为null,表明参数为空,因为对于render函数来说,此时整个vnode.children中什么都没有,就是需要在最后创建结点,所以没必要传入锚点

function render(vnode: any, container: any) {
  // 调用 patch
  patch(null, vnode, container, null, null);
}

最后还需要修改runtime-dominsert的实现,将元素添加到锚点之前(如果有传入锚点的话)

function insert(child, parent, anchor) {
  parent.insertBefore(child, anchor || null);
}

这样整个过程就完成啦,现在看看demo的效果 image.png 可以看到,插入的顺序是正确的,在B A之前插入了新增元素C


5.3 bug 修复 -- 右端创建错误

我们现在实现了左端创建结点的功能,但是别忘了要检查一下之前已经实现的右端创建结点是否正常,说不定会被我们新实现的左端创建给影响了 image.png 果不其然,真的影响到了已经实现好的右端创建功能了,那肯定是刚刚我们新增的代码出问题了,也就是新增的anchor有问题

const nextPos = e2 + 1;
const anchor = nextPos > c2.length ? null : c2[nextPos].el;

很明显我们的anchor忽略了nextPos === c2.length的情况,当nextPosc2.length时,发生了数组下标索引越界,导致无法获取到c2[nextPos].el,所以我们可以加上一个等号即可 这里为了和vue3源码统一,vue3中用的是<的判断逻辑而不是>=,相当于进行一个取反操作

const anchor = nextPos < c2.length ? c2[nextPos].el : null;

image.png image.png 现在无论是左端创建还是右端创建都是正常的了!

5.4 创建多个结点

事实上新节点可能有多个,所以会创建多次,所以我们的patch应当调用多次,直到i > e2为止 现在我们修改demo,更新后的children是在左端插入三个新元素

const prevChildrenCase4 = [
  h('p', { key: 'B' }, 'B'),
  h('p', { key: 'A' }, 'A'),
];

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

看看能否成功 image.png 可以看到没问题,那么右端添加三个新元素呢? image.png 可以看到也没问题


6. 旧的比新的多 -- 删除结点

6.1 右端删除

右端删除时,对应的指针指向为: 删除右端结点--遍历完毕后的指针指向.png 可以看到,此时的i已经不再是小于e1了,而是和它相等,事实上如果说原来是A B C D,后来是A B的话,则有i < e1,所以触发右端删除的条件应当是i <= e1,但是最基本的前提必须是ie2的右边

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++;
  }
}

这就是核心逻辑了,当遇到需要删除的时候,直接调用hostRemove,也就是渲染器实现的remove函数去处理元素的删除逻辑 image.png image.png 可以看到右端删除成功


6.2 左端删除

左端删除时的指针指向: 删除左端结点--遍历完毕后的指针指向.png 可以看到,此时仍然是i > e2,且i <= e1所以上面右端删除的逻辑同样适用于左端删除 demo

// ==================== Case6: 新的比旧的多 -- 左端删除 ====================
const prevChildrenCase6 = [
  h('p', { key: 'E' }, 'E'),
  h('p', { key: 'D' }, 'D'),
  h('p', { key: 'C' }, 'C'),
  h('p', { key: 'B' }, 'B'),
  h('p', { key: 'A' }, 'A'),
];

const nextChildrenCase6 = [
  h('p', { key: 'B' }, 'B'),
  h('p', { key: 'A' }, 'A'),
];

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

    return {
      toggleChildrenCase6,
    };
  },
  render() {
    return this.toggleChildrenCase6
      ? h('div', {}, prevChildrenCase6)
      : h('div', {}, nextChildrenCase6);
  },
};

左端删除元素.gif 可以看到确实是通用的


现在的篇幅已经太长了,还剩下一个中间对比的处理,将会放在下一篇文章进行讲解