Virtual DOM
Virtual DOM(虚拟DOM),是由普通的js对象来描述DOM对象。
- 为什么要使用虚拟
DOM- 真实
DOM的创建需要花费很大代价 - 虚拟
DOM通过比较前后两次状态差异更新真实DOM,极大的减少了真实DOM的创建。
- 真实
- 虚拟
DOM的作用- 维护视图和状态的关系
- 复杂视图情况下提升渲染性能
- 实现跨平台
- 浏览器渲染
DOM - 服务端渲染
SSR(Nuxt.js/Next.js) - 原生应用(
Weex/React Native) - 小程序(
mpvue/uni-app)
- 浏览器渲染
- 虚拟
DOM库SnabbdomVue2.x内部使用改造后的Snabbdom- 源码体积小,只有
200L - 通过模块可扩展
- 源码使用
ts开发 - 最快的
Virtual DOM之一
virtual-dom:最早的虚拟DOM实现
Snabbdom的基本使用
-
安装:
yarn add/npm install snabbdom -
引入:通过
import引入相关的模块import { init } from 'snabbdom/build/package/init' import { h } from 'snabbdom/build/package/h'官方实例写法如下:
import { init } from 'snabbdom/init' import { h } from 'snabbdom/h'官方写法是因为使用了
webpack5的exports,设置了子路径映射,node12之后才支持。

