VUE3虚拟DOM二(算法篇)

56 阅读22分钟

接前一篇的虚拟DOM结构篇

本篇的相关的解析,我们先用文字描述逻辑,然后再转换成代码实现

这一篇我们主要来看下新旧VNode都有children子集时的常见的对比算法,下面的算法逻辑都是补充的上一篇的patchChildren里标注的重点部分,我们把这个逻辑放到我们定义的diffChildren函数中方便查看。

简单Diff算法

这种算法是最常规的算法,简单 Diff 算法的核心逻辑是,拿新的一组子节点中的节点去旧的一组 子节点中寻找可复用的节点。如果找到了,则记录该节点的位置索 引。我们把这个位置索引称为最大索引。在整个更新过程中,如果一 个节点的索引值小于最大索引,则说明该节点对应的真实 DOM 元素需 要移动。

这里循环新的这组 子节点 ,按下标来说是 依次递增 的,所以如果依次在 老的组子节点 中找的对应 子节点 的下标规律如果也是 递增 的,证明 老的和新的是一样顺序,那就不用 移动,否则如果 新的当前子节点 在老的里的 下标 位置小于上一个的话,那证明当前 子节点 位置在老的组里是在 上一个 前面的,新的这组 子节点 按 循环 顺序当前子节点一定是在后面的,这会它是需要 移动 的,借用一张图说明下

image.png

可以看到p-3的索引2,p-1索引是0,当0小于2时,证明在旧子节点中,p-1是在p-3前面的,但是在新子节点p-1是在p-3后面的 所以需要移动。

//简单diff

function diffChildren(n1, n2, container) {
  const oldChildren = n1.children
  const newChildren = n2.children

  let lastIndex = 0

  for (let i = 0; i < newChildren.length; i++) {
    const newVNode = newChildren[i]
    // 初始值为 false,代表没找到
    let find = false
    for (let j = 0; j < oldChildren.length; j++) {
      const oldVNode = oldChildren[j]
      if (oldVNode.key === newVNode.key) {
        find = true
        patch(oldVNode, newVNode, container) //给可复用元素打补丁 先把他们变成一致的内容在移动位置 执行完了之后可复用的节点本身都已经更新完毕了
        if (j < lastIndex) {
          //当前vnode 在oldChildren里的位置 是在上一个前面的 这会按newChildren顺序它是需要移动的 移动真实dom
          // 先获取 newVNode 的前一个 vnode,即 prevVNode
          const prevNode = newChildren[i - 1]
          // 如果 prevVNode 不存在,则说明当前 newVNode 是第一个节点,它不需要移动
          if (prevNode) {
            const anchor = prevNode.el.nextSibling //锚点元素
            // 调用 insert 方法将 newVNode 对应的真实 DOM 插入到锚点元素前面, 也就是 prevVNode 对应真实 DOM 的后面
            insert(newVNode.el, container, anchor)
          }
        } else {
          //符合递增规律不用动
          lastIndex = j //依次处理新的vnode 存储在旧的里的位置
        }

        break
      }
    }

    if (!find) {
      // 如果代码运行到这里,find 仍然为 false,
      // 说明当前 newVNode 没有在旧的一组子节点中找到可复用的节点
      // 也就是说,当前 newVNode 是新增节点,需要挂载
      // 为了将节点挂载到正确位置,我们需要先获取锚点元素
      // 首先获取当前 newVNode 的前一个 vnode 节点
      const prevVNode = newChildren[i - 1]
      let anchor = null

      if (prevVNode) {
        // 如果有前一个 vnode 节点,则使用它的下一个兄弟节点作为锚点元
        anchor = prevVNode.el.nextSibling
      } else {
        // 如果没有前一个 vnode 节点,说明即将挂载的新节点是第一个子节
        // 这时我们使用容器元素的 firstChild 作为锚点  第一个子节点作为锚点
        // 这样查完之后它就会变成第一个
        anchor = container.firstChild
      }
      // 挂载 newVNode
      patch(null, newVNode, container, anchor)
    }
  }

  // 遍历旧的一组子节点 卸载旧的children里 在新的children不存在的项
  for (let i = 0; i < oldChildren; i++) {
    const oldVNode = oldChildren[i]
    // 如果没有找到具有相同 key 值的节点,则说明需要删除该节点

    if (!newChildren.find(vnode => vnode.key === oldVNode.key)) {
      unmount(oldVNode)
    }
  }
}

