Snabbdom 虚拟dom原理

858 阅读10分钟

一、什么是虚拟dom?

  • 虚拟 DOM(Virtual DOM) ,是由普通的 JS 对象来描述 DOM 对象,因为不是真实的 DOM 对象,所以叫虚拟 DOM
// 真实DOM
<ul id='ulist'>
  <li id='first'>这是第一个List元素</li>
  <li id='second'>这是第二个List元素</li>
</ul>
// 虚拟DOM
var element = {
    element: 'ul',
    props: {
        id:"ulist"
    },
    children: [
      { element: 'li', props: { id:"first" }, children: ['这是第一个List元素'] },
      { element: 'li', props: { id:"second" }, children: ['这是第二个List元素'] }
    ]
}

二、为什么要使用虚拟dom

  • 手动操作 DOM 比较麻烦,还需要考虑浏览器兼容性问题,虽然有 jQuery 等库简化 DOM 操作,但是随着项目的复杂 DOM 操作的复杂会提升

  • 为了简化 DOM 的复杂操作于是出现了各种 MVVM 框架,MVVM 框架解决了视图和状态的同步问题

  • 为了简化视图的操作我们可以使用模板引擎,但是模板引擎没有解决跟踪状态变化的问题,于是虚拟 DOM 出现了

  • 虚拟 DOM 的好处是当状态改变时不需要立即更新 DOM,只需要创建一个虚拟树来描述 DOM, 虚拟dom 内部将弄清楚如何有效(diff)的更新 DOM

  • 虚拟 DOM 可以维护程序的状态,跟踪上一次的状态,这样可以更好的维护视图和状态的关系,在复杂视图情况下提升渲染性能

  • 虚拟 DOM 除了可以渲染 DOM 以外,还可以实现 SSR(Nuxt.js/Next.js)、原生应用(Weex/React Native)、小程序(mpvue/uni-app)等

三、虚拟DOM库 Snabbdom

  • Vue 2.x 内部使用的虚拟 DOM 就是改造的 Snabbdom
  • 大约 200 SLOC(single line of code)
  • 通过模块可扩展
  • 源码使用 TypeScript 开发
  • 最快的虚拟 DOM 之一

四、Snabbdom 基本使用

  1. 初始化项目,安装打包工具 parcel
# 创建项目目录
md snabbdom-demo
# 进入项目目录
cd snabbdom-demo
# 创建 package.json
npm init -y
  1. 配置 scripts,配置启动项目命令,和打包项目命令
// --open 指的是自动打开浏览器
"scripts": {
  "dev": "parcel index.html --open",
  "build": "parcel build index.html"
}
  1. 创建项目目录结构

│  index.html
│  package.json
└─src
     	01-basicusage.js
  1. 安装 Snabbdom
npm install snabbdom@2.1.0
  1. 导入 Snabbdom 的两个核心函数
  • init() 是一个高阶函数,返回 patch()
  • h() 返回虚拟节点 VNode
  • package.json 中的 exports 字段主要是用来做路径的映射,方便设置子路径
  • 因为 parcel 和 webpakc5 之前都不支持 package.json 中的 exports 字段,所以这里要补全路径
import { init } from 'snabbdom/build/package/init'
import { h } from 'snabbdom/build/package/h'

const patch = init([])
  1. Snabbdom 案例一
import { init } from 'snabbdom/build/package/init'
import { h } from 'snabbdom/build/package/h'

const patch = init([])

// h() 函数主要是用来生成 vnode
// 第一个参数:标签+选择器
// 第二个参数:如果是字符串就是标签中的文本内容
let vnode = h('div#container.cls', 'Hello World')
let app = document.querySelector('#app')

// 第一个参数:旧的 VNode,(也可以是真实 DOM 元素,patch 会把他转成 VNode)
// 第二个参数:新的 VNode
// 返回新的 VNode
let oldVnode = patch(app, vnode)

vnode = h('div#container.xxx', 'Hello Snabbdom')
// patch 的作用是对比新旧两个 vnode,将差异更新到真实 DOM 元素上
patch(oldVnode, vnode)
  1. Snabbdom 案例二
import { init } from 'snabbdom/build/package/init'
import { h } from 'snabbdom/build/package/h'

