Vue框架源码:Virtual DOM 的实现原理-笔记

395 阅读9分钟

本节主要学习虚拟DOM相关知识,以下是本次的学习目标:

  1. 了解什么是虚拟DOM,以及虚拟DOM的作用。Vue和React为什么会使用它。
  2. 学习SnabbDom基本使用。Vue内部的虚拟DOM是改造了开源库SnabbDom。
  3. 学习SnabbDom源码,了解工作过程。

前置介绍

下面来了解到底什么是虚拟DOM。

什么是虚拟DOM

Virtual DOM,就是用JS对象来描述DOM对象。因为不是真实DOM对象,所以叫虚拟DOM。一个真实DOM的属性是非常多的,所以创建一个DOM对象成本是很高的

而虚拟DOM对象则很简单,它的成员非常少,所以我们创建一个虚拟DOM的成本要小很多

总结:

  1. 虚拟 DOM 是 JS 对象
  2. 虚拟 DOM 是对真实 DOM 的描述

为什么使用虚拟DOM

  1. 早起前端需要手动操作DOM,还需考虑前端兼容性问题,jQuery出来后简化了DOM操作,并兼容了不同浏览器的差异。
  2. 为简化DOM操作,出现了MVVM框架,解决视图和状态同步问题。
  3. 在过去简化视图操作,可以使用模板引擎,但它没办法跟踪状态变化。当数据发生变化,无法获取上一次状态。只能把界面元素全部删除,再重新创建

模板引擎工作原理

  1. 为了解决跟踪状态变化问题,就有了虚拟DOM,它可以跟踪状态变化。当数据改变时不立即更新DOM,而是创建虚拟DOM树来描述真实DOM树,通过虚拟DOM来进行diff,只在真实DOM更新变化的部分。

虚拟DOM工作原理

注意图中的“模板”二字加了引号,这是因为虚拟 DOM 在实现上并不总是借助模板。

GitHub上的virtual-dom的动机描述

  1. 虚拟DOM可以维护程序的状态,可以跟踪上一次的状态。
  2. 跟踪比较前后两次状态差异更新真实DOM。当数据变化之后才操作DOM,不然直接重用DOM。

“深入浅出React”对于虚拟DOM描述

虚拟 DOM,真的是为了更好的性能吗?

  1. 在整个 DOM 操作的演化过程中,主要矛盾并不在于性能,而在于开发者写得爽不爽,在于研发体验/研发效率。虚拟 DOM 不是别的,正是前端开发们为了追求更好的研发体验和研发效率而创造出来的高阶产物。
  2. 虚拟 DOM 的优越之处在于,它能够在提供更爽、更高效的研发模式(也就是函数式的 UI 编程方式)的同时,仍然保持一个还不错的性能。
  3. 解决了跨平台的问题,虚拟 DOM 是对真实渲染内容的一层抽象,它描述的东西可以是真实 DOM,也可以是iOS 界面、安卓界面、小程序......同一套虚拟 DOM,可以对接不同平台的渲染逻辑,从而实现“一次编码,多端运行”

拓展一下性能层面的优化:

除了差量更新以外, “批量更新” 也是虚拟 DOM 在性能方面所做的一个重要努力: “批量更新”在通用虚拟 DOM 库里是由 batch 函数来处理的

在差量更新速度非常快的情况下(比如极短的时间里多次操作同一个 DOM),用户实际上只能看到最后一次更新的效果。这种场景下,前面几次的更新动作虽然意义不大,但都会触发重渲染流程,带来大量不必要的高耗能操作。

这时就需要请 batch 来帮忙了,batch 的作用是缓冲每次生成的补丁集,它会把收集到的多个补丁集暂存到队列中,再将最终的结果交给渲染函数,最终实现集中化的 DOM 批量更新。

虚拟DOM的作用和使用虚拟DOM

虚拟DOM是维护视图和状态的关系,他可以记录上一次状态的变化,只更新状态变化的位置,使用需用DOM的性能会更好些。

  • 虚拟DOM可以维护视图和状态的关系,可以保存视图状态。
  • 在视图比较简单情况或首次渲染视图时,虚拟DOM并不能提高性能。只有复杂情形下才能提高性能,不去渲染状态不变的视图。
  • 最大好处是跨平台:
    • 浏览器平台渲染DOM
    • 服务端渲染 SSR(Nuxt.js-Vue\Next.js-React),就是把虚拟DOM转为字符串。
    • 原生应用
    • 小程序