双端Diff算法

这种算法是vue2中使用的对比算法,下面来讲下具体的实现逻辑。

image.png

我们来看这看这个图:

  1. 它对 新子节点 和 老的子节点 各自声明了变量来记录 开始 和 结束 的位置。
  2. 开启循环只要 新或者旧的 子节点 开始 和 结束 未相交 就继续 循环。
  3. 其实循环我们依次处理的就是 newStartIdx 和 newEndIdx 对应的 开始和结束节点,看他们在 旧子节点 中的位置,是不是也是对应的 开始结束,这个规律,然后如果不是对应的 或者 对应的不对,那就需要 移动,下面具体说下规律。
  4. 首先 oldStartIdx节点 和newStartIDx节 点比较,如果是同一个那直接打补丁(都是开始不用移动)继续下一轮 循环,如果 不是下一步。
  5. oldEndIdx节点 和newEndIdx节点 比较,如果是同一个那直接打补丁(都是开始不用移动)继续下一轮 循环,如果不是下一步。
  6. oldStartIdx节点 和newEndIdx节点 比较,如果是同一个 打补丁,然后我们发现该节点原来是 newChildren 的最后一个而在 oldChildren 是第一个,所以我们要把这个 节点 从 oldChildren 中移到最后一个,所以就是 oldStartIdx 这个对应 真实dom 移到 oldEndIdx的do m后面,让两边的 规律 保持一致,然后把 oldChildren 这个 oldStartIdx位置 置成空表示已经处理过了,然后对应的 oldStartIdx++和newEndIdx-- 各自往里走继续下一轮循环,如果 不是同一个 那下一步。
  7. oldEndIdx节点 和newStartIDx节点 比较,如果是同一个 打补丁,然后我们发现该 节点 原来是 newChildren 的第一一个而在 oldChildren 是最后一个,所以我们要把这个 节点从 oldChildren 中移到第一个,所以就是oldEndIdx 这个对应 真实dom 移到 oldStartIdx的dom 前面,让两边的规律保持一致,然后把 oldChildren 这个位置 oldEndIdx 置成空表示已经处理过了,然后对应的 oldEndIdx--和newStartIDx++ 各自往里走继续下一轮循环,如果不是同一个 那下一步。
  8. 到这一步证明 新旧children的第一个 和最后一个 都没有对上,那这会我们从 newStartIDx 开始处理,我们拿newStartIDx 节点去 oldchildren 中查找,如果找到了那打 补丁,然后把找到的这个节点的 真实dom 挪到oldStartIdx节点的真实dom 前面,如果没找到,那直接把 newStartIDx节点dom 插入到 oldStartIdx节点dom 前面,插到 oldStartIdx节点 前面是因为,newStartIDx节点 在newChildren 这次循环是开始,那在 oldchidren 中应该也是,要保持规律一致。
  9. 然后再处理 oldchildren 的 oldEndIdx 和 oldStartIdx 相交,而 newChildren 的 newStartIDx 和newEndIdx 未相交的情况,出现这种情况代表新的 newChildren 中间未相交的节点是 新节点 ,而且还没有 挂载 ,那就从 newStartIDx开始 循环到 newEndIdx 结束,然后循环相当于每次处理的都是 newStartIDx ,然后每一次都插到 oldStartIdx 前面。
  10. 最后处理 newChildren 的 newStartIDx 和newEndIdx 相交 而 oldchildren 的 oldEndIdx 和 oldStartIdx 未相交 的情况,这种情况代表 新的节点 都处理完了,而 旧的oldEndIdx 和 oldStartIdx 中间的都还没处理,因为 新的newchildren 都处理完了,那我们把从 oldEndIdx 到 oldStartIdx之间 都卸载就好了,这样我们所有的情况就都处理了。

上面所谓的规律保持一致就是节点在newChildren是开始 那在oldChildren也是开始,在newChildren是结束 那在oldChildren也是结束

下面我们来按上面所说看下代码实现:

function diffChildren(n1, n2, container) {
    const oldChildren = n1.children
    const newChildren = n2.children
    // 开始结束坐标
    let oldStartIdx = 0
    let oldEndIdx = oldChildren.length - 1
    let newStartIdx = 0
    let newEndIdx = newChildren.length - 1
    // 对应的vnode
    let oldStartVNode = oldChildren[oldStartIdx]
    let oldEndVNode = oldChildren[oldEndIdx]
    let newStartVNode = newChildren[newStartIdx]
    let newEndVNode = newChildren[newEndIdx]

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (!oldStartVNode) { // while循环判断最后一步会把oldChildren里的某一项设置成空,为空代表处理过了,走下一轮循环继续
            oldStartVNode = oldChildren[++oldStartIdx]
        } else if (!oldEndVNode) {// while循环判断最后一步会把oldChildren里的某一项设置成空,为空代表处理过了,走下一轮循环继续
            oldEndVNode = newChildren[--oldEndIdx]
        } else if (oldStartVNode.key === newStartVNode.key) { //开始对比
            // 调用 patch 函数在 oldStartVNode 与 newStartVNode 之间打补丁
            patch(oldStartVNode, newStartVNode, container)
            // 更新相关索引,指向下一个位置 
            oldStartVNode = oldChildren[++oldStartIdx]
            newStartVNode = newChildren[++newStartIdx]

        } else if (oldEndVNode.key === newEndVNode.key) {//结束对比
            // 节点在新的顺序中仍然处于尾部,不需要移动,但仍需打补丁
            patch(oldEndVNode, newEndVNode, container) //打补丁
            // 更新索引和头尾部节点变量
            oldEndVNode = oldChildren[--oldEndIdx]
            newEndVNode = newChildren[--newEndIdx]
        } else if (oldStartVNode.key === newEndVNode.key) {// 旧的开始对比新的结束
            // 调用 patch 函数在 oldStartVNode 和 newEndVNode 之间打补丁 
            patch(oldStartVNode, newEndVNode, container) //打补丁
            // 将旧的一组子节点的头部节点对应的真实 DOM 节点 oldStartVNode.el 移动到 旧的一组子节点的尾部节点对应的真实 DOM 节点后面 
            insert(oldStartVNode.el, container, oldEndVNode.el.nextSibling)
            // 更新相关索引到下一个位置 
            oldStartVNode = oldChildren[++oldStartIdx]
            newEndVNode = newChildren[--newEndIdx]

        } else if (oldEndVNode.key === newStartVNode.key) { //旧的结束对比新的开始
            patch(oldEndVNode, newStartVNode, container) //打补丁
            // oldEndVNode.el 移动到 oldStartVNode.el 前面 
            insert(oldEndVNode.el, container, oldStartVNode.el)
            // 移动 DOM 完成后,更新索引值,并指向下一个位置 
            oldEndVNode = oldChildren[--oldEndIdx]
            newStartVNode = newChildren[++newStartIdx]

        } else {
            // 遍历旧的一组子节点,试图寻找与 newStartVNode 拥有相同 key 值的节点
            // idxInOld 就是新的一组子节点的头部节点在旧的一组子节点中的索引 
            const idxInOld = oldChildren.findIndex(
                node => node.key === newStartVNode.key
            )
            // idxInOld 大于 0,说明找到了可复用的节点,并且需要将其对应的真实 DOM 移动到头部 
            if (idxInOld > 0) {
                // idxInOld 位置对应的 vnode 就是需要移动的节点
                const vnodeToMove = oldChildren[idxInOld]
                // 打补丁
                patch(vnodeToMove, newStartVNode, container)
                // 将 vnodeToMove.el 移动到头部节点 oldStartVNode.el 之前,因此使用后者作为锚点 
                insert(vnodeToMove.el, container, oldStartVNode.el)
                // 由于位置 idxInOld 处的节点所对应的真实 DOM 已经移动到了别处,因此将其设置为 undefined 
                oldChildren[idxInOld] = undefined
            } else {
                // 将 newStartVNode 作为新节点挂载到头部,使用当前头部节点 oldStartVNode.el 作为锚点 
                patch(null, newStartVNode, container, oldStartVNode.el)
            }
            // 最后更新 newStartIdx 到下一个位置 
            newStartVNode = newChildren[++newStartIdx]
        }
    }

    // 循环结束后检查索引值的情况, 
    if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
        // 如果满足条件,则说明有新的节点遗留,需要挂载它们 
        // 旧的相交了 而新的还未相交 新的中间的未相交部分需要挂载
        for (let i = newStartIdx; i <= newEndIdx; i++) {
            patch(null, newChildren[i], container, oldStartVNode.el)
        }
    } else if (newEndIdx < newStartIdx && oldStartIdx <= oldEndIdx) {
        //移除操作
        //新的相交了 旧的未相交 旧的中间未相交部分需要移除
        for (let i = oldStartIdx; i <= oldEndIdx; i++) {
            unmount(oldChildren[i])
        }
    }

}

