虚拟DOM(Virtual DOM)与Snabbdom

1,123 阅读13分钟

在研究Vue的源码的时候,了解了一下Virtual DOM,以及Virtual DOM的作用。下面分享一下什么是Virtual DOM,以及它的作用。和一个比较常见的Virtual DOM库的使用与源码分析。

什么是Virtual DOM

  • Virtual DOM(虚拟DOM),是由js对象来描述DOM对象,因为不是真是的DOM,所以叫作虚拟DOM。
  • Virtual DOM的格式一般如下:
{
  sel: "div",
  data: {},
  children: undefined,
  text: "Hello Virtual DOM",
  elm: undefined,
  key: undefined
}

为什么使用Virtual DOM

  • 手动操作DOM比较麻烦,虽然有JQuery等库简化DOM操作,但是随着项目的复杂性提升,DOM操作越来越难
  • 为了简化视图的操作我们可以使用模板引擎,但是模板引擎没有解决跟踪状态变化的问题
  • Virtual DOM的好处是可以不用立即更新DOM,当数据改变时,只需创建一个虚拟树来描述DOM,可以跟踪记录上一次的状态,通过比较前后两次的状态差异来更新真实的DOM,在复杂项目中可以提升渲染的性能
  • 除了渲染DOM外,还可以实现SSR渲染(nuxt.js/next.js),原生应用(weex/React Native),小程序等

Snabbdom(Virtual DOM库)

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

Snabbdom的基本使用

  • 我使用的打包工具是parcel,不用webpack,选这个的原因,是可以零配置运行一个简单的demo
# 创建项目目录
md snabbdom-demo
# 进入项目目录
cd snabbdom-demo
# 创建 package.json
npm init -y
# 本地安装 parcel
npm install parcel-bundler -D
# 本地安装 snabbdom   安装snabbdom要注意版本问题,最新的版本有问题,建议安装0.7.4版本
npm install snabbdom@0.7.4 -S
  • 创建对应的目录结构
- index.html
- package.json
└─ src
   index.js
  • 配置 package.json 的 scripts
"scripts": {
 "dev": "parcel index.html --open",
 "build": "parcel build index.html"
}
  • Snabbdom的导入
// 因为snabbdom是commonJs语法,在项目中要是想用ES6模块的语法,要注意导入的方式

// commonJs
let snabbdom = require('snabbdom')

// ES6
import * as snabbdom from 'snabbdom'  或者
import { h, init, thunk} from 'snabbdom'

Snabbdom的使用

先简单的介绍一下常用的几个方法的功能

  • 使用 h() 函数创建 JavaScript 对象(VNode)描述真实 DOM
  • init() 设置模块,创建 patch()
  • patch() 比较新旧两个 VNode,它有两个参数,第一个参数可以是真实的DOM或者VNode,第二个参数是新的VNode
  • 把变化的内容更新到真实 DOM 树上 开始上代码
  • 先简单的写一个Hello World的例子。

在很简单的项目中是没必要使用Virtual DOM的,这里只是为了展示学习

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>snabbdom-demo</title>
</head>
<body>
  <div id="app"></div>
  <script src="./src/index.js"></script>
</body>
</html>
import * as snabbdom from 'snabbdom'

let patch = snabbdom.init([])

let vnode = snabbdom.h('div#app.app', 'Hello World')
let app = document.querySelector('#app')

let oldVnode = patch(app, vnode) // 保留上一次的vnode

setTimeout(() => { // 过两秒更新一下DOM
  vnode = snabbdom.h('div#app.app', 'Hello World1')
  oldVnode = patch(oldVnode, vnode)
}, 2000);

setTimeout(() => {
  vnode = snabbdom.h('div#app.app', 'Hello World2')
  oldVnode = patch(oldVnode, vnode)
}, 4000);
  • 来一个包含子节点的例子