虚拟DOM开源库:

  • Snabbdom-Vue2.x基于它开发改造虚拟DOM
  • virtual-dom

Snabbdom

这里开始学习这个虚拟DOM库。它有两个核心函数inith函数,主要是使用它们俩函数,及配置一些参数项。

模块

snabbdom的模块是扩展本身的功能的,类似于插件机制。

模块的作用

  • Snabbdom的核心模块只能对VNode进行操作,并不能处理属性、样式、事件。可以通过注册Snabbdom默认提供的模块来实现。
  • Snabbdom的模块可以用来扩展Snabbdom的功能。
  • Snabbdom中的模块的实现是通过注册全局的钩子函数来实现的。这里的全局钩子函数是VNode整个生命周期过程中被触发的函数

官方提供的模块

  • attributes:设置VNode对应dom元素属性,通过setAttributes方法设置
  • props:设置VNode对应dom元素属性,通过object.props方式设置
  • dataset:处理HTML5中data-这样的自定义属性
  • class:不是设置类样式,是切换类样式
  • style:设置行内样式,可以实现动画过渡,它还注册了transitionend事件
  • eventlisteners:注册和移除事件

模块的使用步骤

  • 导入需要的模块
  • init()函数中注册模块
  • h()函数中的第二个参数处使用模块

源码解析

它的基本使用如下所示:

  • 使用init()函数设置模块,创建patch函数
  • 使用h()函数创建JavaScript对象(VNode)描述真实DOM
  • 使用patch()函数对比新旧两个VNode的差异,如果其第一个参数是真实DOM,会将其转换为VNode再对比。
  • 把变化的内容更新到真实DOM树

下载snabbdom源码的git代码:

git clone -b v2.1.0 --depth=1 https://github.com/snabbdom/snabbdom.git

我们重点关注examplessrc,也就是示例和源码。perf文件夹是性能测试。

examples文件夹示例:

  • 两个svg相关示例
  • hero-模块示例
  • reorder-animation-过渡动画示例

h函数

h函数的作用是创建VNode。这里有用到一个概念,就是函数的重载。h函数的核心就是处理不同的传参情况,并且通过vnode函数创建一个VNode对象返回。

VNode

我们可以通过VNode的接口定义来看看一个虚拟DOM应具有哪些属性:

export interface VNode {
  sel: string | undefined;
  data: VNodeData | undefined;
  children: Array<VNode | string> | undefined;
  elm: Node | undefined;
  text: string | undefined;
  key: Key | undefined;
}

patch整体过程分析

  • patch(oldVNode, newVNode),对比两个虚拟DOM的差异,并把差异更新到真实DOM。本质是找树的差异,也就是diff算法
  • 把新节点中变化的内容渲染到真实DOM,并将新节点返回,作为下一次处理的旧节点。
  • 首先对比修旧VNode是否为相同节点(节点的sel和key相同)。
  • 如果不是相同节点,则会删除之前的内容,重新渲染。
  • 如果是相同节点,则会判断新的VNode是否有text,如果有,并且和旧的VNode的text不同,则直接更新文本内容。
  • 如果新的VNode有children,依次对比新旧节点的子节点,看是否有变化。

patch函数是如何创建的--init

通过看init函数的源码,它接收两个参数modulesdomApi,第一个参数我们用过,是内置模块数组,可以扩展处理DOM元素的能力。

在这里可以看看module的结构是什么:

export const styleModule = {
    pre: forceReflow,
    create: updateStyle,
    update: updateStyle,
    destroy: applyDestroyStyle,
    remove: applyRemoveStyle
};

它就是导出的钩子函数对象,将对应的功能函数,定义到不同的钩子中,然后被下面的循环体,加入到对应的钩子数组中去。

而第二个参数,则是一个VNode API集合,默认是传递操作浏览器DOM的API方法,供创建VNode时调用。之所以虚拟DOM能跨平台,就是靠传入的这个DOMAPI,决定如何转化VNode,来生成不同平台元素

我们在调用init函数时,所传入的module,会在这一步将其对应的钩子取出来,并存到对应的钩子数组中去。

for (i = 0; i < hooks.length; ++i) {
  cbs[hooks[i]] = [];
  for (j = 0; j < modules.length; ++j) {
    const hook = modules[j][hooks[i]];
    if (hook !== undefined) {
      (cbs[hooks[i]] as any[]).push(hook);
    }
  }
}

