vue3 源码学习,实现一个 mini-vue(十二):diff 算法核心实现

1,445 阅读9分钟

前言

原文来自 我的个人博客 github地址

我们之前完成过一个 patchChildren 的方法,该方法的主要作用是为了 更新子节点,即:为子节点打补丁

子节点的类型多种多样,如果两个 ELEMENT 的子节点都是 TEXT_CHILDREN 的话,那么直接通过 setText 附新值即可。

但是如果 新旧 ELEMENT 的子节点都为 ARRAY_CHILDREN 的话,那么想要完成一个 高效 的更新就会比较复杂了。这个时候,我们就需要,比较两组子节点,以达到一个高效的更新功能。这种 比较的算法 就是 diff 算法。

vue 中对 diff 算法的描述在 packages/runtime-core/src/renderer.tspatchKeyedChildren(1759行) 方法中:

image.png

观察该方法,可以发现该方法内部被分成了 5 块( 5 种场景):

  1. sync from start:自前向后的对比
  2. sync from end:自后向前的对比
  3. common sequence + mount:新节点多于旧节点,需要挂载
  4. common sequence + unmount:旧节点多于新节点,需要卸载
  5. unknown sequence:乱序

5 块就是 diff核心逻辑。我们本章就是围绕这五种场景进行分析实现,现在,就让我们开始循序渐进的解开 diff 算法的神秘面纱吧~~~

1. 前置知识:VNode 虚拟节点 key 属性的作用

在学习 diff 算法前,有一个属性我们必须先了解一下,那就是 key

我们知道在 v-for 循环的时候,我们必须要指定一个 key 值。那么这个 key 值的作用是什么呢?

如果大家有看过我前几篇关于渲染器的文章,应该还记得我们写过一个方法:在 packages/runtime-core/src/vnode.ts 中的 isSameVNodeType 方法:

/**
 * 根据 key || type 判断是否为相同类型节点
 */
export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
    return n1.type === n2.type && n1.key === n2.key
}

typekey 都是 vnodeprops

可以看出 vue 是通过判断两个 VNodetypekey 这两个属性是否相同来判断两个 VNode 是否为 相同 VNode 的。

这个概念在 diff 中非常重要,它决定了 ELEMENT复用 逻辑。因为我们目前的代码并没有 key 这个属性,现在就来把 key 加一下。

  1. packages/runtime-core/src/vnode.tscreateBaseVNode 中,增加 key 属性:
 const vnode = {
    __v_isVNode: true,
    type,
    props,
    shapeFlag,
 +  key: props?.key || null
 } as VNode

这样,我们的 vnode 就可以具备 key 属性了。

2. 场景一:自前向后的 diff 对比

我们创建如下测试实例:

<script>
  const { h, render } = Vue

  const vnode = h('ul', [
    h('li', { key: 1 }, 'a'),
    h('li', { key: 2 }, 'b'),
    h('li', { key: 3 }, 'c')
  ])
  // 挂载
  render(vnode, document.querySelector('#app'))

  // 延迟两秒,生成新的 vnode,进行更新操作
  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)
</script>

在上面的测试实例中,我们利用 vnode2 更新 vnode 节点。

它们的子节点都是一个 ARRAY_CHILDREN ,需要注意的是它们的 子节点具备相同顺序下的相同 vnode (type、key 相等)。唯一不同的地方在于 第三个 li 标签显示的内容不同

那么我们来看一下这种情况下 vue 是如何来处理对应的 diff 的。

2.1 源码阅读

patchKeyedChildren 中,进行 debugger,等待两秒,进入 debugger

image.png

  1. 由上图所示,在 patchKeyedChildren 方法中程序会进入 while(i < e1 && i <= e2) 这个循环,而在 第一次循环 中,因为 n1n2keytype 都相同,所以会进入 if 执行 patch 方法,进行打补丁。最后 i++ 变为 1。因为 此时仍然满足 i <= e1 && i <= e2 ,所以会 第二次进入循环