import * as snabbdom from 'snabbdom'
// 下面是常用模块,还有一些在官网
import { classModule } from 'snabbdom/modules/class' // 可以根据变量动态切换的class
import { propsModule } from 'snabbdom/modules/props' // 设置不为布尔值的属性
import { attributesModule } from 'snabbdom/modules/attributes' // 设置不为布尔值的属性
import { datasetModule } from 'snabbdom/modules/dataset' // 设置data-*的属性
import { styleModule } from 'snabbdom/modules/style' // 设置内联样式
import { eventListenersModule } from 'snabbdom/modules/eventlisteners' // 注册和移除事件

let patch = snabbdom.init([
  classModule,
  propsModule,
  attributesModule,
  datasetModule,
  styleModule,
  eventListenersModule
])

function handleClick(num) {
  console.log('点击了' + num)
}

let app = document.querySelector('#app')

let vnode = snabbdom.h('div#app', {
  style: {
    color: 'pink'
  },
  dataset: {
    id: 1
  },
  props: {
    title: 'hello world'
  },
  on: {
    click: [handleClick, 1] // 如果不想传参数可以写成  handleClick
  }
},[
  snabbdom.h('p', '你好啊')
])

let oldVnode = patch(app, vnode)
  • 上一个可以排序,添加,删除的列表
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>snabbdom-demo</title>
  <style>
    *{
      margin: 0;
      padding: 0;
    }
    html,body{
      height: 100%;
      background-color: #000000;
      color: #ffffff;
      font-size: 14px;
    }
    #app{
      width: 600px;
      padding-top: 50px;
      margin-left: 100px;
    }
    .h1{
      margin-bottom: 15px;
    }
    .options_box{
      display: flex;
      align-items: center;
      justify-content: space-between;
      margin-bottom: 15px;
    }
    .options_box .left_btn {
      margin-right: 10px;
    }
    .options_box .left_btn,
    .options_box .right_btn{
      cursor: pointer;
      padding: 5px 10px;
      background-color: #1d1717;
    }
    .options_box .left_btn.active,
    .options_box .right_btn.active{
      background-color: #524e4e;
    }

    .list_box .list{
      background-color: #524e4e;
      margin-bottom: 10px;
    }
    .list_box .list .btn_box{
      overflow: hidden;
    }
    .list_box .list .btn_box .btn{
      cursor: pointer;
      float: right;
      padding: 5px 10px;
    }
    .list_box .list .row{
      display: flex;
      align-items: center;
      padding: 0 15px 15px;
    }
    .list_box .list .row .rank{
      width: 30px;
    }
    .list_box .list .row .title{
      flex: 1;
    }
    .list_box .list .row .desc{
      margin-left: 20px;
      flex: 3;
    }
  </style>
</head>
<body>
  <div id="app"></div>
  <script src="./src/index.js"></script>
</body>
</html>
import {h, init} from 'snabbdom'
// 下面是常用模块,还有一些在官网
import { classModule } from 'snabbdom/modules/class' // 可以根据变量动态切换的class
import { propsModule } from 'snabbdom/modules/props' // 设置不为布尔值的属性
import { styleModule } from 'snabbdom/modules/style' // 设置内联样式
import { eventListenersModule } from 'snabbdom/modules/eventlisteners' // 注册和移除事件

let patch = init([
  classModule,
  propsModule,
  styleModule,
  eventListenersModule
])

let oldVnode = null // 旧节点
let types = '' // 当前排序的条件

let tableData = [
  { rank: 3, title: 'The Godfather: Part II', desc: 'The early life and career of Vito Lake Tahoe, Nevada to pre-revolution 1958 Cuba.', elmHeight: 0 },
  { rank: 1, title: 'The Shawshank Redemption', desc: 'Two imprisoned men bond over a ', elmHeight: 0 },
  { rank: 7, title: '12 Angry Men', desc: 'A dissenting juror in a murder trial slowly ma as it seemed in court.', elmHeight: 0 },
  { rank: 4, title: 'The Dark Knight', desc: 'When the menace known as the Joker wreaks h ability to fight injustice.', elmHeight: 0 },
  { rank: 9, title: 'The Lord of the Rings: The Return of the King', desc: 'Gandalf and A from Frodo and Sam aDoom with the One Ring.', elmHeight: 0 },
  { rank: 6, title: 'Schindler\'s List', desc: 'In Poland during World War II, Oskar Schinessing their persecution by the Nazis.', elmHeight: 0 },
  { rank: 2, title: 'The Godfather', desc: 'The aging patriarch of an organized crime  son.', elmHeight: 0 },
  { rank: 8, title: 'The Good, the Bad and the Ugly', desc: 'A bounty hunting scam joins rtune in gold buried in a remote cemetery.', elmHeight: 0 },
  { rank: 5, title: 'Pulp Fiction', desc: 'The lives of two mob hit men, a boxer, a gangs violence and redemption.', elmHeight: 0 },
  { rank: 10, title: 'Fight Club', desc: 'An insomniac office worker looking for a way t they form an into something much, much more...', elmHeight: 0 },
]
let rankId = tableData.length // 最后一个rank的值