init函数是一个高阶函数,它方法内部最后返回了一个patch函数,好处是通过init函数,初始化了modulesdomApi两个参数。在patch函数调用时,就只要传递后面的新、旧VNode了。因为patch函数会被频繁调用,所以通过高阶函数来包装一下。

patch函数如何对比VNode

这里直接上加了注释的patch函数核心代码:

function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
  let i: number, elm: Node, parent: Node

  // 存储新插入节点的队列,这里存入的目的是触发这些节点上的“insert”钩子函数
  const insertedVnodeQueue: VNodeQueue = []

  // patch出发前,先处理pre钩子中的函数
  for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()

  if (!isVnode(oldVnode)) {
    oldVnode = emptyNodeAt(oldVnode)
  }

  // 判断是否是相同节点,通过key和sel是否一致来判断
  if (sameVnode(oldVnode, vnode)) {
    // 去寻找两个节点的差异,并更新到真实DOM上。此时是不用重新创建dom元素的
    patchVnode(oldVnode, vnode, insertedVnodeQueue)
  } else {
    // 不是相同元素


    elm = oldVnode.elm! // 标识一定是有值
      parent = api.parentNode(elm) as Node // 获取父元素,供后面创建的新VNode挂载到这个父元素下

    // 创建VNode节点对应的真实DOM,并将Hook队列传递过去,触发对应钩子函数
    createElm(vnode, insertedVnodeQueue)

    if (parent !== null) {
      // 将新节点的dom插入老节点的兄弟元素之前
      api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
      // 把父节点中对应的节点元素移除
      removeVnodes(parent, [oldVnode], 0, 0)
    }
  }

  for (i = 0; i < insertedVnodeQueue.length; ++i) {
    // 触发insert钩子函数,这是用户定义的钩子函数
    insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i])
  }
  // 执行post钩子函数
  for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()

  // 返回新的vnode节点,作为下次对比的老节点
  return vnode
}

接下来就来调试patch函数,在代码中通过断点去调试。

createElm函数

这里同样直接上加了注释的createElm函数核心代码:

function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
  // 执行用户设置的 init 钩子函数
  let i: any
  let data = vnode.data
  if (data !== undefined) {
    const init = data.hook?.init
    if (isDef(init)) {
      // init:创建真实DOM之前,让用户可以对VNode做一次修改
      init(vnode)
      data = vnode.data
    }
  }


  const children = vnode.children
  const sel = vnode.sel
  // 把VNode转换成真实DOM对象(没有渲染到页面),而只是把它挂载到VNode的elm属性上
  if (sel === '!') {
    // 创建注释节点
    if (isUndef(vnode.text)) {
      vnode.text = ''
    }
    vnode.elm = api.createComment(vnode.text!)
                                  } else if (sel !== undefined) {
      // 创建对应的DOM元素
      // 解析选择器
      // Parse selector

      /**
       * 这里是解析标签名,获取#和.的位置,从sel中获取标签名
       */
      const hashIdx = sel.indexOf('#')
      const dotIdx = sel.indexOf('.', hashIdx)
      const hash = hashIdx > 0 ? hashIdx : sel.length
      const dot = dotIdx > 0 ? dotIdx : sel.length
      const tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel

      /**
       * 解析完成后,开始创建对应的dom元素,并存储到vnode的elm属性中
       * ns是命名空间的意思
       */
      const elm = vnode.elm = isDef(data) && isDef(i = data.ns)
      ? api.createElementNS(i, tag) // 带命名空间的,一般是创建svg
      : api.createElement(tag) // 直接创建
      // 判断有无id和class,来设置id和类样式
      if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot))
      if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/./g, ' '))

      /**
       * dom元素创建完毕,遍历触发所有create钩子函数
       */
      for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode)

      /**
       * 判断是否有子节点,如果有则创建对应的子节点vnode,并追加到DOM树上。
       * 这里children和text是互斥的。
       */
      if (is.array(children)) {
        for (i = 0; i < children.length; ++i) {
          const ch = children[i]
          if (ch != null) {
            // 子节点不为null,则递归调用createElm,追加到elm中
            api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue))
          }
        }
      } else if (is.primitive(vnode.text)) {
        // 没有children,则直接创建文本节点,追加到elm中
        api.appendChild(elm, api.createTextNode(vnode.text))
      }
      /**
       * 获取用户传入的钩子函数,如果有传入,则调用用户传的create钩子函数
       */
      const hook = vnode.data!.hook
      if (isDef(hook)) {
        hook.create?.(emptyNode, vnode)
        // 如果有insert钩子函数,则将vnode注入到insertedVnodeQueue中,
        // 等dom元素插入到DOM树中后,在执行insert钩子函数
        if (hook.insert) {
          insertedVnodeQueue.push(vnode)
        }
      }
    } else {
      // 选择器为空,创建文本节点
      vnode.elm = api.createTextNode(vnode.text!)
    }
  // 返回新创建的DOM对象
  return vnode.elm
}