image.png

  1. 因为第二次的 n1n2typekey 仍然相同,所以仍然会进入 if 和第一步执行相同操作,接着 i++ 变为 2,此时会 第三次进入循环 ,而因为第三次的 n1n2 的也是相同 VNode,所以与前两次相同执行 patch

  2. 三次循环全部完成,此时,我们查看浏览器,可以发现 children更新 操作 已经完成

  3. 后续的代码无需关心。

总结:

由以上代码可知:

  1. diff 所面临的的第一个场景就是:自前向后的 diff 比对
  2. 在这样的一个比对中,会 依次获取相同下标的 oldChildnewChild
  3. 如果 oldChildnewChild相同的 VNode,则直接通过 patch 进行打补丁即可
  4. 如果 oldChildnewChild不相同的 VNode,则会跳出循环
  5. 每次处理成功,则会自增 i 标记,表示:自前向后已处理过的节点数量

2.1 代码实现

根据我们上一小节的源码阅读,下面我们可以直接实现对应逻辑。

  1. 首先我们先让我们的代码支持 ARRAY_CHILDREN 的渲染。创建 mountChildren 方法:
const mountChildren = (children, container, anchor) => {
  for (let i = 0; i < children.length; i++) {
    patch(null, children[i], container, anchor)
  }
}
  1. packages/runtime-core/src/renderer.ts 中触发 mountElement 方法:
else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    // 设置 Array 子节点
    mountChildren(vnode.children, el, anchor)
}
  1. 接下来我们来处理 diff,在 packages/runtime-core/src/renderer.ts 中,创建 patchKeyedChildren 方法:
/**
 * diff
 */
const patchKeyedChildren = (
  oldChildren,
  newChildren,
  container,
  parentAnchor
) => {
  /**
   * 索引
   */
  let i = 0
  /**
   * 新的子节点的长度
   */
  const newChildrenLength = newChildren.length
  /**
   * 旧的子节点最大(最后一个)下标
   */
  let oldChildrenEnd = oldChildren.length - 1
  /**
   * 新的子节点最大(最后一个)下标
   */
  let newChildrenEnd = newChildrenLength - 1

  // 1. 自前向后的 diff 对比。经过该循环之后,从前开始的相同 vnode 将被处理
  while (i <= oldChildrenEnd && i <= newChildrenEnd) {
    const oldVNode = oldChildren[i]
    const newVNode = normalizeVNode(newChildren[i])
    // 如果 oldVNode 和 newVNode 被认为是同一个 vnode,则直接 patch 即可
    if (isSameVNodeType(oldVNode, newVNode)) {
      patch(oldVNode, newVNode, container, null)
    }
    // 如果不被认为是同一个 vnode,则直接跳出循环
    else {
      break
    }
    // 下标自增
    i++
  }
}
  1. patchChildren 方法中,触发 patchKeyedChildren 方法:
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
  //  这里要进行 diff 运算
  patchKeyedChildren(c1, c2, container, anchor)
}
  1. 最后,创建对应测试实例 packages/vue/examples/runtime/render-element-diff.html
<script>
  const { h, render } = Vue

  const vnode = h('ul', [
    h('li', { key: 1 }, 'a'),
    h('li', { key: 2 }, 'b'),
    h('li', { key: 3 }, 'c')
  ])
  // 挂载
  render(vnode, document.querySelector('#app'))

  // 延迟两秒,生成新的 vnode,进行更新操作
  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)
</script>

3. 场景二:自后向前的 diff 对比

现在,我们的代码已经处理 自前向后完全相同的 vnode,但也仅仅如此。

接下来我们看另一个例子:

<script>
  const { h, render } = Vue

  const vnode = h('ul', [
    h('li', { key: 1 }, 'a'),
    h('li', { key: 2 }, 'b'),
    h('li', { key: 3 }, 'c')
  ])
  // 挂载
  render(vnode, document.querySelector('#app'))

  // 延迟两秒,生成新的 vnode,进行更新操作
  setTimeout(() => {
    const vnode2 = h('ul', [
      h('li', { key: 4 }, 'a'),
      h('li', { key: 2 }, 'b'),
      h('li', { key: 3 }, 'd')
    ])
    render(vnode2, document.querySelector('#app'))
  }, 2000)
</script>