const patch = init([])

// 第二个参数:如果是数组就是标签中的子元素
let vnode = h('div#container', [
  h('h1', 'Hello Snabbdom'),
  h('p', '这是一个p')
])

let app = document.querySelector('#app')
let oldVnode = patch(app, vnode)

setTimeout(() => {
  // 两秒之后更改 div 中的内容
  vnode = h('div#container', [
    h('h1', 'Hello World'),
    h('p', 'Hello P')
  ])
  patch(oldVnode, vnode)

  // 两秒之后删除 div 中的内容
  patch(oldVnode, h('!'))
}, 2000);

五、Snabbdom 模块

  • Snabbdom 的核心库是不能处理 DOM 元素的属性、样式、事件......但是我们可以通过注册 Snabbdom 默认提供的模块来实现

  • Snabbdom 中的模块是可以用来拓展 Snabbdom 的功能

  • Snabbdom 中的模块的实现是通过注册全局的狗子函数来实现的

  • Snabbdom 官方提供的常用模块:

    1. attributes

      • 设置 DOM 元素的属性,使用 setAttribute()
      • 处理布尔类型的属性
    2. props

      • 和 attributes 模块相似,设置 DOM 元素的属性 element[attr] = value
      • 不处理布尔类型的属性
    3. class

      • 切换类样式
      • 注意:给元素设置类样式是通过 sel 选择器
    4. dataset

      • 设置 data-* 的自定义属性
    5. eventlisteners

      • 注册和移除事件
    6. style

      • 设置行内样式,支持动画
      • delayed/remove/destroy
  • Snabbdom 模块使用步骤:

    1. 导入需要的模块
    2. init() 中注册模块
    3. 使用 h() 函数创建 VNode 的时候,可以把第二个参数设置为对象,其他参数往后移
import { init } from 'snabbdom/build/package/init'
import { h } from 'snabbdom/build/package/h'

// 1. 导入模块
import { styleModule } from 'snabbdom/build/package/modules/style'
import { eventListenersModule } from 'snabbdom/build/package/modules/eventlisteners'

// 2. 注册模块
const patch = init([
  styleModule,
  eventListenersModule
])

// 3. 使用h() 函数的第二个参数传入模块中使用的数据(对象)
let vnode = h('div', [
  h('h1', { style: { backgroundColor: 'red' } }, 'Hello World'),
  h('p', { on: { click: eventHandler } }, 'Hello P')
])

function eventHandler() {
  console.log('别点我,疼')
}

let app = document.querySelector('#app')
patch(app, vnode)

六、Snabbdom 源码解析

主要分析 Snabbdom 的核心源码

  1. 使用 h() 函数创建 JavaScript 对象(VNode)描述真实 DOM
  2. init() 设置模块,创建 patch()
  3. patch() 比较新旧两个 VNode
  4. 把变化的内容更新到真实 DOM 树上

6-1 h()

  • h()函数作用主要是用来创建 vnode 对象,和 Vue 中的h()函数类似

  • Snabbdom 中的h()函数是用ts写的,用了函数重载的概念

  • 函数重载

    1. 参数个数或类型不同的函数
    2. JavaScript 中没有重载的概念
    3. TypeScript 中有重载,不过重载的实现还是通过代码调整参数 重载的示意 当add传两个参数时候,就会调用第一个方法,当给add传三个参数的时候会调用第二个方法,这就是函数重载
function add (a: number, b: number) {
  console.log(a + b)
}
function add (a: number, b: number, c: number) {
  console.log(a + b + c)
}
add(1, 2)
add(1, 2, 3)

h()函数源码