-
init、h、patch函数的使用-
init: 接受一个模块数组,并返回一个patch函数const patch = init([]) -
h:创建虚拟DOM,接收三个参数,第一个是字符串类型的标签或者选择器,第二个参数是一个可选的选项对象,第三个参数是表示子元素,可以是一个字符串或一个数组,也是可选的。// 创建一个id为main的空div vnode = h('div#main') // 创建一个id为main的div,文本内容为Hello Vue vnode = h('div#main', 'Hello Vue') // 创建包含多个子元素的div vnode = h('div', [ 'Hello Vue', h('h1', 'children') ]) -
patch:init函数执行后的返回,接收两个参数,第一个参数是要被替换的真实DOM或虚拟DOM,第二个参数是新的虚拟DOMconst old = patch(container, vnode) patch(old, vnode)
-
-
模块
-
模块的作用
Snabbdom的核心库不能处理DOM元素的属性/样式/事件等,可以通过注册Snabbdom默认提供的模块来实现Snabbdom中的模块可以用来扩展Snabbdom的功能Snabbdom的模块是通过注册全局的钩子函数来实现,这些钩子函数在虚拟DOM的生命周期会被执行
-
官方提供的模块
attributes:设置DOM对象的属性,使用DOM的标准方法createAttribute()实现props:设置DOM对象的属性,只是是通过对象.的形式实现dataset:处理元素的自定义属性class:改变元素样式属性style:设置行内样式eventlisteners:添加事件监听。
-
模块的使用
- 导入:导入需要使用的模块
- 注册:在
init函数中注册 - 使用:在
h函数的第二个参数中传入选项数据来使用
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 pacth = init([ styleModule, eventListenersModule, ]) const container = document.getElementById('app') // 在h函数的第二个参数中传入选项数据 const vnode = h('div', [ h('h1', { style: { color: 'red' } }, 'Style Test'), h('p', { on: { click: clickHandler } }, 'Click Test') ]) function clickHandler() { console.log('click'); } pacth(container, vnode) ```
-
-
Snabbdom源码-
h函数 :h函数最早见于hyperscript,用于创建超文本。Snabbdom在此基础上对h函数进行改造,用于创建虚拟DOM(vnode)对象。h函数内部使用重载来实现函数的多种参数情况的调用。处理函数参数,并调用vnode函数生成vnode对象。 -
patch函数整体执行过程分析:patch(oldVnode, newVnode):把新节点中变化的内容渲染到真实DOM,并返回新节点作为下一次处理的旧节点。- 对比新旧
VNode,是否是相同节点,比较节点的key和sel是否相同 - 如果不是相同节点,删除之前的内容,重新渲染
newVnode。 - 如果是相同的节点,
newVnode中有text,再判断新旧节点的text是否相同,如果不同,直接更新文本内容。 - 如果
newVnode中有children,在判断子节点是否有变化。··
-
init(modules[, domApi]):modules表示引用的模块,domApi接收传入的dom操作方法对象,默认为htmlDomApi,也可以传入其他平台处理dom的方法对象,也是虚拟DOM实现跨平台的基础。-
首先定义了一个接收钩子回调函数的对象,用来接收各个钩子的回调函数。
const hooks: Array<keyof Module> = ['create', 'update', 'remove', 'destroy', 'pre', 'post'] let i: number let j: number const cbs: ModuleHooks = { create: [], update: [], remove: [], destroy: [], pre: [], post: [] } -
对
domApi进行处理,设置domApi的默认值。const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi -
遍历
modules,并将模块中的钩子函数保存到ModuleHooks中。// 对注册的模块进行遍历,并将模块中定义的钩子函数存入到保存钩子函数的对象中。 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) } } } -
返回
patch函数。
-
-
patch(oldVnode, vnode):oldVnode可以是真实的DOM或者虚拟DOM,vnode是要替换显示的虚拟DOM。-
定义变量,执行模块的
pre钩子函数。let i: number, elm: Node, parent: Node const insertedVnodeQueue: VNodeQueue = [] for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]() -
判断
oldVnode是否是Vnode节点,如果不是则将它转化为Vnode。if (!isVnode(oldVnode)) { oldVnode = emptyNodeAt(oldVnode) }判断是否是
Vnode只要判断对象中是否具有sel属性。function isVnode (vnode: any): vnode is VNode { return vnode.sel !== undefined }由真实
DOM创建Vnode只需要拼接选择器,然后调用vnode函数创建Vnode对象即可。function emptyNodeAt (elm: Element) { const id = elm.id ? '#' + elm.id : '' const c = elm.className ? '.' + elm.className.split(' ').join('.') : '' return vnode(api.tagName(elm).toLowerCase() + id + c, {}, [], undefined, elm) } -
判断转化后的
oldVnode和vnode是否是相同的Vnode- 如果相同,则比较两个
Vnode的差异,并将差异更新到视图。 - 如果不相同,则先根据
vnode创建真实DOM,然后将创建的真实DOM通过oldVnode的父元素添加到oldVnode的后面,并删除oldVnode。
if (sameVnode(oldVnode, vnode)) { patchVnode(oldVnode, vnode, insertedVnodeQueue) } else { elm = oldVnode.elm! parent = api.parentNode(elm) as Node createElm(vnode, insertedVnodeQueue) if (parent !== null) { api.insertBefore(parent, vnode.elm!, api.nextSibling(elm)) removeVnodes(parent, [oldVnode], 0, 0) } } - 如果相同,则比较两个
-
遍历更新到视图的
Vnode,并执行用户传入的insert钩子函数(通过vnode.data.hook指定)。for (i = 0; i < insertedVnodeQueue.length; ++i) { insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i]) }
这里的属性名后面加上
!的写法是typescript的语法,表示属性不是undefined或null。-
执行模块的
post钩子函数。并返回vnode对象。for (i = 0; i < cbs.post.length; ++i) cbs.post[i]() return vnode -
这里有两种钩子函数,一种是模块提供的
vnode的生命周期钩子函数,以及用户传入的生命周期钩子,类似Vue的生命周期钩子函数。vnode生命周期钩子函数是注册模块中提供的,然后保存在cbs对象中,包括['create', 'update', 'remove', 'destroy', 'pre', 'post']。- 用户传入的生命周期钩子函数是创建
vnode对象时通过第二个参数data传入的。定义在data.hook中,包括init、create、insert、remove等。
-
-
createElm(vnode, insertedVnodeQueue):根据虚拟DOM创建真实DOM,并将真实DOM对象保存在vnode对象的elm属性中。vnode是要创建为真实DOM的Vnode对象,insertedVnodeQueue保存用户传入了insert钩子的vnode对象。-
首先定义变量,并执行用户传入的
init钩子函数,这里执行用户传入的init钩子时可能修改data,所有需要对data进行重新赋值。let i: any let data = vnode.data // 判断并执行init钩子 if (data !== undefined) { const init = data.hook?.init if (isDef(init)) { init(vnode) data = vnode.data//可能修改`data`,所有需要对`data`进行重新赋值 } } -
然后要根据
vnode对象来创建真实的DOM节点,并保存到vnode.elm中。 这里按节点的类型分为三种情况,注释节点、元素节点以及文本节点。-
注释节点,当
sel属性为!时,认为要创建注释节点,然后需要处理一下vnode.text,没有定义时需要给一个空字符的默认值。然后通过createComment来创建一个注解节点。if (sel === '!') { // 判断是否是注释节点 if (isUndef(vnode.text)) { // 是否有注释文本 vnode.text = '' } vnode.elm = api.createComment(vnode.text!) // 创建注释DOM } -
文本节点,当
sel没有定义时,即为undefined,认为要创建一个文本节点。然后根据vnode.text创建文本节点即可。vnode.elm = api.createTextNode(vnode.text!) -
元素节点,除了上述两种情况外,都认为要创建元素节点。
- 首先根据
sel选择器解析出需要创建的元素标签名,以及id和class。 - 再根据
data.ns命名空间属性来判断是否要根据命名空间来创建节点,将id和class属性添加到节点上; - 判断是否具有子元素或文本内容,如果存在子元素节点,递归遍历并创建子元素节点添加到当前节点,如果存在文本内容,则根据文本内容创建子节点并添加到当前节点。如果存在子元素就不存在文本内容,存在文本内容就不存在子元素,这两者是互斥的。
- 最后如果用户传入了
insert钩子函数,则将vnode对象添加到insertedVnodeQueue中。
if (sel !== undefined) { // 根据选择器创建真实DOM元素, div#conatienr.main.active // Parse selector // 创建DOM元素 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) // ns表示命名空间 ? api.createElementNS(i, tag) // 如果有命名空间,根据命名空间创建DOM元素 : api.createElement(tag) if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot)) // 设置id属性 if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' ')) // 设置class样式 for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode) // 执行create钩子函数 // 判断是否存在子节点,如果存在子节点,递归向本节点添加子节点创建的真实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)) { // 判断是否有文本内容,如果有文本内容,创建文本节点,并将其添加到当前节点 api.appendChild(elm, api.createTextNode(vnode.text)) } const hook = vnode.data!.hook if (isDef(hook)) { hook.create?.(emptyNode, vnode) if (hook.insert) { insertedVnodeQueue.push(vnode) } } } - 首先根据
-
-
返回
vnode.elm。
-
-
removeVnodes (parentElm: Node, vnodes: VNode[], startIdx: number, endIdx: number):从父节点中删除子节点,parentElm父元素节点,是一个真实的DOM节点,vnodes是要删除的子节点的数组,startIdx是要删除的子节点的起始下标,endIdx是要删除的子节点的结束下标。-
从
startIdx开始遍历每个要删除的子节点,对非空节点进行处理。 -
如果子节点是文本节点,则直接删除节点即可。
-
子节点为非文本节点时,在删除每个子节点之前,需要先执行当前节点及其子节点中用户传入的
destroy钩子函数。// 执行虚拟DOM及其子元素的destroy钩子,和cbs中的destroy钩子 function invokeDestroyHook (vnode: VNode) { const data = vnode.data if (data !== undefined) { data?.hook?.destroy?.(vnode) for (let i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode) if (vnode.children !== undefined) { for (let j = 0; j < vnode.children.length; ++j) { const child = vnode.children[j] if (child != null && typeof child !== 'string') { invokeDestroyHook(child) } } } } } -
然后执行模块的
remove钩子函数以及用户传入的remove钩子函数,最后再移除节点元素。这里为了防止重复删除节点,将生成的删除节点的函数rm传入到remove钩子函数中,在remove钩子函数中调用rm删除节点。这样设计的原因是在remove钩子中可能存在异步任务(如动画),需要等待异步任务执行完毕之后才能移除节点。在rm函数内部使用闭包的原理记录模块中remove钩子的数量listeners,每次调用rm函数时,listeners会自减,当listeners为零时才会执行从真实DOM移除节点的操作。// 创建删除元素的方法 function createRmCb (childElm: Node, listeners: number) { return function rmCb () { if (--listeners === 0) { // 判断remove钩子是否都执行完了,防止重复删除,模块的remove钩子中可能存在异步的任务,需要等待这些任务执行完成之后才能删除元素。 const parent = api.parentNode(childElm) as Node api.removeChild(parent, childElm) // 删除元素 } } }
removeVnodes完整代码// 从父节点移除虚拟数组中指定范围的元素对应的真实DOM 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) // 执行destroy钩子 listeners = cbs.remove.length + 1 // 记录remove钩子个数 rm = createRmCb(ch.elm!, listeners) for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm) // 执行虚拟DOM remove钩子函数 // 用户传入的 remove 钩子 const removeHook = ch?.data?.hook?.remove if (isDef(removeHook)) { removeHook(ch, rm) // 这里为什么不执行子节点用户传入的remove的钩子呢 } else { rm() } } else { // Text node api.removeChild(parentElm, ch.elm!) } } } } -
-
patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue):对比oldNode和vnode,并更新差异部分视图。-
首先执行用户传入的
prepatch钩子函数。判断两个节点是否相同,如果不相同则执行模块和用户传入的update钩子函数。const hook = vnode.data?.hook hook?.prepatch?.(oldVnode, vnode) const elm = vnode.elm = oldVnode.elm! const oldCh = oldVnode.children as VNode[] const ch = vnode.children as VNode[] if (oldVnode === vnode) return // 执行模块及用户传入的update if (vnode.data !== undefined) { for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode) vnode.data.hook?.update?.(oldVnode, vnode) } -
开始比较新旧节点。根据
vnode中是否具有文本内容来进行判断,文本内容和子元素节点时互斥的。-
如果
vnode中没有文本内容,再根据是否具有子元素节点来进行处理。结合oldVnode的子节点和文本内容是否存在有以下几种情况。vnode存在子元素节点,oldVnode也存在子元素节点,需要对比二者的子元素节点,更新差异部分。vnode存在子元素节点,oldVnode存在文本内容,设置文本内容为空,并添加vnode的子元素节点。vnode不存在子元素节点,oldVnode存在子元素节点,删除oldVnode的子元素节点。vnode不存在子元素节点,oldVnode存在文本内容,将文本内容设置为空。
// 更新元素子节点 if (isUndef(vnode.text)) { // 新节点中不存在文本节点 if (isDef(oldCh) && isDef(ch)) { // 新旧节点都存在子节点,对比子节点 if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue) } else if (isDef(ch)) {// 新节点中存在子节点,旧节点中存在文本节点 if (isDef(oldVnode.text)) api.setTextContent(elm, '') // 先将文本节点内容设置为空 addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)// 将新节点中的子节点添加到真实DOM中。 } else if (isDef(oldCh)) { // 如果旧节点中存在子节点,移除旧节点的子节点 removeVnodes(elm, oldCh, 0, oldCh.length - 1) } else if (isDef(oldVnode.text)) { // 如果旧节点中存在文本节点,将文本内容设置为空 api.setTextContent(elm, '') } } -
如果
vnode中有文本内容,则判断一下oldVnode中是否有子元素节点,如果有的话需要移除oldVnode中的子元素节点,来触发节点移除相关的钩子函数。然后再设置文本内容为vnode的文本内容。else if (oldVnode.text !== vnode.text) {// 新旧节点文本内容不同, if (isDef(oldCh)) { // 如果旧节点中存在子元素节点,移除子元素节点 removeVnodes(elm, oldCh, 0, oldCh.length - 1) } api.setTextContent(elm, vnode.text!) // 设置文本内容为新节点的文本内容。 }
-
-
最后执行用户传入的
postpatch钩子。
-
-
function updateChildren (parentElm: Node, oldCh: VNode[], newCh: VNode[], insertedVnodeQueue: VNodeQueue):更新子元素的差异部分。函数函数内部定义了四个指针变量,分别是指向oldCh开始位置的oldStartIdx以及结束位置的oldEndIdx,和指向newCh开始位置的newStartIdx以及结束位置的newEndIdx。然后通过判断指针指向的节点是否相同来进行相应的操作,有以下几种判断情况:oldStartIdx和newStartIdx指向的节点是相同节点,更新节点,oldStartIdx和newStartIdx向后移动一位,再次进行判断。oldEndIdx和newEndIdx指向的节点是相同节点,更新节点,oldEndIdx和newEndIdx向前移动一位,再次进行判断。oldStartIdx和newEndIdx指向的节点是相同节点,更新节点,将oldStartIdx位置的节点移动到旧子节点的最后位置,oldStartIdx向后移动一位,newEndIdx向前移动一位。然后再次重复上面的判断过程。oldEndIdx和newStartIdx指向的节点是相同节点,更新节点,将oldEndIdx位置的节点移动到旧子节点的最前面。oldEndIdx向前移动一位,newStartIdx向后移动一位。- 如果上面的情况都不满足,则根据
key来查找旧子节点的[oldStartIdx, oldEndIdx]范围内中有没有相同的key。- 如果能找到相同的
key, 则根据sel属性来判断是否是同一节点,如果是同一节点,则更新节点,并将更新后的节点移动到newStartIdx之前的位置。 需要注意的是,更新节点只会更新子节点或文本内容,不会更新节点的状态。 如果不是同一节点,则直接在newStartIdx之前插入新节点。 - 如果找不到到相同的
key,表示是一个新增的节点,则直接在newStartIdx之前插入新节点。 - 最后经过上述处理之后,
newStartIdx向后移动一位。
- 如果能找到相同的
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { if (oldStartVnode == null) { // 将oldStartIdx定位到第一个非空子节点 oldStartVnode = oldCh[++oldStartIdx] // Vnode might have been moved left } else if (oldEndVnode == null) {// 将oldEndIdx定位到最后一个非空子节点 oldEndVnode = oldCh[--oldEndIdx] } else if (newStartVnode == null) {// 将newStartIdx定位到第一个非空子节点 newStartVnode = newCh[++newStartIdx] } else if (newEndVnode == null) {// 将newEndIdx定位到最后一个非空子节点 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)) { // 旧子节点的开始节点和新子节点的结束节点相同, patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue) // 在旧子节点的基础上,更新开始节点的内容, // 将旧子节点的开始节点移动到旧子节点的结束节点之后 api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!)) oldStartVnode = oldCh[++oldStartIdx] // 旧子节点开始指针向后移 newEndVnode = newCh[--newEndIdx] // 新子节点结束指针向前移 } else if (sameVnode(oldEndVnode, newStartVnode)) { // 旧子节点的结束节点和新子节点的开始节点相同, patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue) // 在旧子节点的基础上,更新开始节点的内容, // 将旧子节点的结束节点移动到旧子节点的开始节点之前 api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!) oldEndVnode = oldCh[--oldEndIdx]// 旧子节点结束指针向前移 newStartVnode = newCh[++newStartIdx]// 新子节点开始指针向后移 } else { if (oldKeyToIdx === undefined) { // 创建一个旧子节点的key和index映射关系的对象。 oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) } idxInOld = oldKeyToIdx[newStartVnode.key as string] // 在旧子节点中定位新子节点开始节点的位置 if (isUndef(idxInOld)) { // 新子节点开始节点是一个新增节点 // 创建节点,并将节点插入到旧子节点开始节点之前 api.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 any // 将对应位置上的节点置空 // 移动更新的节点到旧节点的开始位置 api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!) } } newStartVnode = newCh[++newStartIdx]// 新子节点开始指针向后移 } }循环进行上述判断,当
oldCh遍历完成(oldStartIdx > oldEndIdx),或newCh遍历完成(newStartIdx > newEndIdx)时标志循环结束。这里又分为3中情况:oldStartIdx > oldEndIdx和newStartIdx > newEndIdx都成立,表示oldCh和newCh都遍历完成,所以不需要进行其他处理。- 只有
oldStartIdx > oldEndIdx成立,表示newCh还没有处理完,[newStartIdx , newEndIdx]范围内节点需要添加到对应的位置,即前一个节点之前。 - 只有
newStartIdx > newEndIdx成立,表示表示oldCh还没有处理完,需要移除[newStartIdx , newEndIdx]范围内节点。
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) } }
-

2万+

被折叠的 条评论
为什么被折叠?