在上面的例子中, vnode2 的第一个子节点的 key = 4,这就会导致一个情况:**如果我们从前往后进行 diff 比对,那么第一个 child 无法满足 isSameVNodeType ,就会直接跳出 **

我们继续去调试看看源码是怎么处理的

3.1 源码阅读

  1. 进入 patchKeyedChildren 方法,因为前面的赋值都是一样的我们直接来到第一个 while 循环:

image.png

  1. 当进入第一个 while 的第一次循环时,此时 n1key1 ,n2key4,所以 isSameVNodeType(n1,n2)false,会执行 else 中的 break 跳出当前 while。第一个 while 结束,来到第二个 while开始 第一次循环

image.png

  1. 由上图可知,第二个 while 是从后往前遍历的,且第一次进入循环会比较两个列表的最后一个 vnode 节点,因为此时两个节点不相同所以会进行 patch 打补丁,完成第三个节点的更新后,e1-- e2--e1e2 此时都为 1,所以会 第二次进入循环

image.png

  1. 由于第二次进入循环 n1n2typekey 还是相同的,所以会再次执行 patch 操作,此时 e1e2 都为 0,满足 i <= e1 && i <= e2 所以 第三次进入循环

image.png

  1. 此时 n1.key = 1 n2.key = 4 所以会执行 break 跳出循环。

  2. 此时,各变量的值为:e1 = 0 e2 = 0 i = 0 l2 = 3

  3. 三次循环全部完成,此时,我们查看浏览器,可以发现 children 的 更新 操作 已经完成。后续的代码无需关心。

总结:

由以上代码可知:

  1. vuediff 首先会 自前向后自后向前,处理所有的 相同的 VNode 节点
  2. 每次处理成功之后,会自减 e1e2 ,表示:新、旧节点中已经处理完成节点(自后向前)

3.2 代码实现

明确好了自后向前的 diff 对比之后,接下来我们就可以直接进行对应的实现了:

  1. patchKeyedChildren 方法中,处理自后向前的场景:
// 2. 自后向前的 diff 对比。经过该循环之后,从后开始的相同 vnode 将被处理
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--
}
  1. 创建测试实例 packages/vue/examples/runtime/render-element-diff-2.html
<script>
  const { h, render } = Vue

  const vnode = h('ul', [
    h('li', { key: 1 }, 'a'),
    h('li', { key: 2 }, 'b'),
    h('li', { key: 3 }, 'c')
  ])
  // 挂载
  render(vnode, document.querySelector('#app'))

  // 延迟两秒,生成新的 vnode,进行更新操作
  setTimeout(() => {
    const vnode2 = h('ul', [
      h('li', { key: 4 }, 'a'),
      h('li', { key: 2 }, 'b'),
      h('li', { key: 3 }, 'd')
    ])
    render(vnode2, document.querySelector('#app'))
  }, 2000)
</script>

4. 场景三:新节点多余旧节点时的 diff 比对

以上两种场景,新节点数量和旧节点数量都是完全一致的。

但是我们也知道一旦产生更新,那么新旧节点的数量是可能会存在不一致的情况,具体的不一致情况会分为两种:

  1. 新节点的数量多于旧节点的数量
  2. 旧节点的数量多于新节点的数量

本小节我们先来研究一下 新节点的数量多于旧节点的数量 的情况

新节点的数量多于旧节点的数量的场景下,依然可以被细分为两种具体的场景:

  1. 多出的新节点位于 尾部
  2. 多出的新节点位于 头部

明确好了以上内容之后,我们来看如下测试实例

<script>
  const { h, render } = Vue

  const vnode = h('ul', [h('li', { key: 1 }, 'a'), h('li', { key: 2 }, 'b')])
  // 挂载
  render(vnode, document.querySelector('#app'))

  // 延迟两秒,生成新的 vnode,进行更新操作
  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)
</script>

4.1 源码阅读

根据以上代码进入 debugger,忽略掉前两种场景,直接从第三种场景开始:

  1. 代码进入场景三 3. common sequence + mount

image.png

以上逻辑为:多出的新节点位于 尾部 的场景。

那么接下来我们来看:多出的新节点位于 头部 的场景:

<script>
  const { h, render } = Vue

  const vnode = h('ul', [h('li', { key: 1 }, 'a'), h('li', { key: 2 }, 'b')])
  // 挂载
  render(vnode, document.querySelector('#app'))

  // 延迟两秒,生成新的 vnode,进行更新操作
  setTimeout(() => {
    const vnode2 = h('ul', [
      h('li', { key: 3 }, 'c'),
      h('li', { key: 1 }, 'a'),
      h('li', { key: 2 }, 'b')
    ])
    render(vnode2, document.querySelector('#app'))
  }, 2000)
</script>

根据以上代码,再次进入情景三:

  1. 代码进入场景三 3. common sequence + mount

image.png

总结:

由以上代码可知:

  1. 对于 新节点多余旧节点 的场景具体可以在细分为两种情况:
    1. 多出的新节点位于 尾部
    2. 多出的新节点位于 头部
  2. 这两种情况下的区别在于:插入的位置不同
  3. 明确好插入的位置之后,直接通过 patch 进行打补丁即可。

4.1 代码实现

根据上一小节的分析,我们可以直接在 packages/runtime-core/src/renderer.ts 中的 patchKeyedChildren 方法下,实现如下代码:

// 3. 新节点多余旧节点时的 diff 比对。
if (i > oldChildrenEnd) {
  if (i <= newChildrenEnd) {
    const nextPos = newChildrenEnd + 1
    const anchor =
      nextPos < newChildrenLength ? newChildren[nextPos].el : parentAnchor
    while (i <= newChildrenEnd) {
      patch(null, normalizeVNode(newChildren[i]), container, anchor)
      i++
    }
  }
}

创建对应测试实例 packages/vue/examples/runtime/render-element-diff-3.html

<script>
  const { h, render } = Vue

  const vnode = h('ul', [h('li', { key: 1 }, 'a'), h('li', { key: 2 }, 'b')])
  // 挂载
  render(vnode, document.querySelector('#app'))

  // 延迟两秒,生成新的 vnode,进行更新操作
  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)
</script>

测试成功

5. 场景四:旧节点多于新节点时的 diff 比对

接下来我们来看场景四 旧节点多于新节点时,根据场景三的经验,其实我们也可以明确,对于旧节点多于新节点时,对应的场景也可以细分为两种:

  1. 多出的旧节点位于 尾部
  2. 多出的旧节点位于 头部
<script>
  const { h, render } = Vue

  const vnode = h('ul', [
    h('li', { key: 1 }, 'a'),
    h('li', { key: 2 }, 'b'),
    h('li', { key: 3 }, 'c')
  ])
  // 挂载
  render(vnode, document.querySelector('#app'))

  // 延迟两秒,生成新的 vnode,进行更新操作
  setTimeout(() => {
    const vnode2 = h('ul', [
      h('li', { key: 1 }, 'a'),
      h('li', { key: 2 }, 'b')
    ])
    render(vnode2, document.querySelector('#app'))
  }, 2000)
</script>

5.1 源码阅读

跟踪代码,直接进入场景四即可:

image.png

  1. 因为 i = 2,e1 = 0,e2 = 1,所以最后会执行 unmount 方法 卸载 多余出来的第三个 vnode

  2. 以上代码比较简单,对于 多出的旧节点位于 头部 的场景,同样执行该逻辑。

总结:

由以上代码可知:

旧节点多于新节点时,整体的处理比较简单,只需要 卸载旧节点即可

5.2 代码实现

根据上一小节的分析,我们可以直接在 packages/runtime-core/src/renderer.ts 中的 patchKeyedChildren 方法下,实现如下代码:

// 4. 旧节点多与新节点时的 diff 比对。
else if (i > newChildrenEnd) {
  while (i <= oldChildrenEnd) {
    unmount(oldChildren[i])
    i++
  }
}

创建如下测试实例 packages/vue/examples/runtime/render-element-diff-4.html

