1 什么是虚拟 DOM
Virtual Dom(虚拟DOM)是由普通的 JS 对象来描述 DOM 对象,创建虚拟 DOM的开销比真实DOM 的开销小很多
2 为什么要使用虚拟 DOM
- 前端开发旧的时代
- MVVM 框架解决视图和状态同步的问题
- 模板引擎可以简化视图操作,没办法跟踪状态
- 虚拟DOM 跟踪状态变化,用 diff 算法来有效更新 DOM
参考 github 上 virtual-dom 的动机描述
维护视图和状态的关系
- 复杂视图情况下提升渲染性能
- 跨平台
- 浏览器平台渲染 DOM
- 服务端渲染 SSR (nust.js/next.js)
- 原生应用 (weex/React Native)
- 小程序(mpvue/uni-app)等
虚拟 DOM 库
- Snabbdom
- Vue.js 2.x 内部使用的虚拟 DOM 就是改造的 Snabbdom
- 大约 200 SLOC(single line of code)
- 通过模块可扩展
- 源码使用 TypeScript 开发
- 最快的 Virtual Dom 之一
- virtual-dom
4 Snabbdom 的基本使用
- 安装 parcel
初始化package.json yarn init —yes
安装parcel yarn add parcel-bundler -D
- 配置 scripts
package.json 的scripts 中添加两条命令
..."scripts": {"dev": "parcel index.html --open","build": "parcel build index.html"},...
- 目录结构
根目录新建 index.html,src 目录,src下新建js
- 安装 snabbdom
yarn add snabbdom —save
- js 中引入 snabbdom ```javascript // nodejs 12以上的版本才支持 package.json 中的export webpack 5支持 // import { init } from ‘snabbdom/init’ // import { init } from ‘snabbdom/h’ // 改为实际路径 import { init } from ‘snabbdom/build/package/init’ import { h } from ‘snabbdom/build/package/h’;
const path = init([])
<a name="zf56X"></a>#### 5 snabbdom 中 init/h/path 的使用```javascriptimport { init } from 'snabbdom/build/package/init'import { h } from 'snabbdom/build/package/h';const patch = init([])// h 函数// 第一个参数:标签加选择器// 第二个参数:如果是字符串就是标签中的文本内容let vnode = h('div#container.cls','hello world')// 获取挂载点let app = document.querySelector('#app')// 对比两个vnode,比较两个vnode 的差异,更新真实dom// 第一个参数:旧VNODE,可以是 dom 元素// 第二个参数:新的VNODE,// 返回新的 VNODElet oldVNode = patch(app, vnode)vnode = h('div#container.container','hello there')patch(oldVNode, vnode)
6 snabbdom 中嵌套节点、异步更新视图及清除视图
import { init } from 'snabbdom/build/package/init'import { h } from 'snabbdom/build/package/h';const patch = init([])// 通过数组中h函数调用嵌套视图let vnode = h('div#container.cls',[h('h1','hello world'),h('p', '这是一个p')])let app = document.querySelector('#app')let oldVNode = patch(app, vnode)setTimeout(()=>{// vnode = h('div#container.container',[// h('h1','hello there'),// h('p', 'hello p')// ])// patch(oldVNode, vnode)// 清除div 中的内容patch(oldVNode, h('!'))},2000)
7 模块的使用
- 模块的作用
- Snabbdom 的核心库并不能处理 DOM 元素的属性/样式/事件等,可以通过注册 Snabbdom 默认提供的模块来实现
- Snabbdom 中的模块可以用来扩展 Snbbdom 的功能
- Snabbdom 中的模块的实现是通过注册全局的钩子函数来实现的
- 官方提供的模块
- attributes 设置 DOM 的属性,通过attributes 来设置顺序,判断 Boolean 类型的属性
- props 设置DOM 的属性,通过对象名-属性的方式设置属性的,不会判断 Boolean 类型的属性
- dataset 处理h5 中 data-attr 类属性
- class 切换类样式
- style 设置行内样式
- eventlisteners 处理事件的
- 模块的使用步骤
- 导入需要的模块
- init() 中注册模块
- h() 函数的第二个参数处,处理模块,通过第二个参数设置为对象来设置 ```javascript 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)
<a name="gk55P"></a>#### 8 阅读 Snabbdom 源码如何阅读源码- 宏观了解- 带着目标看源码- 看源码的过程要不求甚解- 调试- 参考资料Snabbdom 的核心- init() 设置模块,创建 patch() 函数- 使用 h() 函数创建 JavaScript 对象(vnode)描述真实dom- patch() 比较新旧两个vnode- 把变化的内容更新到真实的 DOM 树上<a name="u1ZAT"></a>#### 9 h 函数- 作用: 创建 vnode 对象- vue 中的h 函数- h 函数最早见于 hyperscript,使用 JavaScript 创建文本函数重载- 参数个数或者参数类型不同的函数- JavaScript 中没有重载的概念- TypeScript 中有重载,不过重载的实现是通过代码调整参数```javascript...function addNS (data: any, children: VNodes | undefined, sel: string | undefined): void {...}// h 函数的重载export function h (sel: string): VNodeexport function h (sel: string, data: VNodeData | null): VNodeexport function h (sel: string, children: VNodeChildren): VNodeexport function h (sel: string, data: VNodeData | null, children: VNodeChildren): VNodeexport function h (sel: any, b?: any, c?: any): VNode {var data: VNodeData = {}var children: anyvar text: anyvar i: number// 处理参数实现重载机制if (c !== undefined) {// 三个参数的情况// sel data children/textif (b !== null) {data = b}// 如果c 是 数组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) {// 两个参数的情况if (is.array(b)) {children = b} else if (is.primitive(b)) {text = b} else if (b && b.sel) {children = [b]} else { data = b }}if (children !== undefined) {// 判断 children 是否有值for (i = 0; i < children.length; ++i) {// 如果是字符串或者数字,创建文本节点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)}// 返回 vnodereturn vnode(sel, data, children, text, undefined)};
10 vnode
vnode 中,引入了一些模块,定义了 VNode 接口和 VNodeData 接口,然后导出了一个 vnode 函数,函数中通过data.key 获取key,然后导出了一个vnode 对象
11 patch 整体过程分析
- patch(oldNode, newNode)
- 把新节点中变化的内容渲染到真实的DOM,最后返回新节点作为下次处理的旧节点
- 对比新旧 VNode 是否相同节点(节点的key和sel)相同
- 如果不是相同节点,删除之前的内容重新渲染
- 如果是相同节点,判断新的 VNode 是否有 text ,如果有并且和 oldNode 的 text 不同,直接更新文本内容
如果新的 VNode 有children,判断子节点是否变化
12 init 函数
init 函数接受两个参数,模块和api,api的作用是把vnode 转换成其他平台的元素,不传的时候默认浏览器环境下的html DOM 操作 api
- cbs 对象,对象的键是方法上面hooks 数组的成员,然后遍历模块数组,把每个模块中的 hooks 成员存到 cbs 对象的对应数组中
之后定义了一些内部方法,最后返回一个patch(oldNode,newNode) 函数,此处用到高阶函数,通过init 函数初始化 modules和domapi,然后再后续调用patch 的时候内存中就会有了这个两个成员,直接传oldNode 和 newNode 就可以了
export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {let i: numberlet j: numberconst cbs: ModuleHooks = {create: [],update: [],remove: [],destroy: [],pre: [],post: []}const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApifor (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)}}}function emptyNodeAt ...function createRmCb ...function createElm ...function addVnodes ...function invokeDestroyHook ...function removeVnodes ...function updateChildren ...function patchVnode ...return function patch (oldVnode: VNode | Element, vnode: VNode)...}
13 patch 函数
patch 函数接收两个参数,oldNode|elemet 和 newNode
- 函数内部定义了几个内部成员和新插入节点的队列数组,队列的目的是为了后续触发节点上用户定义的钩子函数
- 预处理,遍历 init 函数中定义的 cbs 对象中 pre 属性对应的钩子数组,并且执行
- 判断oldnode 是否是 vnode 对象,不是的话将dom对象转换为vnode 对象
- 判断新旧node 是否相同(key和sel)相同,如果是相同的则对比内容是否有变化,然后将变化的内容更新到dom;如果不是相同的,则获取父节点,并且调用createElm 方法生成新的dom挂载到新的节点elm上面,然后把新节点插入到父节点下的旧节点的兄弟元素位置,移除旧的节点
- 然后遍历插入节点的数组,调用 节点的data下的hook下的insert方法
最后遍历cbs 的post 属性数组,调用post 对应数组的钩子
...return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {let i: number, elm: Node, parent: Nodeconst insertedVnodeQueue: VNodeQueue = []// 执行pre 中的钩子函数for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()// 判断是否vnode节点if (!isVnode(oldVnode)) {// dom 转换为vnode 节点oldVnode = emptyNodeAt(oldVnode)}//判断是否相同的vnode key/sel 相等if (sameVnode(oldVnode, vnode)) {// 更新变化的内容patchVnode(oldVnode, vnode, insertedVnodeQueue)} else {elm = oldVnode.elm!parent = api.parentNode(elm) as Node // 父节点// 创建真实domcreateElm(vnode, insertedVnodeQueue)// 新节点插入dom 树并且移除旧节点if (parent !== null) {// 将新节点的dom 插入到父节点中、旧节点后api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))// 移除旧节点removeVnodes(parent, [oldVnode], 0, 0)}}// 遍历新节点队列,调用data中hook中的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}
14 createElm 函数
执行用户设置的init钩子函数
- 把vnode 转换成真实的的 dom 对象
返回创建的dom
function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {// 执行用户设置的init钩子函数let i: anylet data = vnode.dataif (data !== undefined) {const init = data.hook?.initif (isDef(init)) {init(vnode)data = vnode.data}}const children = vnode.children// 把vnode 转换成真实的 dom 对象const sel = vnode.selif (sel === '!') {// 注释节点if (isUndef(vnode.text)) {vnode.text = ''}// 挂载空节点到vnode的elm上vnode.elm = api.createComment(vnode.text!)} else if (sel !== undefined) {// 解析选择器// Parse selectorconst hashIdx = sel.indexOf('#')const dotIdx = sel.indexOf('.', hashIdx)const hash = hashIdx > 0 ? hashIdx : sel.lengthconst dot = dotIdx > 0 ? dotIdx : sel.lengthconst 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)// 添加id和classif (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot))if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' '))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)) {// text 类型是字符串或则数字则直接插入到文本节点api.appendChild(elm, api.createTextNode(vnode.text))}const hook = vnode.data!.hookif (isDef(hook)) {hook.create?.(emptyNode, vnode)if (hook.insert) {insertedVnodeQueue.push(vnode)}}} else {// 选择器为空则创造文本节点vnode.elm = api.createTextNode(vnode.text!)}// 返回新创建的domreturn vnode.elm}
15 removeVnodes 和 addVnodes
removeVnodes
接受4个参数:父节点、要删除的节点数组、删除开始的下标、结束的下标
- 从开始下标到结束下标遍历传入的节点数组,判断节点是否为null,为null 时为文本节点,直接删除,不为null 则判断sel 是否定义
- 如果有 sel ,执行节点 data 属性的hook 中的destroy 方法,然后遍历 cbs 数组中 destroy 属性的数组,并执行其中的钩子函数,有children 则递归执行
- 通过 createRmCb 高阶函数,返回真实删除dom 的函数,并在函数内部记录 cbs 中的remove 钩子函数数量,然后赋值给rm 函数
- 遍历 cbs 中remove 数组,执行钩子函数
判断data.hook.remove 是否定义,如果定义了则把节点和rm函数传入并调用,否则直接调用rm 函数
function removeVnodes (parentElm: Node,vnodes: VNode[],startIdx: number,endIdx: number): void {for (; startIdx <= endIdx; ++startIdx) {let listeners: numberlet rm: () => voidconst ch = vnodes[startIdx]if (ch != null) {if (isDef(ch.sel)) {invokeDestroyHook(ch)listeners = cbs.remove.length + 1rm = createRmCb(ch.elm!, listeners)for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm)const removeHook = ch?.data?.hook?.removeif (isDef(removeHook)) {removeHook(ch, rm)} else {rm()}} else { // Text nodeapi.removeChild(parentElm, ch.elm!)}}}}
addVnodes
接收6个参数:父节点、插入位置的节点、插入的节点数组、开始下标、结束下标、插入节点队列
从开始下标到结束下标循环,获取到插入的节点数组中的对应节点,不为空则直接调用插入的方法
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) {api.insertBefore(parentElm, createElm(ch, insertedVnodeQueue), before)}}}
16 patchVnode
触发prepatch、update 钩子函数
- 新节点有text 属性且不等于旧节点的 text 属性时,如果老节点有children,移除老节点的children对应的 DOM 元素,设置新节点对应 DOM 元素的 textContent
- 新老节点都有children 且不相等时,调用 updateChildren 方法,对比子节点并且更新子节点的差异
- 只有新节点有 children时,如果老节点有 text属性,清空对应 DOM 元素的 textContent,添加所有的子节点
- 只有老节点有 children 时,移除所有的老节点
- 只有老节点有 text 属性时,清空对应 DOM 元素的 textcontent
触发 postpatch 钩子函数
function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {// 第一个过程 触发 prepatch 和 update 钩子函数const hook = vnode.data?.hookhook?.prepatch?.(oldVnode, vnode)const elm = vnode.elm = oldVnode.elm!const oldCh = oldVnode.children as VNode[]const ch = vnode.children as VNode[]if (oldVnode === vnode) returnif (vnode.data !== undefined) {for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)// 模块和用户都可以设置 update 钩子函数,用户设置的后执行是模块可能会先操作dom,之后用户再操作去覆盖vnode.data.hook?.update?.(oldVnode, vnode)}// 第二个过程:真正对比新旧 Vnodeif (isUndef(vnode.text)) {// 新节点的 text 不存在if (isDef(oldCh) && isDef(ch)) {// 新旧节点都存在children// 对比新旧节点的所有子节点并更新 DOMif (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue) // 核心代码} else if (isDef(ch)) {// 新节点的 children 存在// 老节点有 textif (isDef(oldVnode.text)) api.setTextContent(elm, '')addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)} else if (isDef(oldCh)) {// 旧节点的 children 存在removeVnodes(elm, oldCh, 0, oldCh.length - 1)} else if (isDef(oldVnode.text)) {// 旧节点的 text 存在api.setTextContent(elm, '')}} else if (oldVnode.text !== vnode.text) {// 新旧节点的 text 不相等if (isDef(oldCh)) {// 旧节点是否有子节点removeVnodes(elm, oldCh, 0, oldCh.length - 1)}// 插入新节点api.setTextContent(elm, vnode.text!)}// 第三过程:触发 postpatch 钩子函数hook?.postpatch?.(oldVnode, vnode)}
17 updateChildren 函数
diff 算法
虚拟 DOM 中的 Diff 算法
- 查找两棵树中的每个节点的差异
- Snabbdom 根据 Dom 的特点对传统的 diff 算法做了优化
- Dom 操作时候很少会跨级别操作节点
- 只比较同级别的节点
执行过程
- 在对开始和结束节点比较的时候,总共有四种情况
- oldStartVnode / newStartVnode
- oldEndVnode / newEndVnode
- 如果新旧开始节点是 samenode(key/sel相同)
- 调用 patchVnode() 对比和更新节点
- 把旧开始和新开始索引往后移动 oldStart++ / newStart++
- 如果新旧结束节点是 samenode
- 调用 patchVnode() 对比和更新节点
- 把旧开始和新开始索引往前移动 oldStart— / newStart—
- 如果新旧开始节点是 samenode(key/sel相同)
- oldStartVnode / newEndVnode
- 调用 patchVnode() 对比和更新节点
- 把 oldStartVnode 对应的 DOM 元素,移到右边,更新索引
- oldEndVnode / newStartVnode
- 调用 patchVnode() 对比和更新节点
- 把 oldEndVnode 对应的 DOM 元素,移动到左边,更新索引
- 非上述四种情况说明开始和结束节点都不同
- 拿新节点的开始去跟旧节点数组中的每个节点对比,判断是否samevnode
- 如果不是则创建新的dom插入到旧节点的第一个位置
- 如果是samevnode则递归调用,找到更新的元素更新
- 循环结束
- 当老节点的所有子节点先遍历完(oldStartIndex > oldEndIndex),循环结束
- 如果老节点的数组先遍历完(oldStartIndex>oldEndIndex)
- 说明新节点有剩余,把剩余节点批量插入到右边
- 如果老节点的数组先遍历完(oldStartIndex>oldEndIndex)
- 新节点的所有子节点先遍历完(newStartIndex > newEndIndex),循环结束
- 如果新节点的数组先遍历完
- 说明老节点有剩余,把剩余节点批量删除
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 | undefinedlet idxInOld: numberlet elmToMove: VNodelet before: any// 同级别节点比较// 新旧节点都没有遍历完 则开始循环while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {// 先判断 nullif (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)) {// 旧开始等于新开始// 比较差异并更新dompatchVnode(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 rightpatchVnode(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 leftpatchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!)oldEndVnode = oldCh[--oldEndIdx]newStartVnode = newCh[++newStartIdx]} else {// 开始和结尾比较结束if (oldKeyToIdx === undefined) {oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)}idxInOld = oldKeyToIdx[newStartVnode.key as string]if (isUndef(idxInOld)) {// New elementapi.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)} else {elmToMove = oldCh[idxInOld]if (elmToMove.sel !== newStartVnode.sel) {api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)} else {patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)oldCh[idxInOld] = undefined as anyapi.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)}}}
18 key的意义
不添加key的时候,对于 samevnode 的节点,会最大程度的重用,此时子节点如果有某种被改变了的状态也会保留进新的节点中,导致整体的状态与预期不一致。
- 说明老节点有剩余,把剩余节点批量删除
- 如果新节点的数组先遍历完
- 当老节点的所有子节点先遍历完(oldStartIndex > oldEndIndex),循环结束