// h 函数的重载
export function h (sel: string): VNode
export function h (sel: string, data: VNodeData | null): VNode
export function h (sel: string, children: VNodeChildren): VNode
export function h (sel: string, data: VNodeData | null, children: VNodeChildren): VNode
export function h (sel: any, b?: any, c?: any): VNode {
  var data: VNodeData = {}
  var children: any
  var text: any
  var i: number
  // 处理参数,实现重载的机制
  if (c !== undefined) {
    // 处理三个参数的情况
    // sel、data、children/text
    if (b !== null) {
      data = b
    }
    if (is.array(c)) {
     // 如果 c 是数组
      children = c
    } else if (is.primitive(c)) {
      // 如果 c 是字符串或者数字
      text = c
    } else if (c && c.sel) {
      // 如果 c 是 VNode
      children = [c]
    }
  } else if (b !== undefined && b !== null) {
    if (is.array(b)) {
      children = b
    } else if (is.primitive(b)) {
      // 如果 b 是字符串或者数字
      text = b
    } else if (b && b.sel) {
      // 如果 b 是 VNode
      children = [b]
    } else { data = b }
  }
  if (children !== undefined) {
    // 处理 children 中的原始值(string/number)
    for (i = 0; i < children.length; ++i) {
      // 如果 child 是 string/number,创建文本节点
      if (is.primitive(children[i])) children[i] = vnode(undefined, undefined, undefined, children[i], undefined)
    }
  }
  if (
    sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' &&
    (sel.length === 3 || sel[3] === '.' || sel[3] === '#')
  ) {
    // 如果是 svg,添加命名空间
    addNS(data, children, sel)
  }
  // 返回 VNode
  return vnode(sel, data, children, text, undefined)
};

6-2 VNode

VNode 就是虚拟节点,是一个用来描述 DOM 元素的 JavaScript 对象

export interface VNode {
  // 选择器
  sel: string | undefined;
  // 节点数据:属性/样式/事件等
  data: VNodeData | undefined;
  // 子节点,和 text 只能互斥
  children: Array<VNode | string> | undefined;
  // 记录 vnode 对应的真实 DOM
  elm: Node | undefined;
  // 节点中的内容,和 children 只能互斥
  text: string | undefined;
  // 优化用
  key: Key | undefined;
}

export function vnode (sel: string | undefined,
                      data: any | undefined,
                      children: Array<VNode | string> | undefined,
                      text: string | undefined,
                      elm: Element | Text | undefined): VNode {
  const key = data === undefined ? undefined : data.key
  return { sel, data, children, text, elm, key }
}

6-3 patch 整体过程分析

  • patch(oldVnode, newVnode)

  • patch 又俗称打补丁,把新节点中变化的内容渲染到真实 DOM,最后返回新节点作为下一次处理的旧节点

  • 对比新旧 VNode 是否相同节点(节点的 key 和 sel 相同)

  • 如果不是相同节点,删除之前的内容,重新渲染

  • 如果是相同节点,再判断新的 VNode 是否有 text,如果有并且和 oldVnode 的 text 不同,直接更新文本内容

  • 如果新的 VNode 有 children,判断子节点是否有变化,判断子节点的过程使用的就是 diff 算法

  • diff 过程只进行同层级比较

6-4 init()

  • init(modules, domApi) 主要作用是返回 patch() 函数,他是一个高阶函数

  • 为什么要使用高阶函数?

    1. 因为 patch() 函数再外部会调用多次,每次调用依赖一些参数,比如:modules/domApi/cbs
    2. 通过高阶函数让 init() 内部形成闭包,返回的 patch() 可以访问到 modules/domApi/cbs,而不需要重新创建
  • init() 在返回 patch() 之前,首先收集了所有模块中的钩子函数存储到 cbs 对象中

init() 函数源码

const hooks: Array<keyof Module> = ['create', 'update', 'remove', 'destroy', 'pre', 'post']
export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
  let i: number
  let j: number
  const cbs: ModuleHooks = {
    create: [],
    update: [],
    remove: [],
    destroy: [],
    pre: [],
    post: []
  }
  // 初始化 api
  const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi
  // 把传入的所有模块的钩子方法,统一存储到 cbs 对象中
  // 最终构建的 cbs 对象的形式 cbs = [ create: [fn1, fn2], update: [], ... ]
  for (i = 0; i < hooks.length; ++i) {
    // cbs['create'] = []
    cbs[hooks[i]] = []
    for (j = 0; j < modules.length; ++j) {
      // const hook = modules[0]['create']
      const hook = modules[j][hooks[i]]
      if (hook !== undefined) {
        (cbs[hooks[i]] as any[]).push(hook)
      }
    }
  }
  ……
  return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
    ……
  }
}