<script>
  const { h, render } = Vue

  const vnode = h('ul', [
    h('li', { key: 1 }, 'a'),
    h('li', { key: 2 }, 'b'),
    h('li', { key: 3 }, 'c')
  ])
  // 挂载
  render(vnode, document.querySelector('#app'))

  // 延迟两秒,生成新的 vnode,进行更新操作
  setTimeout(() => {
    const vnode2 = h('ul', [
      h('li', { key: 1 }, 'a'),
      h('li', { key: 2 }, 'b')
    ])
    render(vnode2, document.querySelector('#app'))
  }, 2000)
</script>

测试成功

6. 最长递增子序列

在场景五的 diff 中,vue 使用了 最长递增子序列 这样的一个概念,所以想要更好地理解场景五,那么我们需要先搞明白,两个问题:

  1. 什么是最长递增子序列?
  2. 最长递增子序列在 diff 中的作用是什么?

什么是最长递增子序列?

维基百科 - 最长递增子序列 在一个给定的数值序列中,找到一个子序列,使得这个子序列元素的数值依次递增,并且这个子序列的长度尽可能地大。

前置知识:最长递增子序列在 diff 中的作用是什么?

根据我们之前的四种场景可知,所谓的 diff,其实说白了就是对 一组节点 进行 添加删除打补丁 的对应操作。但是除了以上三种操作之外,其实还有最后一种操作方式,那就是 移动

我们来看下面这个例子:

旧节点:1、2、3、4、5、6
新节点:1、3、2、4、6、5

我们可以根据 新节点 生成 递增子序列,其结果为:

  1. 1、3、6
  2. 1、2、4、6
  3. ......

那么接下来,我们来分析一下移动的策略,整个移动根据递增子序列的不同,将拥有两种移动策略:

  1. 1、3、6 递增序列下:
    1. 因为 1、3、6 的递增已确认,所以它们三个是不需要移动的,那么我们所需要移动的节点无非就是 2、4、5
    2. 所以我们需要经过 三次 移动
  2. 1、2、4、6 递增序列下:
    1. 因为 1、2、4、6 的递增已确认,所以它们四个是不需要移动的,那么我们所需要移动的节点无非就是 两个 3、5
    2. 所以我们需要经过 两次 移动

所以由以上分析,我们可知:最长递增子序列的确定,可以帮助我们减少移动的次数

所以,当我们需要进行节点移动时,移动需要事先构建出最长递增子序列,以保证我们的移动方案。

7. 源码逻辑:求解最长递增子序列

vue 中关于求 求解最长递增子序列 的代码在 packages/runtime-core/src/renderer.ts 中的 2410 行代码,可以看到存在一个 getSequence 的函数。

image.png

这个解法的原理就是通过 贪心 + 二分查找,有兴趣的同学可以去 Leetcode 上做些相关的算法题,这里就不详细展开了。。。

8. 场景五:乱序下的 diff 比对

那么到目前为止,我们已经明确了:

  1. diff 指的就是:添加、删除、打补丁、移动 这四个行为
  2. 最长递增子序列 是什么,以及在 diff 中的作用
  3. 场景五的乱序,是最复杂的场景,将会涉及到 添加、删除、打补丁、移动 这些所有场景。

那么明确好了以上内容之后,我们先来看对应场景五的测试实例

<script>
  const { h, render } = Vue

  const vnode = h('ul', [
    h('li', { key: 1 }, 'a'),
    h('li', { key: 2 }, 'b'),
    h('li', { key: 3 }, 'c'),
    h('li', { key: 4 }, 'd'),
    h('li', { key: 5 }, 'e')
  ])
  // 挂载
  render(vnode, document.querySelector('#app'))

  // 延迟两秒,生成新的 vnode,进行更新操作
  setTimeout(() => {
    const vnode2 = h('ul', [
      h('li', { key: 1 }, 'new-a'),
      h('li', { key: 3 }, 'new-c'),
      h('li', { key: 2 }, 'new-b'),
      h('li', { key: 6 }, 'new-f'),
      h('li', { key: 5 }, 'new-e')
    ])
    render(vnode2, document.querySelector('#app'))
  }, 2000)
</script>

该测试实例经过前四个 while 的过程为:

image.png

  1. 初始状:索引 i= 0。旧节点结束索引 e1 = 4。新节点索引 e2 = 4
  2. 自前向后的对比:索引 i = 1。旧节点结束索引 e1 = 4。新节点索引 e2 = 4
  3. 自后到钱的对比:索引 i = 4。旧节点结束索引 e1 = 3。新节点索引 e2 = 3
  4. 增加新节点:无任何变化
  5. 删除旧节点:无任何变化

8.1 源码阅读

运行该测试实例,我们来跟踪场景五的逻辑:

image.png

  1. 5.1 中创建一个 <key(新节点的 key):index(新节点的位置)>Map 对象 keyToNewIndexMap。通过该对象可知:新的 child(根据 key 判断指定 child) 更新后的位置(根据对应的 index 判断)在哪里。接下来来到 5.2

image.png 2. 5.2 主要做的就是循环 oldChildren ,并尝试进行 patch(打补丁)或 unmount(删除)旧节点。接下来来到 5.3

image.png

  1. 如上图 5.3 主要针对移动和挂载做处理

8.2 代码实现

  1. 复制 vue 中的源码,然后修改一下变量名 即可。在 patchKeyedChildren 中,添加场景五乱序逻辑:
  // 5. 乱序的 diff 比对
  else {
  const oldStartIndex = i
  const newStartIndex = i
  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
  const newIndexToOldIndexMap = new Array(toBePatched)
  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) {
      newIndex = keyToNewIndexMap.get(prevChild.key)
    }

    if (newIndex === undefined) {
      unmount(prevChild)
    }
    else {
      newIndexToOldIndexMap[newIndex - newStartIndex] = i + 1
      if (newIndex >= maxNewIndexSoFar) {
        maxNewIndexSoFar = newIndex
      } else {
        moved = true
      }
      patch(prevChild, newChildren[newIndex], container, null)
      patched++
    }
  }

  const increasingNewIndexSequence = moved
    ? getSequence(newIndexToOldIndexMap)
    : []
  j = increasingNewIndexSequence.length - 1
  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) {
      if (j < 0 || i !== increasingNewIndexSequence[j]) {
        move(nextChild, container, anchor)
      } else {
        j--
      }
    }
  }
}
  1. 新增 move 方法:
/**
* 移动节点到指定位置
*/
const move = (vnode, container, anchor) => {
    const { el } = vnode
    hostInsert(el!, container, anchor)
}
  1. 新增 getSequence 方法
/**
 * 获取最长递增子序列下标
 * 维基百科:https://en.wikipedia.org/wiki/Longest_increasing_subsequence
 * 百度百科:https://baike.baidu.com/item/%E6%9C%80%E9%95%BF%E9%80%92%E5%A2%9E%E5%AD%90%E5%BA%8F%E5%88%97/22828111
 */