removeVnodes 和 addvnodes

/**
   * 
   * @param parentElm 要删除的元素所在父元素
   * @param vnodes array,存放要删除的dom元素对应的vnode
   * @param startIdx 数组中要删除节点的开始位置
   * @param endIdx 数组中要删除节点的结束位置
   */
function removeVnodes (
 parentElm: Node,
 vnodes: VNode[],
 startIdx: number,
 endIdx: number): void {
  for (; startIdx <= endIdx; ++startIdx) {
    let listeners: number
    let rm: () => void
    const ch = vnodes[startIdx]
    if (ch != null) {
      if (isDef(ch.sel)) {
        // 元素节点删除操作
        invokeDestroyHook(ch) // 触发vnode的Destroy Hook函数
        listeners = cbs.remove.length + 1  // 获取cbs中remove函数个数,该listeners变量是为了防止重复删除dom元素(我觉得是保证所有remove钩子函数得到执行)
        rm = createRmCb(ch.elm!, listeners) // 真正返回删除dom元素的函数,同时内部做了一个判断
        // 当listeners 减为0,也就是所有remove钩子函数执行完毕时,才会执行删除元素的操作
        for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm)

        // 处理用户传入的remove钩子函数
        const removeHook = ch?.data?.hook?.remove
        if (isDef(removeHook)) {
          removeHook(ch, rm) // 传入了vnode和rm,用户需要手动执行rm
        } else {
          rm()
        }
      } else { // Text node
        // 文本节点删除操作
        api.removeChild(parentElm, ch.elm!)
      }
    }
  }
}
/**
   * 
   * @param parentElm 父元素
   * @param before 参考节点,vnode对应元素插入在它之前
   * @param vnodes array,要添加的节点
   * @param startIdx vnodes数组开始位置
   * @param endIdx vnodes数组结束位置,可决定要把哪些节点插入到parentElm中
   * @param insertedVnodeQueue 存储插入的具有insert钩子函数的节点
   */
function addVnodes (
 parentElm: Node,
 before: Node | null,
 vnodes: VNode[],
 startIdx: number,
 endIdx: number,
 insertedVnodeQueue: VNodeQueue
) {
  for (; startIdx <= endIdx; ++startIdx) {
    const ch = vnodes[startIdx]
    if (ch != null) {
      // 调用insertBefore方法插入元素,用createElm方法转换成dom元素,插入到真实dom中
      api.insertBefore(parentElm, createElm(ch, insertedVnodeQueue), before)
    }
  }
}

patchVnode

它的功能是对比新旧两个节点,找到他们的差异,更新到真实dom上。先来通过一张图认识该方法做了哪些事情:

在执行代码之前会判断新旧两节点是否相等:

function sameVnode (vnode1: VNode, vnode2: VNode): boolean {
  return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel
}

下面给出源码注释版:

function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
  // 第一个过程:触发 prepatch 和 update 钩子函数
  const hook = vnode.data?.hook
  hook?.prepatch?.(oldVnode, vnode) // 获取用户传入的 prepatch 钩子函数,并立即执行。它是在对比两新旧节点之前执行的
  const elm = vnode.elm = oldVnode.elm! // 把旧节点的elm属性赋值给新节点
  // 分别获取修旧节点的子节点
  const oldCh = oldVnode.children as VNode[] 
  const ch = vnode.children as VNode[]
  if (oldVnode === vnode) return // 判断是否是相同节点,如果相同则不需比较
  if (vnode.data !== undefined) { // todo 难道没有data 就不执行模块自带的update钩子函数了吗?
    // 获取 update 钩子函数依次执行
    for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
    // 后执行用户传入的 update 钩子函数,是因为用户修改 vnode 的数据可以覆盖模块 update 钩子的数据
    vnode.data.hook?.update?.(oldVnode, vnode)
  }

  // 第二个过程:真正对比新旧 vnode 差异的地方 
  if (isUndef(vnode.text)) {
    // 判断是否都有子节点
    if (isDef(oldCh) && isDef(ch)) {
      // 都有子节点则调用 updateChildren 函数,它会对比新旧vnode中的所有子节点,并更新DOM
      if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
    } else if (isDef(ch)) {
      // 新vnode有子节点,则判断老节点是否有text属性
      if (isDef(oldVnode.text)) api.setTextContent(elm, '') // 如果有,则直接清空文本内容
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue) // 并将新 vnode 的子节点插入到 elm 属性中
    } else if (isDef(oldCh)) {
      // 如果老vnode有子节点,则直接从DOM树上移除
      removeVnodes(elm, oldCh, 0, oldCh.length - 1)
    } else if (isDef(oldVnode.text)) {
      // 如果老节点有 text 属性,则直接清空文本内容
      api.setTextContent(elm, '')
    }

  } 
  // 判断修旧节点text属性是否相等,如果相等则不作操作
  else if (oldVnode.text !== vnode.text) {
    // 新旧节点 text 属性不相等,判断老节点是否有子节点
    if (isDef(oldCh)) {
      // 如果老节点有子节点,则直接删除,因为此时的新节点只有 text 文本属性
      removeVnodes(elm, oldCh, 0, oldCh.length - 1)
    }

    // 直接更新 vnode 的 text 值到对应的 elm 元素上
    api.setTextContent(elm, vnode.text!);
  }

  // 第三个过程:触发 postpatch 钩子函数
  hook?.postpatch?.(oldVnode, vnode)
}

updateChildren的整体过程

虚拟DOM中的Diff算法,简单的说就是查找每一个节点的差异。使用虚拟DOM的原因是,渲染真实DOM开销很大,会引起浏览器的重排和重绘,非常耗费性能。diff算法是一种排序算法,传统的diff算法是每一个节点都和对方每一个节点进行依次比对,时间复杂度为O(n^2)。

而snabbdom根据DOM的特点对传统diff算法进行了优化:

  • DOM操作时很少会跨级别操作节点
  • 只比较相同级别的节点

在对开始和结束节点比较的时候,总共有四种情况:

首先先来看新旧开始节点之间的比较,首先根据索引找到各自的开始节点,通过sameVNode函数来判断是否为相同节点(key和sel是否相同):

1.如果是相同节点,则调用patchVNode对比内部差异并更新到真实DOM。第一对节点比对完成后,会移动索引至下一对节点(oldStartIdx++、newStartIdx++)进行比较。

2.如果开始节点不是相同节点, 则会从后往前比较,比较旧结束节点和新结束节点是否是sameVNode,如果是相同节点直接调用patchVNode,进行新老节点的更新,这里会重用DOM对象。比对完成后会向前移动索引。如果是sameVNode的话会重用旧节点DOM元素


3.再来看旧开始节点和新结束节点的比较,对比他们是否是sameVNode

如果是相同节点同样会调用patchVNode比较两个节点内部的差异,并且对比完差异更新完后,还要将旧元素移动到最后来,因为这里旧开始节点和新结束节点相同,所以移动到最后来,同时还要更新索引。这里就涉及到了移动元素。

比对完后就要移动索引,开始比对2、5节点了。

4.第四种情况是比较旧结束节点和新开始节点,这里一开始比对6、1节点,同样调用sameVNode,如果是相同节点,依然调用patchVNode对比更新差异,对比完后再把旧结束元素移到前面来,因为这里旧结束节点和新开始节点是相同的。


如果以上四种情况都不满足,说明开始和结束节点都不相同。这时候则会来旧节点数组中依次查找是否有相同的新节点。

首先会来遍历新VNode的开始节点,在旧节点数组中查找是否具有相同key值的节点:

  1. 如果没找到,则说明此时的新开始节点是一个新节点。那么此时要创建新的DOM元素,并把它插入到最前面的位置来。
  2. 如果找到相同key值的节点,还要判断新旧sel属性是否相同。如果sel属性不相同,还要创建DOM元素,并把它插入到最前面的位置。如果sel属性相同,说明是相同节点,旧节点会赋值给elmToMove这个变量,再调用patchVNode对比更新两节点的差异,再将elmToMove对应得DOM元素移动到最前面。

总结:结合源码看了一下,这种首尾比较的模式,就像是在尽可能的重用DOM,它主要是应对新节点元素仅仅位置互换的情况。因为一般传统的比较方式是,每一个新节点都要去和所有老节点去比较一次,这样性能很慢,所以Snabbdom对此做了优化。