let app = document.querySelector('#app')

// 删除某一行
function handleRemove(row, index) {
  tableData.splice(index, 1)
  oldVnode = patch(oldVnode, returnVnode(tableData))
}
// 添加一行
function addRow() {
  let data = {
    rank: ++rankId,
    title: `我是添加的第条${rankId - 10}数据`,
    desc: `我是添加的第条${rankId - 10}数据`,
    elmHeight: 0
  }
  tableData.push(data)
  oldVnode = patch(oldVnode, returnVnode(tableData))
}
// 创建表的每一行
function tableRow(row, index) {
  return h('div.list', [
    h('p.btn_box', [
      h('span.btn', {
        on: {
          click: [handleRemove, row, index]
        }
      }, 'x')
    ]),
    h('div.row', [
      h('div.rank', row.rank),
      h('div.title', row.title),
      h('div.desc', row.desc),
    ])
  ])
}

// 排序
function changeSort(type) {
  types = type
  tableData.sort((a, b) => {
    if(typeof a[type] === 'number') {
      return a[type] - b[type]
    }else {
      return a[type].localeCompare(b[type])
    }
  })
  oldVnode = patch(oldVnode, returnVnode(tableData))
}

// 返回vnode
function returnVnode(data) {
  return h('div#app', [
    h('h1.h1', 'Top 10 movies'),
    h('div.options_box', [
      h('div.left', [
        h('span.left_btn', {
          class: {active: types === 'rank'},
          on: {
            click: [changeSort, 'rank']
          }
        }, 'rank'),
        h('span.left_btn', {
          class: {active: types === 'title'},
          on: {
            click: [changeSort, 'title']
          }
        }, 'title'),
      ]),
      h('span.right_btn',{
        on: {
          click: addRow
        }
      }, 'add')
    ]),
    h('div.list_box', data.map(tableRow))
  ])
}

oldVnode = patch(app, returnVnode(tableData))

Snabbdom的源码分析

Snabbdom的核心

  • 使用 h() 函数创建 JavaScript 对象(VNode)描述真实 DOM
  • init() 设置模块,创建 patch()
  • patch() 比较新旧两个 VNode,它有两个参数,第一个参数可以是真实的DOM或者VNode,第二个参数是新的VNode
  • 把变化的内容更新到真实 DOM 树上

Snabbdom的源码

│ h.ts h() 函数,用来创建 VNode
│ hooks.ts 所有钩子函数的定义
│ htmldomapi.ts 对 DOM API 的包装
│ is.ts 判断数组和原始值的函数
│ jsx-global.d.ts jsx 的类型声明文件
│ jsx.ts 处理 jsx
│ snabbdom.bundle.ts 入口,已经注册了模块
│ snabbdom.ts 初始化,返回 init/h/thunk
│ thunk.ts 优化处理,对复杂视图不可变值得优化
│ tovnode.ts DOM 转换成 VNode
│ vnode.ts 虚拟节点定义
│
├─helpers
│   attachto.ts 定义了 vnode.ts 中 AttachData 的数据结构
│
└─modules 所有模块定义
   attributes.ts
   class.ts
   dataset.ts
   eventlisteners.ts
   hero.ts example 中使用到的自定义钩子
   module.ts 定义了模块中用到的钩子函数
   props.ts
   style.ts