function getSequence(arr) {
  // 获取一个数组浅拷贝。注意 p 的元素改变并不会影响 arr
  // p 是一个最终的回溯数组,它会在最终的 result 回溯中被使用
  // 它会在每次 result 发生变化时,记录 result 更新前最后一个索引的值
  const p = arr.slice()
  // 定义返回值(最长递增子序列下标),因为下标从 0 开始,所以它的初始值为 0
  const result = [0]
  let i, j, u, v, c
  // 当前数组的长度
  const len = arr.length
  // 对数组中所有的元素进行 for 循环处理,i = 下标
  for (i = 0; i < len; i++) {
    // 根据下标获取当前对应元素
    const arrI = arr[i]
    //
    if (arrI !== 0) {
      // 获取 result 中的最后一个元素,即:当前 result 中保存的最大值的下标
      j = result[result.length - 1]
      // arr[j] = 当前 result 中所保存的最大值
      // arrI = 当前值
      // 如果 arr[j] < arrI 。那么就证明,当前存在更大的序列,那么该下标就需要被放入到 result 的最后位置
      if (arr[j] < arrI) {
        p[i] = j
        // 把当前的下标 i 放入到 result 的最后位置
        result.push(i)
        continue
      }
      // 不满足 arr[j] < arrI 的条件,就证明目前 result 中的最后位置保存着更大的数值的下标。
      // 但是这个下标并不一定是一个递增的序列,比如: [1, 3] 和 [1, 2]
      // 所以我们还需要确定当前的序列是递增的。
      // 计算方式就是通过:二分查找来进行的

      // 初始下标
      u = 0
      // 最终下标
      v = result.length - 1
      // 只有初始下标 < 最终下标时才需要计算
      while (u < v) {
        // (u + v) 转化为 32 位 2 进制,右移 1 位 === 取中间位置(向下取整)例如:8 >> 1 = 4;  9 >> 1 = 4; 5 >> 1 = 2
        // https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/Right_shift
        // c 表示中间位。即:初始下标 + 最终下标 / 2 (向下取整)
        c = (u + v) >> 1
        // 从 result 中根据 c(中间位),取出中间位的下标。
        // 然后利用中间位的下标,从 arr 中取出对应的值。
        // 即:arr[result[c]] = result 中间位的值
        // 如果:result 中间位的值 < arrI,则 u(初始下标)= 中间位 + 1。即:从中间向右移动一位,作为初始下标。 (下次直接从中间开始,往后计算即可)
        if (arr[result[c]] < arrI) {
          u = c + 1
        } else {
          // 否则,则 v(最终下标) = 中间位。即:下次直接从 0 开始,计算到中间位置 即可。
          v = c
        }
      }
      // 最终,经过 while 的二分运算可以计算出:目标下标位 u
      // 利用 u 从 result 中获取下标,然后拿到 arr 中对应的值:arr[result[u]]
      // 如果:arr[result[u]] > arrI 的,则证明当前  result 中存在的下标 《不是》 递增序列,则需要进行替换
      if (arrI < arr[result[u]]) {
        if (u > 0) {
          p[i] = result[u - 1]
        }
        // 进行替换,替换为递增序列
        result[u] = i
      }
    }
  }
  // 重新定义 u。此时:u = result 的长度
  u = result.length
  // 重新定义 v。此时 v = result 的最后一个元素
  v = result[u - 1]
  // 自后向前处理 result,利用 p 中所保存的索引值,进行最后的一次回溯
  while (u-- > 0) {
    result[u] = v
    v = p[v]
  }
  return result
}

至此,场景五的逻辑完成。

创建对应测试实例 packages/vue/examples/runtime/render-element-diff-5.html

<script>
  const { h, render } = Vue

  const vnode = h('ul', [
    h('li', { key: 1 }, 'a'),
    h('li', { key: 2 }, 'b'),
    h('li', { key: 3 }, 'c'),
    h('li', { key: 4 }, 'd'),
    h('li', { key: 5 }, 'e')
  ])
  // 挂载
  render(vnode, document.querySelector('#app'))

  // 延迟两秒,生成新的 vnode,进行更新操作
  setTimeout(() => {
    const vnode2 = h('ul', [
      h('li', { key: 1 }, 'new-a'),
      h('li', { key: 3 }, 'new-c'),
      h('li', { key: 2 }, 'new-b'),
      h('li', { key: 6 }, 'new-f'),
      h('li', { key: 5 }, 'new-e')
    ])
    render(vnode2, document.querySelector('#app'))
  }, 2000)
</script>

6.gif

测试成功

9. 总结

总结 diff

整个的 diff 就分成 5 种场景,前四种场景相对而言,还比较简单。最复杂的就是第五种,乱序的场景。

整个 diff 中,涉及到了很多的算法。比如:最长递增子序列。

总结 runtime 模块,对于 runtime 而言:

  1. 我们首先是了解了 dom、节点、节点树和 虚拟 DOM,虚拟节点之间的概念
  2. 然后知道了render 函数和 h 函数各自的作用。h 函数可以得到一个 vnode,而 render 函数可以渲染一个 vnode
  3. 接着就是挂载、更新、卸载这三组概念。知道了针对于不同的 vnode 节点,他们的挂载、更新、卸载方式也都是不同的。
  4. 之后又深入了解组件,我们知道组件本质上是一个对象(或函数),组件的挂载本质上是 render 函数的挂载。
  5. 组件内部维护了一个 effect 对象,以达到响应性渲染的效果。
  6. 针对于 setup 函数而言,它在实现上反而更加简单,因为不需要改变 this 指向了。
  7. 最后的 diff 分为 5 种场景,最后一种场景还是比较复杂的。