循环结束情况

到这里整个同级别元素的比较就结束了。再来看看循环结束的两种情况:

  1. 老节点子节点个数小于新节点的子节点个数。此时新节点还有剩余。
  2. 老节点子节点个数大于新节点的子节点个数。此时老节点还有剩余。

先来看第一种情况:老节点先遍历完,说明新节点有剩余,此时会调用addVNodes为剩余节点创建对应DOM元素,批量插入到元素右边。(这里没搞懂啊)

应该是比对完老VNode的子节点后,会将新VNode的剩余子节点,批量插入到旧结束节点的后面,循环结束。

再来看第二种情况:老节点有剩余,所以会批量删除剩余节点。这里假设1、2、3都比对一致,第4对节点不一致,此时“开始节点”就比较完毕了,就会从后往前开始比较,开始比较“结束节点”,假设6、7、8和4、5、6都一样,那么5、3是不一致的,此时“结束节点”的比较就也结束了。

这个时候新开始索引就大于了新结束的索引。

updateChildren源码

/**
 * 对比新旧vnode中的所有子节点,并更新DOM
 * @param parentElm 父元素,会在这里进行插入或移除元素的操作
 * @param oldCh 旧节点的子节点
 * @param newCh 新节点的子节点
 * @param insertedVnodeQueue 调用addVNodes时传入的队列
 */
function updateChildren (parentElm: Node,
  oldCh: VNode[],
  newCh: VNode[],
  insertedVnodeQueue: VNodeQueue) {
  let oldStartIdx = 0
  let newStartIdx = 0
  let oldEndIdx = oldCh.length - 1
  let oldStartVnode = oldCh[0]
  let oldEndVnode = oldCh[oldEndIdx]
  let newEndIdx = newCh.length - 1
  let newStartVnode = newCh[0]
  let newEndVnode = newCh[newEndIdx]
  let oldKeyToIdx: KeyToIndexMap | undefined
  let idxInOld: number
  let elmToMove: VNode
  let before: any
  // 同级别节点的比较
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (oldStartVnode == null) {
      oldStartVnode = oldCh[++oldStartIdx] // Vnode might have been moved left
    } else if (oldEndVnode == null) {
      oldEndVnode = oldCh[--oldEndIdx]
    } else if (newStartVnode == null) {
      newStartVnode = newCh[++newStartIdx]
    } else if (newEndVnode == null) {
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
      patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
      api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!))
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
      patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
      api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!)
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
    } else {
      // 如果没有匹配到,说明开始和结束节点都不相同。这时候则会来旧节点数组中依次查找是否有相同的新节点
      if (oldKeyToIdx === undefined) {
        // 这里会创建一个map对象,键是老节点的key,值是老节点的index,方便根据新节点的key,找到老节点数组中对应的索引。
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
      }
      // 在老节点中寻找索引
      idxInOld = oldKeyToIdx[newStartVnode.key as string]
      if (isUndef(idxInOld)) { // New element
        // 找不到老节点对应key,说明是个新节点,那么此时要创建新的DOM元素,并把它插入到最前面的位置来。
        api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
      } else {
        // 如果找到了,则取出对应的老节点
        elmToMove = oldCh[idxInOld]
        if (elmToMove.sel !== newStartVnode.sel) {
          // 如果sel属性不同说明新元素有改动,也同样需要创建一个新的,插入到老节点之前
          api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
        } else {
          // 如果相同,则直接对比新旧节点区别
          patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
          // 比对完,则置空对应的老节点索引,说明它已被处理完
          oldCh[idxInOld] = undefined as any
          // 然后插入到老开始节点前面来
          api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!)
        }
      }
      newStartVnode = newCh[++newStartIdx]
    }
  }
  // 循环结束的收尾工作。如果有一个数组没有被遍历完,说明有剩余的元素
  if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
    if (oldStartIdx > oldEndIdx) {
      // 老节点数组遍历完,新节点数组有剩余
      before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm
      // 将剩余节点批量添加至末尾
      addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else {
      // 新节点数组遍历完,老节点数组有剩余
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
    }
  }
}

注意看while循环的结束条件和循环结束收尾工作的判断条件。

key的意义

不设置key会最大程度重用DOM节点,但是有时候会有问题。如果不设置key,仅仅比较sel属性,容易造成错误的DOM重用,导致渲染错误。