h 函数

  • h函数在使用vue的时候是经常见到的
new Vue({
 router,
 store,
 render: h => h(App)
}).$mount('#app')
  • snabbdom的h函数是用来创建vnode的,它利用了函数重载的思想,根据传入的参数个数或类型的不同,执行不同函数。
// 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 = {}, children: any, text: any, i: number;
 // 处理参数,实现重载的机制
 if (c !== undefined) {
  // 处理三个参数的情况
  // sel、data、children/text
  if (b !== null) { data = b; }
  if (is.array(c)) { children = c; }
  // 如果 c 是字符串或者数字
  else if (is.primitive(c)) { text = c; }
  // 如果 c 是 VNode
  else if (c && c.sel) { children = [c]; }
} else if (b !== undefined && b !== null) {
  // 处理两个参数的情况
  // 如果 b 是数组
  if (is.array(b)) { children = b; }
  // 如果 b 是字符串或者数字
  else if (is.primitive(b)) { text = b; }
  // 如果 b 是 VNode
  else if (b && b.sel) { 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);
};
// 导出模块
export default h;

VNode

一个VNode就是一个虚拟节点,用来描述一个DOM元素,下面分析一下snabbdom的vnode.ts

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 {
 let key = data === undefined ? undefined : data.key;
 return {sel, data, children, text, elm, key};
}
export default vnode;

Snabbdom.ts

分析一下snabbdom.ts文件

  • patch(oldVnode, newVnode)
  • 打补丁,把新节点中变化的内容渲染到真实 DOM,最后返回新节点作为下一次处理的旧节点
  • 对比新旧 VNode 是否相同节点(节点的 key 和 sel 相同)
  • 如果不是相同节点,删除之前的内容,重新渲染
  • 如果是相同节点,再判断新的 VNode 是否有 text,如果有并且和 oldVnode 的 text 不同,直接更新文本内容
  • 如果新的 VNode 有 children,判断子节点是否有变化,判断子节点的过程使用的就是 diff 算法
  • diff 过程只进行同层级比较

init 函数

  • init(modules, domApi),返回 patch() 函数(高阶函数)。modules是一个数组,数组里面是各种扩展插件,domApi是一个包含dom操作的对象

patch 函数

  • 功能
    • 传入新旧 VNode,对比差异,把差异渲染到 DOM
    • 返回新的 VNode,作为下一次 patch() 的 oldVnode
  • 执行过程
    • 首先执行模块中的钩子函数 pre
    • 如果 oldVnode 和 vnode 相同(key 和 sel 相同),调用 patchVnode(),找节点的差异并更新 DOM
    • 如果 oldVnode 是 DOM 元素。① 把 DOM 元素转换成 oldVnode ② 调用 createElm() 把 vnode 转换为真实 DOM,记录到 vnode.elm ③ 把刚创建的 DOM 元素插入到 parent 中 ④ 移除老节点,触发用户设置的 create 钩子函数
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);
  // 触发 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]();
 // 返回 vnode
 return vnode;
};

createElm 函数

  • 功能:createElm(vnode, insertedVnodeQueue),返回创建 vnode 对应的 DOM 元素
  • 执行过程
    • 首先触发用户设置的 init 钩子函数
    • 如果选择器是!,创建注释节点;如果选择器为空,创建文本节点
    • 如果选择器不为空。① 解析选择器,设置标签的 id 和 class 属性 ② 执行模块的 create 钩子函数 ③ 如果 vnode 有 children,创建子 vnode 对应的 DOM,追加到 DOM 树 ④ 如果 vnode 的 text 值是 string/number,创建文本节点并追击到 DOM 树 ⑤ 执行用户设置的 create 钩子函数 ⑥ 如果有用户设置的 insert 钩子函数,把 vnode 添加到队列中