快速Diff算法

该算法最早应用于 ivi 和 inferno 这两个框架,Vue.js 3 借鉴并扩展了它,我们来看下它的性能对比。

image.png

不同于简单 Diff 算法和双端 Diff 算法,快速 Diff 算法包含预处理 步骤,这其实是借鉴了纯文本 Diff 算法的思路。在纯文本 Diff 算法 中,存在对两段文本进行预处理的过程。例如,在对两段文本进行 Diff 之前,可以先对它们进行全等比较:

if (text1 === text2) return

这也称为快捷路径。如果两段文本全等,那么就无须进入核心 Diff 算法的步骤了。除此之外,预处理过程还会处理两段文本相同的 前缀和后缀。假设有如下两段文本:

 TEXT1: I use vue for app development 
 TEXT2: I use react for app development

对于内容相 同的问题,是不需要进行核心 Diff 操作的。因此,对于 TEXT1 和 TEXT2 来说,真正需要进行 Diff 操作的部分是:

 TEXT1: vue 
 TEXT2: react

快速 Diff 算法借鉴了纯文本 Diff 算法中预处理的步骤。

image.png

预先从新旧children各自开始和结束位置循环,一样的就打补丁,遇到不一样的就停止。

预处理完之后,看下是不是有如果有一方处理完了,但是另一方没处理完的情况。 先看看旧的都处理完了,然后新的没处理完,代表新的需要挂载上,然后看下判断条件:如果标记开始的 j 下标 是大于 oldEnd 旧的 结束 而小于等于 newEnd 它时,就代表旧的都处理完了,但是新的没处理完 那 j==>newEnd 之间的需要挂载上,而挂载锚点如下:

if (j > oldEnd && j <= newEnd)

// 锚点的索引 
const anchorIndex = newEnd + 1 
// 锚点元素 
const anchor = anchorIndex < newChildren.length ? newChildren[anchorIndex].el : null

默认是 newEnd+1,如果 newEnd 是最后一个 代表预处理后面时 刚开始就停止了 开始就不一样 所以那下一个就是null,这样所有的 每次循环 就会被插在最后,否则就是处理过newEnd不是最后一个,每次插在 newEnd+1 之前,也就是最后一个处理的前面。

然后 再看看 新的处理完了然后旧的没处理完,那 剩下的 没处理完的就都是要 卸载 掉,判断条件:j大于newEnd,新的处理完了,然后 j<=oldEnd 代表旧的没处理完,然后卸载它们之间的节点:

 // j -> oldEnd 之间的节点应该被卸载 
while (j <= oldEnd) {
  unmount(oldChildren[j++])
}

然后我们预处理加我们说的这两种情况的完整代码如下:

function diffChildren(n1, n2, container) {
    const newChildren = n2.children
    const oldChildren = n1.children
    // 更新相同的前置节点
    let j = 0
    let oldVNode = oldChildren[j]
    let newVNode = newChildren[j]
    while (oldVNode.key === newVNode.key) {
        patch(oldVNode, newVNode, container)
        j++
        oldVNode = oldChildren[j]
        newVNode = newChildren[j]
    }
    // 更新相同的后置节点 
    // 索引 oldEnd 指向旧的一组子节点的最后一个节点
    let oldEnd = oldChildren.length - 1
    // 索引 newEnd 指向新的一组子节点的最后一个节点
    let newEnd = newChildren.length - 1
    oldVNode = oldChildren[oldEnd]
    newVNode = newChildren[newEnd]
    // while 循环从后向前遍历,直到遇到拥有不同 key 值的节点为止 
    while (oldVNode.key === newVNode.key) {
        // 调用 patch 函数进行更新 
        patch(oldVNode, newVNode, container)
        // 递减 oldEnd 和 nextEnd 
        oldEnd--
        newEnd--
        oldVNode = oldChildren[oldEnd]
        newVNode = newChildren[newEnd]
    }
    // 预处理完毕后,如果满足如下条件,则说明从 j --> newEnd 之间的节点应作为新节点插入 
    if (j > oldEnd && j <= newEnd) {
        // 锚点的索引 
        const anchorIndex = newEnd + 1
        // 锚点元素 
        const anchor = anchorIndex < newChildren.length ?
            newChildren[anchorIndex].el : null
        // 采用 while 循环,调用 patch 函数逐个挂载新增节点 
        while (j <= newEnd) {
            patch(null, newChildren[j++], container, anchor)
        }
    } else if (j > newEnd && j <= oldEnd) {
        // j -> oldEnd 之间的节点应该被卸载 
        while (j <= oldEnd) {
            unmount(oldChildren[j++])
        }
    }
}