6-5 patch()

  • patch()功能是将传入新旧 VNode,对比差异,把差异渲染到 DOM,返回新的 VNode,作为下一次 patch() 的 oldVnode

  • patch()执行过程

    1. 首先执行模块中的钩子函数

    2. 如果 oldVnode 和 vnode 相同(key 和 sel 相同),调用 patchVnode(),找节点的差异并更新 DOM,

    3. 如果 oldVnode 是 DOM 元素(首次渲染),把 DOM 元素转换成 oldVnode,然后调用 createElm() 把 vnode 转换为真实 DOM,记录到 vnode.elm,把刚创建的 DOM 元素插入到 parent 中,移除老节点,触发用户设置的 create 钩子函数

patch() 源码

return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
  let i: number, elm: Node, parent: Node
  // 保存新插入节点的队列,为了触发钩子函数
  const insertedVnodeQueue: VNodeQueue = []
  // 执行模块的 pre 钩子函数
  for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()
      // 如果 oldVnode 不是 VNode,创建 VNode 并设置 elm 
      if (!isVnode(oldVnode)) {
        // 把 DOM 元素转换成空的 VNode
        oldVnode = emptyNodeAt(oldVnode)
      }
      // 如果新旧节点是相同节点(key 和 sel 相同)
      if (sameVnode(oldVnode, vnode)) {
        // 找节点的差异并更新 DOM
        patchVnode(oldVnode, vnode, insertedVnodeQueue)
      } else {
        // 如果新旧节点不同,vnode 创建对应的 DOM
        // 获取当前的 DOM 元素
        elm = oldVnode.elm!
        parent = api.parentNode(elm) as Node
        // 触发 init/create 钩子函数,创建 DOM
        createElm(vnode, insertedVnodeQueue)

        if (parent !== null) {
          // 如果父节点不为空,把 vnode 对应的 DOM 插入到文档中
          api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
          // 移除老节点
          removeVnodes(parent, [oldVnode], 0, 0)
        }
  }
	// 执行用户设置的 insert 钩子函数
  for (i = 0; i < insertedVnodeQueue.length; ++i) {
    insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i])
  }
  // 执行模块的 post 钩子函数
  for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()
  return vnode
}

6-6 createElm()

  • createElm()作用是把 vnode 转化为真实 dom 元素,并且把 dom 元素存储在 vnode 对象的 elm 属性中,然后返回创建好的真实 dom ,这里并没有把创建的 dom 元素挂载到 dom 树上

  • createElm()执行过程:

    1. 首先触发用户设置的 init 钩子函数
    2. 如果选择器是!,创建注释节点
    3. 如果选择器为空,创建文本节点
    4. 如果选择器不为空,解析选择器,设置标签的 id 和 class 属性,执行模块的 create 钩子函数
    5. 如果 vnode 有 children,创建子 vnode 对应的 DOM,追加到 DOM 树
    6. 如果 vnode 的 text 值是 string/number,创建文本节点并追击到 DOM 树
    7. 执行用户设置的 create 钩子函数
    8. 如果有用户设置的 insert 钩子函数,把 vnode 添加到队列中

createElm() 源码

function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
    let i: any
    let data = vnode.data
    
    if (data !== undefined) {
      // 执行用户设置的 init 钩子函数
      const init = data.hook?.init
      if (isDef(init)) {
        init(vnode)
        data = vnode.data
      }
    }
    const children = vnode.children
    const sel = vnode.sel
    if (sel === '!') {
      // 如果选择器是!,创建注释节点
      if (isUndef(vnode.text)) {
        vnode.text = ''
      }
      vnode.elm = api.createComment(vnode.text!)
    } else if (sel !== undefined) {
      // 如果选择器不为空
      // 解析选择器
      // Parse selector
      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
      const elm = vnode.elm = isDef(data) && isDef(i = data.ns)
        ? api.createElementNS(i, tag)
        : api.createElement(tag)
      if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot))
      if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/./g, ' '))
      // 执行模块的 create 钩子函数
      for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode)
      // 如果 vnode 中有子节点,创建子 vnode 对应的 DOM 元素并追加到 DOM 树上
      if (is.array(children)) {
        for (i = 0; i < children.length; ++i) {
          const ch = children[i]
          if (ch != null) {
            api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue))
          }
        }
      } else if (is.primitive(vnode.text)) {
        // 如果 vnode 的 text 值是 string/number,创建文本节点并追加到 DOM 树
        api.appendChild(elm, api.createTextNode(vnode.text))
      }
      const hook = vnode.data!.hook
      if (isDef(hook)) {
        // 执行用户传入的钩子 create
        hook.create?.(emptyNode, vnode)
        if (hook.insert) {
          // 把 vnode 添加到队列中,为后续执行 insert 钩子做准备
          insertedVnodeQueue.push(vnode)
        }
      }
    } else {
      // 如果选择器为空,创建文本节点
      vnode.elm = api.createTextNode(vnode.text!)
    }
    // 返回新创建的 DOM                                
    return vnode.elm
  }