patchVnode 函数

  • 功能:patchVnode(oldVnode, vnode, insertedVnodeQueue),对比 oldVnode 和 vnode 的差异,把差异渲染到 DOM
  • 执行过程
    • 首先执行用户设置的 prepatch 钩子函数
    • 执行模块的 create 钩子函数,再执行用户设置的 create 钩子函数
    • 如果设置了 vnode.text 并且和 oldVnode.text 不相等。如果老节点有子节点,全部移除,设置 DOM 元素的 textContent 为 vnode.text
    • 如果 vnode.text 未定义
      • 如果 oldVnode.children 和 vnode.children 都有值。调用 updateChildren(),使用 diff 算法对比子节点,更新子节点
      • 如果 vnode.children 有值。 oldVnode.children 无值。清空 DOM 元素,调用 addVnodes() ,批量添加子节点
      • 如果 oldVnode.children 有值, vnode.children 无值。调用 removeVnodes() ,批量移除子节点
      • 如果 oldVnode.text 有值。清空 DOM 元素的内容
function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue:VNodeQueue) {
 const hook = vnode.data?.hook;
 // 首先执行用户设置的 prepatch 钩子函数
 hook?.prepatch?.(oldVnode, vnode);
 const elm = vnode.elm = oldVnode.elm!;
 let oldCh = oldVnode.children as VNode[];
 let 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)) {
   // 使用 diff 算法对比子节点,更新子节点
   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);
}

updateChildren 函数

  • diff 算法的核心,对比新旧节点的 children,更新 DOM
  • 通过调试 updateChildren,我们发现不带 key 的情况需要进行两次 DOM 操作,带 key 的情况只需要更新一次 DOM 操作(移动 DOM 项),所以带 key 的情况可以减少 DOM 的操作,如果子项比较多,更能体现出带 key 的优势。

简述 Diff 算法的执行过程

diff算法是一种通过同层的树节点进行比较的高效算法,避免了对树进行逐层搜索遍历,所以时间复杂度只有 O(n)。

diff算法有两个比较显著的特点:

1、比较只会在同层级进行, 不会跨层级比较。

2、在diff比较的过程中,循环从两边向中间收拢。

diff流程:

1 、首先定义 oldStartIdx、newStartIdx、oldEndIdx 以及 newEndIdx 分别是新老两个 VNode 的两边的索引。

2、接下来是一个 while 循环,在这过程中,oldStartIdx、newStartIdx、oldEndIdx 以及 newEndIdx 会逐渐向中间靠拢。while 循环的退出条件是直到老节点或者新节点的开始位置大于结束位置。

while 循环中会遇到四种情况:

情形一:当新老 VNode 节点的 start 是同一节点时,直接 patchVnode 即可,同时新老 VNode 节点的开始索引都加 1。

情形二:当新老 VNode 节点的 end 是同一节点时,直接 patchVnode 即可,同时新老 VNode 节点的结束索引都减 1。

情形三:当老 VNode 节点的 start 和新 VNode 节点的 end 是同一节点时,这说明这次数据更新后 oldStartVnode 已经跑到了 oldEndVnode 后面去了。这时候在 patchVnode 后,还需要将当前真实 dom 节点移动到 oldEndVnode 的后面,同时老 VNode 节点开始索引加 1,新 VNode 节点的结束索引减 1。

情形四:当老 VNode 节点的 end 和新 VNode 节点的 start 是同一节点时,这说明这次数据更新后 oldEndVnode 跑到了 oldStartVnode 的前面去了。这时候在 patchVnode 后,还需要将当前真实 dom 节点移动到 oldStartVnode 的前面,同时老 VNode 节点结束索引减 1,新 VNode 节点的开始索引加 1。

3、while 循环的退出条件是直到老节点或者新节点的开始位置大于结束位置。

情形一:如果在循环中,oldStartIdx大于oldEndIdx了,那就表示oldChildren比newChildren先循环完毕,那么newChildren里面剩余的节点都是需要新增的节点,把[newStartIdx, newEndIdx]之间的所有节点都插入到DOM中

情形二:如果在循环中,newStartIdx大于newEndIdx了,那就表示newChildren比oldChildren先循环完毕,那么oldChildren里面剩余的节点都是需要删除的节点,把[oldStartIdx, oldEndIdx]之间的所有节点都删除