然后接下来就是重点部分了,上面所说的都是理想情况下,正常大概率会是新旧的预处理都没几个循环就结束了,然后接下来就是看如何处理中间部分,我们来看一张图:

image.png

j ==> newEnd 和 j ===> oldEnd 中间的部分就是需要我们用算法处理的。

标记一
  1. 我们需要计算新的一组子节点中剩余 未处理节点的数量,即 newEnd - j + 1,然后创建一个长度与之相 同的数组 source,最后使用 fill 函数完成数组的填充值为-1。
  2. 这样这个source下标和新的这组子节点未处理的节点时一一对应的,然后这个下标值我们需要实际存的是 新的一组子节点中的节点在旧的一组子节点 中的位置索引,如果未找到那就是默认值-1。
  3. 存这个的作用是我们需要用它来计算出一个最长递增子序列,然后用于辅助完成dom移动的操作。
  4. 最长递增子序列如何辅助移动的后面讲。

再看一张图:

image.png

上图中source存的的新一组子节点中的节点在旧的一组子节点 中的位置索引都有了,然后还有一个索引表来优化算法,帮助source数组快速存储。

下面我们看下如何优化辅助的:

  1. 首先创建 keyIndex 空对象 {} 当索引表。
  2. 循环新未处理的子节点 j ===> newEnd,然后节点的key当索引表的属性key,然后值就是新子节点的下标。
  3. 然后循环旧未处理的子节点 j ===> oldEnd,然后根据旧子节点的key在 keyIndex索引表中查找,如果找到对应的key了,取出对应的值也就是 对应新子节点的下标,然后根据这个 下标 找到新子节点然后 patch 先打补丁,然后根据这个新子节点的下标找一下对应一下 source数组中的位置,然后把当前这个旧的子节点的下标存进去。

这样我们就完成了 标记一所说的 source数组的填充。

那在做上面的创建索引表和填充source数组的同时,我们 看下 是否需要 移动,在做 循环旧的 子节点 时进行判断, 判断 节点 是否需要 移动 的方法与 简单 Diff 算法类似,俩变量 moved 标记是否需要移动默认为false,pos 存储 当前旧节点在新子节点的下标位置,旧子节点循环是依次往后的,所以 正常来说 顺序没变的话 循环 旧节点 时找 新子节点 的 下标 位置是越来越大的,如果不符合这个规则,证明是需要移动的。

除此之外,我们还需要一个数量标识,代表 已经更新过的节点数 量 。我们知道, 已经更新过的节点数量 应该小于新的一组子节点中需 要更新的节点数量。一旦前者超过后者,则说明有多余的节点,我们 应该将它们卸载。

然后我们在上面所写的代码else if后再加一个else:


   else if (j > newEnd && j <= oldEnd) {
        // j -> oldEnd 之间的节点应该被卸载 
        while (j <= oldEnd) {
            unmount(oldChildren[j++])
        }
    } else {
        // 构造source数组
        const count = newEnd + 1
        const source = new Array(count)
        source.fill(-1)
        const oldStart = j
        const newStart = j
        let moved = false
        let pos = 0
        const keyIndex = {}
        for (let i = newStart; i < newEnd; i++) {
            keyIndex[newChildren[i].key] = i
        }
        // 新增 patched 变量,代表更新过的节点数量
        let patched = 0
        for (let i = oldStart; i < oldEnd; i++) {
            oldVNode = oldChildren[i]
            if (patched <= count) { //已更新节点的数量  和  未处理的新节点count总数对比 如果小于等于 证明新节点还没处理完
                // 旧节点在新节点的下标位置
                const k = keyIndex[oldVNode.key]
                if (k !== undefined) { // 这个位置有 新的一轮更新它还在 这会看是否需要移动
                    newVNode = newChildren[k]
                    patch(oldVNode, newVNode, container) //更新
                    // 每更新一个节点,都将 patched 变量 +1
                    patched++
                    // 填充source数组
                    source[k - newStart] = i
                    if (k < pos) { // 顺序未变的情况  下标 位置是越来越大的,如果不符合这个规则 需要移动
                        moved = true
                    } else {
                        pos = k
                    }

                } else {
                    // 没找到 新一轮更新 它oldVode被卸载了
                    unmount(oldVNode)
                }

            } else {
                // 到这证明新节点都已经处理完了  但是旧节点还没循环完 这会把剩下的都卸载掉
                // 如果更新过的节点数量大于需要更新的节点数量,则卸载多余的节点
                unmount(oldVNode)
            }
        }
    }