6-7 patchVnode()

  • patchVnode() 是 patch() 中调用的方法,主要对比 oldVnode 和 vnode 的差异,把差异渲染到 DOM

  • patchVnode() 执行过程

    1. 首先执行用户设置的 prepatch 钩子函数

    2. 执行 create 钩子函数,首先执行模块的 create** 钩子函数,然后再执行用户设置的 create 钩子函数

    3. 如果 vnode.text 未定义,就判断是否 oldVnode.children 和 vnode.children 是否都有值,然后调用 updateChildren(),使用 diff 算法对比子节点,更新子节点

    4. 如果 vnode.children 有值,oldVnode.children 无值,则清空 DOM 元素,调用 addVnodes(),批量添加子节点

    5. 如果 oldVnode.children 有值,vnode.children 无值,就调用 removeVnodes(),批量移除子节点

    6. 如果 oldVnode.text 有值,清空 DOM 元素的内容

    7. 如果设置了 vnode.text 并且和 oldVnode.text 不等,如果老节点有子节点,全部移除,设置 DOM 元素的 textContent 为 vnode.text

    8. 最后执行用户设置的 postpatch 钩子函数

patchVnode() 源码

function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
   const hook = vnode.data?.hook
   // 首先执行用户设置的 prepatch 钩子函数
   hook?.prepatch?.(oldVnode, vnode)
   const elm = vnode.elm = oldVnode.elm!
   const oldCh = oldVnode.children as VNode[]
   const ch = vnode.children as VNode[]
 	// 如果新老 vnode 相同返回
   if (oldVnode === vnode) return
   if (vnode.data !== undefined) {
     // 执行模块的 update 钩子函数
     for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
     // 执行用户设置的 update 钩子函数
     vnode.data.hook?.update?.(oldVnode, vnode)
   }
 	// 如果 vnode.text 未定义
   if (isUndef(vnode.text)) {
     // 如果新老节点都有 children
     if (isDef(oldCh) && isDef(ch)) {
       // 调用 updateChildren 对比子节点,更新子节点
       if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
     } else if (isDef(ch)) {
       // 如果新节点有 children,老节点没有 children
     	// 如果老节点有text,清空dom 元素的内容
       if (isDef(oldVnode.text)) api.setTextContent(elm, '')
       // 批量添加子节点
       addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
     } else if (isDef(oldCh)) {
       // 如果老节点有children,新节点没有children
     	// 批量移除子节点
       removeVnodes(elm, oldCh, 0, oldCh.length - 1)
     } else if (isDef(oldVnode.text)) {
       // 如果老节点有 text,清空 DOM 元素
       api.setTextContent(elm, '')
     }
   } else if (oldVnode.text !== vnode.text) {
     // 如果没有设置 vnode.text
     if (isDef(oldCh)) {
       // 如果老节点有 children,移除
       removeVnodes(elm, oldCh, 0, oldCh.length - 1)
     }
     // 设置 DOM 元素的 textContent 为 vnode.text
     api.setTextContent(elm, vnode.text!)
   }
   // 最后执行用户设置的 postpatch 钩子函数
   hook?.postpatch?.(oldVnode, vnode)
 }