然后moved是true的时候证明需要移动,这会我们根据 source 数组计算出一个 最长递增子序列(最长递增子序列概念不懂可以百度一下,很简单)。

接下来逻辑我们来看张图:

image.png

因为 source 数组的最长递增子序列为 [2, 3],其中元素 2 在 该数组中的索引为 0,而数组 3 在该数组中的索引为 1,所以最终对节点进行重新编 号结果 为 seq = [0, 1], seq存的是source的对应的下标位置。

这里我们推理一个东西:

  1. 求出的最长递增子序列,例如 [2, 3] ,因为是按旧子节点的 下标大小 求出的 最长递增子序列,也就是说在旧组子节点中 3对应位置的节点 一定 是在 2对应的节点后面,计算最长递增子序列中间是不连续的,所以导致 求出来的位置可能也不是连续的,但是 大的节点位置在后面一定是不变的,虽然实际渲染时中间可能有其他的节点 但是从规律看 前后顺序 一定是符合 最长递增子序列的前后顺序的
  2. 而source数组顺序就是 新组未处理子节点的渲染顺序,我们看前面求出的最长递增子序列,对应到source下标的话 虽然可能是不连续(最长递增子序列特性导致),但是也是慢慢变大的,也就是说在新的一轮渲染中 对应的最长递增子序列的source下标位置,我们看到了也就是是 [0, 1] ,循环渲染时按下标1一定是在0后面的, 也就是说对应的实际元素也就是 p-4 在 p-3 后面(虽然实际应用的时候真实场景下下标可能不连续,但是下标大的一定是在后面的,和那个最长递增子序列是对应的)。
  3. 也就是在新的一轮渲染中,seq对应存的source下标位置对应的newVNode 前后关系和 oldChildren中对应节点的前后关系是一样的(前面最长递增子序列顺序已经论证过了),这也就意味着 seq对应的newVNode 位置是不用移动的。

所以这就验证了书里所说: 在新的一组子节点中,重新 编号后索引值为 0 和 1 的这两个节点在更新前后顺序没有发生变 化。换句话说,重新编号后,索引值为 0 和 1 的节点不需要移动。

这样的话我们继续:

  1. 用索引 i 指向新的一组子节点中的最后一个节点
  2. 用索引 s 指向最长递增子序列中的最后一个元素

image.png

  1. 因为渲染是新的为主,所以从尾到头循环source数组。
  2. 然后判断source[i]是-1代表没有 然后直接挂载,挂载的锚点就是当前节点的下一个兄弟节点,挂载到它之前,没有兄弟节点的话就是插入到最后。
  3. 如果i === seq[s]的话,证明不用移动(前面说了,seq里对应下标的不用移动),然后当前处理完了s-- s要往前移。
  4. i !== seq[s] 证明需要移动,和挂载一样 移动的锚点就是当前节点的下一个兄弟节点,移动到它之前,没有兄弟节点的话就是移动到最后

这样处理完了 也就是说快读diff也就结束了,我们来看下具体实现:

function diffChildren(n1, n2, container) {
    const newChildren = n2.children
    const oldChildren = n1.children
    // 更新相同的前置节点
    let j = 0
    let oldVNode = oldChildren[j]
    let newVNode = newChildren[j]
    while (oldVNode.key === newVNode.key) {
        patch(oldVNode, newVNode, container)
        j++
        oldVNode = oldChildren[j]
        newVNode = newChildren[j]
    }
    // 更新相同的后置节点 
    // 索引 oldEnd 指向旧的一组子节点的最后一个节点
    let oldEnd = oldChildren.length - 1
    // 索引 newEnd 指向新的一组子节点的最后一个节点
    let newEnd = newChildren.length - 1
    oldVNode = oldChildren[oldEnd]
    newVNode = newChildren[newEnd]
    // while 循环从后向前遍历,直到遇到拥有不同 key 值的节点为止 
    while (oldVNode.key === newVNode.key) {
        // 调用 patch 函数进行更新 
        patch(oldVNode, newVNode, container)
        // 递减 oldEnd 和 nextEnd 
        oldEnd--
        newEnd--
        oldVNode = oldChildren[oldEnd]
        newVNode = newChildren[newEnd]
    }
    // 预处理完毕后,如果满足如下条件,则说明从 j --> newEnd 之间的节点应作为新节点插入 
    if (j > oldEnd && j <= newEnd) {
        // 锚点的索引 
        const anchorIndex = newEnd + 1
        // 锚点元素 
        const anchor = anchorIndex < newChildren.length ?
            newChildren[anchorIndex].el : null
        // 采用 while 循环,调用 patch 函数逐个挂载新增节点 
        while (j <= newEnd) {
            patch(null, newChildren[j++], container, anchor)
        }
    } else if (j > newEnd && j <= oldEnd) {
        // j -> oldEnd 之间的节点应该被卸载 
        while (j <= oldEnd) {
            unmount(oldChildren[j++])
        }
    } else {
        // 构造source数组
        const count = newEnd + 1
        const source = new Array(count)
        source.fill(-1)
        const oldStart = j
        const newStart = j
        let moved = false
        let pos = 0
        const keyIndex = {}
        for (let i = newStart; i < newEnd; i++) {
            keyIndex[newChildren[i].key] = i
        }
        // 新增 patched 变量,代表更新过的节点数量
        let patched = 0
        for (let i = oldStart; i < oldEnd; i++) {
            oldVNode = oldChildren[i]
            if (patched <= count) { //已更新节点的数量  和  未处理的新节点count总数对比 如果小于等于 证明新节点还没处理完
                // 旧节点在新节点的下标位置
                const k = keyIndex[oldVNode.key]
                if (k !== undefined) { // 这个位置有 新的一轮更新它还在 这会看是否需要移动
                    newVNode = newChildren[k]
                    patch(oldVNode, newVNode, container) //更新
                    // 每更新一个节点,都将 patched 变量 +1
                    patched++
                    // 填充source数组
                    source[k - newStart] = i
                    if (k < pos) { // 顺序未变的情况  下标 位置是越来越大的,如果不符合这个规则 需要移动
                        moved = true
                    } else {
                        pos = k
                    }

                } else {
                    // 没找到 新一轮更新 它oldVode被卸载了
                    unmount(oldVNode)
                }

            } else {
                // 到这证明新节点都已经处理完了  但是旧节点还没循环完 这会把剩下的都卸载掉
                // 如果更新过的节点数量大于需要更新的节点数量,则卸载多余的节点
                unmount(oldVNode)
            }
        }

        if (moved) {
            // 最长递增子序列
            const seq = lis(sources)
            // s 指向最长递增子序列的最后一个元素 
            let s = seq.length - 1
            let i = count - 1
            for (i; i >= 0; i--) {
                if (source[i] === -1) {
                    // 说明索引为 i 的节点是全新的节点,应该将其挂载
                    // 该节点在新 children 中的真实位置索引
                    const pos = i + newStart
                    const newVNode = newChildren[pos]
                    const nextPos = pos + 1
                    // 锚点 
                    const anchor = nextPos < newChildren.length
                        ? newChildren[nextPos].el
                        : null
                    // 挂载 
                    patch(null, newVNode, container, anchor)
                } else if (i !== seq[s]) {
                    // 如果节点的索引 i 不等于 seq[s] 的值,说明该节点需要移动
                    // 该节点在新的一组子节点中的真实位置索引 

                    const pos = i + newStart
                    const newVNode = newChildren[pos]
                    // 该节点的下一个节点的位置索引 
                    const nextPos = pos + 1
                    // 锚点 
                    const anchor = nextPos < newChildren.length
                        ? newChildren[nextPos].el
                        : null
                    // 移动 
                    insert(newVNode.el, container, anchor)

                } else {
                    // 当 i === seq[s] 时,说明该位置的节点不需要移动
                    // 只需要让 s 指向前一个位置
                    s--
                }
            }
        }

    }
}

求最长递增子序列一般是 贪心加二分查找 后面我们具体解析一下实现思路 先贴vue3里的实现

function getSequence(arr) {
    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) / 2) | 0
                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
}