6-8 updateChildren()

  • updateChildren() 是 diff 算法的核心,主要是用来对比新旧节点的 children,更新 DOM

  • updateChildren() 执行过程:
    要对比两棵树的差异,如果取第一棵树的每一个节点依次和第二课树的每一个节点比较,这样的时间复杂度为 O(n^3),而且在 DOM 操作的时候很少很少会把一个父节点移动/更新到某一个子节点,因此只需要找同级别的子节点依次比较,然后再找下一级别的节点比较,这样算法的时间复杂度为 O(n)

image.png 在进行同级别节点比较的时候,首先会对新老节点数组的开始和结尾节点设置标记索引,遍历的过程中移动索引
在对开始和结束节点比较的时候,总共有四种情况

    -   oldStartVnode / newStartVnode (旧开始节点 / 新开始节点)
    -   oldEndVnode / newEndVnode (旧结束节点 / 新结束节点)
    -   oldStartVnode / oldEndVnode (旧开始节点 / 新结束节点)
    -   oldEndVnode / newStartVnode (旧结束节点 / 新开始节点)

情况1、2:
oldStartVnode / newStartVnode (旧开始节点 / 新开始节点)
oldEndVnode / newEndVnode (旧结束节点 / 新结束节点)

image.png 如果 oldStartVnode 和 newStartVnode 是 sameVnode (key 和 sel 相同),就调用 patchVnode() 对比和更新节点,把旧开始和新开始索引往后移动 oldStartIdx++ / oldEndIdx++

情况3:
oldStartVnode / newEndVnode (旧开始节点 / 新结束节点) 相同

image.png 就调用 patchVnode() 对比和更新节点,把 oldStartVnode 对应的 DOM 元素,移动到右边 - 更新索引

情况4:
oldEndVnode / newStartVnode (旧结束节点 / 新开始节点) 相同

image.png 调用 patchVnode() 对比和更新节点, 把 oldEndVnode 对应的 DOM 元素,移动到左边,更新索引

  • 如果不是以上四种情况,就遍历新节点,使用 newStartNode 的 key 在老节点数组中找相同节点,如果没有找到,说明 newStartNode 是新节点,创建新节点对应的 DOM 元素,插入到 DOM 树中,如果找到了,判断新节点和找到的老节点的 sel 选择器是否相同,如果不相同,说明节点被修改了,重新创建对应的 DOM 元素,插入到 DOM 树中,如果相同,把 elmToMove 对应的 DOM 元素,移动到左边

  • 循环结束之后,如果老节点的数组先遍历完(oldStartIdx > oldEndIdx),说明新节点有剩余,把剩余节点批量插入到右边

  • 循环结束之后,如果新节点的数组先遍历完(newStartIdx > newEndIdx),说明老节点有剩余,把剩余节点批量删除

updateChildren() 源码

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)) {
      // 1. 比较老开始节点和新的开始节点
      patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      // 2. 比较老结束节点和新的结束节点
      patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
      // 3. 比较老开始节点和新的结束节点
      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
      // 4. 比较老结束节点和新的开始节点
      patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
      api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!)
      oldEndVnode = oldCh[--oldEndIdx]
      newStartVnode = newCh[++newStartIdx]
    } else {
      // 开始节点和结束节点都不相同
      // 使用 newStartNode 的 key 再老节点数组中找相同节点
      // 先设置记录 key 和 index 的对象
      if (oldKeyToIdx === undefined) {
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
      }
      // 遍历 newStartVnode, 从老的节点中找相同 key 的 oldVnode 的索引
      idxInOld = oldKeyToIdx[newStartVnode.key as string]
      // 如果是新的vnode
      if (isUndef(idxInOld)) { // New element
        // 如果没找到,newStartNode 是新节点
        // 创建元素插入 DOM 树
        api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
      } else {
        // 如果找到相同 key 相同的老节点,记录到 elmToMove 遍历
        elmToMove = oldCh[idxInOld]
        if (elmToMove.sel !== newStartVnode.sel) {
          // 如果新旧节点的选择器不同
          // 创建新开始节点对应的 DOM 元素,插入到 DOM 树中
          api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
        } else {
          // 如果相同,patchVnode()
          // 把 elmToMove 对应的 DOM 元素,移动到左边
          patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
          oldCh[idxInOld] = undefined as any
          api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!)
        }
      }
      // 重新给 newStartVnode 赋值,指向下一个新节点
      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)
    }
  }
}