Snabbdom 是 Vue 使用的虚拟 DOM 库,专注提供简单、模块性的体验,以及强大的功能和性能。
虚拟 DOM
Virtual DOM(虚拟 DOM),是由普通的 JS 对象来描述 DOM 对象。真实的 DOM 对象,成员非常多,创建这样的对象成本是非常高的。使用 Virtual DOM 来描述 DOM,虚拟 DOM 对象就是一个 JS 对象,相比之下,成员少了很多,创建这样一个对象的成本要小很多。
虚拟 DOM 可以维护程序的状态,跟踪上一次的状态,通过比较前后两次状态差异更新真实 DOM。维护状态,更新视图。
作用
- 维护视图和状态的关系
- 复杂视图情况下提升渲染性能
- 跨平台
- 浏览器平台渲染 DOM
- 服务端渲染 SSR(Nuxt.js/Next.js)
- 原生应用(Weex/React Nelative)
- 小程序(mpvue/uni-app)
安装
yarn add snabbdom
基本使用
snabbdom 库的三个核心函数:init,h,patch。init 函数就是 snabbdom 的初始化函数;patch 是 init 函数返回的一个函数,它把虚拟 DOM 转换成真实 DOM,并挂载到 DOM 树上;h 函数创建并返回一个 VNode 虚拟节点。
import { init } from 'snabbdom/build/package/init'import { h } from 'snabbdom/build/package/h'const patch = init([])// 第一个参数:标签+选择器// 第二个参数:如果是字符串就是标签中的文本内容let vnode = h('div#container.cls', 'Hello World!')let app = document.querySelector('#app')// 第一个参数:旧的 VNode,可以是 DOM 元素// 第二个参数:新的 VNode// 返回新的 VNodelet oldVNode = patch(app, vnode)vnode = h('div#container.xxx', 'Hello Snabbdom')patch(oldVnode, vnode)
h 函数可以给元素嵌套生成子节点。传入 !可以创建一个空的注释节点<!---->。
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(() => {// vnode = h('div#container', [// h('h1', 'Hello World'),// h('p', 'Hello P')// ])// patch(oldVnode, vnode)// 清除div中的内容patch(oldVnode, h('!')) // ! 生成一个空的注释节点:<!---->}, 2000);
插件
- Snabbdom 的核心库并不能处理 DOM 元素的属性/样式/事件等,可以通过注册 Snabbdom 默认提供的模块来实现。
- Snabbdom 中的模块可以用来扩展 Snabbdom 的功能。
- Snabbdom 中的模块的实现是通过注册全局的钩子函数来实现的。
使用步骤
- 导入需要的模块
- init() 中注册模块
- h() 函数的第二个参数处使用模块
import { init } from 'snabbdom/build/package/init'import { h } from 'snabbdom/build/package/h'// 使用 style 模块改变元素的样式,使用 eventListeners 为元素注册事件// 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)
源码分析
h 函数分析
import { vnode, VNode, VNodeData } from './vnode'import * as is from './is'export type VNodes = VNode[]export type VNodeChildElement = VNode | string | number | undefined | nullexport type ArrayOrElement<T> = T | T[]export type VNodeChildren = ArrayOrElement<VNodeChildElement>function addNS (data: any, children: VNodes | undefined, sel: string | undefined): void {data.ns = 'http://www.w3.org/2000/svg'if (sel !== 'foreignObject' && children !== undefined) {for (let i = 0; i < children.length; ++i) {const childData = children[i].dataif (childData !== undefined) {addNS(childData, (children[i] as VNode).children as VNodes, children[i].sel)}}}}// 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}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// 如果 c 是字符串或数字} else if (is.primitive(b)) {text = b// 如果 c 是 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)}// 返回 VNodereturn vnode(sel, data, children, text, undefined)};
VNode
import { Hooks } from './hooks'import { AttachData } from './helpers/attachto'import { VNodeStyle } from './modules/style'import { On } from './modules/eventlisteners'import { Attrs } from './modules/attributes'import { Classes } from './modules/class'import { Props } from './modules/props'import { Dataset } from './modules/dataset'import { Hero } from './modules/hero'export type Key = string | numberexport interface VNode {sel: string | undefined // 选择器data: VNodeData | undefined // 模块中所需的数据children: Array<VNode | string> | undefined // 与 text 互斥,描述子节点elm: Node | undefined //text: string | undefined // 与 children 互斥,记录文本节点的内容key: Key | undefined // VNode 对象的唯一标识}export interface VNodeData {props?: Propsattrs?: Attrsclass?: Classesstyle?: VNodeStyledataset?: Dataseton?: Onhero?: HeroattachData?: AttachDatahook?: Hookskey?: Keyns?: string // for SVGsfn?: () => VNode // for thunksargs?: any[] // for thunks[key: string]: any // for any other 3rd party module}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.keyreturn { sel, data, children, text, elm, key }}
patch 分析
整体过程
- patch(oldValue,newValue)
- 把新节点中变化的内容渲染到真实 DOM,最后返回新节点作为下一次处理的旧节点
- 对比新旧 VNode 是否相同节点(节点的 key 和 sel 相同)
- 如果不是相同的节点,删除之前的内容,重新渲染
- 如果是相同节点,再判断新的 VNode 是否有 text,如果有并且和 oldNode 的 text 不同,直接更新文本内容
- 如果新的 VNode 有 children,判断子节点是否有变化
创建
由 init() 函数返回。
export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {let i: numberlet j: numberconst cbs: ModuleHooks = {create: [],update: [],remove: [],destroy: [],pre: [],post: []}// 初始化 apiconst 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 {……}}
源码
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 并设置 elmif (!isVnode(oldVnode)) {// 把 DOM 元素转换成空的 VNodeoldVnode = emptyNodeAt(oldVnode)}// 如果新旧节点是相同节点(key 和 sel 相同)if (sameVnode(oldVnode, vnode)) {// 找节点的差异并更新 DOMpatchVnode(oldVnode, vnode, insertedVnodeQueue)} else {// 如果新旧节点不同,vnode 创建对应的 DOM// 获取当前的 DOM 元素elm = oldVnode.elm!parent = api.parentNode(elm) as Node// 触发 init/create 钩子函数,创建 DOMcreateElm(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}
createElm 函数
createElm 函数用来创建一个 DOM 对象。
function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {let i: anylet data = vnode.dataif (data !== undefined) {// 执行用户设置的 init 钩子函数const init = data.hook?.initif (isDef(init)) {init(vnode)data = vnode.data}}const children = vnode.childrenconst sel = vnode.selif (sel === '!') {// 如果选择器是!,创建注释节点if (isUndef(vnode.text)) {vnode.text = ''}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)) : selconst 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!.hookif (isDef(hook)) {// 执行用户传入的钩子 createhook.create?.(emptyNode, vnode)if (hook.insert) {// 把 vnode 添加到队列中,为后续执行 insert 钩子做准备insertedVnodeQueue.push(vnode)}}} else {// 如果选择器为空,创建文本节点vnode.elm = api.createTextNode(vnode.text!)}// 返回新创建的 DOMreturn vnode.elm}
removeVnodes 函数和 addVnodes 函数
removeVnodes 函数用于删除 DOM 树上的元素。
function removeVnodes (parentElm: Node, // 父节点vnodes: VNode[], // 要删除的元素对应的 VNodestartIdx: number,endIdx: number): void {for (; startIdx <= endIdx; ++startIdx) {let listeners: numberlet rm: () => voidconst ch = vnodes[startIdx]// 如果节点存在if (ch != null) {if (isDef(ch.sel)) {// 元素节点// 递归触发元素和它的子元素的 destroy 钩子invokeDestroyHook(ch)listeners = cbs.remove.length + 1rm = createRmCb(ch.elm!, listeners) // 返回删除函数,当 remove 钩子全部执行后才执行这个函数for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm)const removeHook = ch?.data?.hook?.remove// 如果用户传入了 remove 钩子函数,执行钩子函数if (isDef(removeHook)) {removeHook(ch, rm)} else {// 如果没有传入 remove 钩子函数,直接执行 rmrm()}} else { // Text node// 文本节点api.removeChild(parentElm, ch.elm!)}}}}
addVnodes 函数用于创建 VNode 对象,并把它转成 DOM 对象,添加到 DOM 树上。
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)}}}
patchVnode 函数
对比 oldVnode 和 vnode 的差异,把差异渲染到 DOM。
过程
- 首先执行用户设置的 prepatch 钩子函数
- 执行 create 钩子函数
- 首先执行模块的 create 钩子函数
- 然后执行用户设置的 create 钩子函数
- 如果 vnode.text 未定义
- 如果 oldVnode.children 和 vnode.children 都有值
- 调用 updateChildren()
- 使用 diff 算法对比子节点,更新子节点
- 如果 vnode.children 有值,oldVnode.children 无值
- 清空 DOM 元素
- 调用 addVnodes(),批量添加子节点
- 如果 oldVnode.children 有值,vnode.children 无值
- 调用 removeVnodes(),批量移除子节点
- 如果 oldVnode.text 有值
- 清空 DOM 元素的内容
- 如果 oldVnode.children 和 vnode.children 都有值
- 如果设置了 vnode.text 并且和和 oldVnode.text 不等
- 如果老节点有子节点,全部移除
- 设置 DOM 元素的 textContent 为 vnode.text
- 最后执行用户设置的 postpatch 钩子函数
源码
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) returnif (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)) {// 如果新老节点都有 childrenif (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.textif (isDef(oldCh)) {// 如果老节点有 children,移除removeVnodes(elm, oldCh, 0, oldCh.length - 1)}// 设置 DOM 元素的 textContent 为 vnode.textapi.setTextContent(elm, vnode.text!)}// 最后执行用户设置的 postpatch 钩子函数hook?.postpatch?.(oldVnode, vnode)}
diff 算法
diff 算法用来查找两棵树每一个节点的差异。DOM 操作很少会跨级别操作节点,根据这个特点 Snabbdom 对传统的 diff 算法进行优化,只比较同级别的节点。
在对开始节点和结束节点比较的时候,总共有四种情况:
- oldStartVnode / newStartVnode (旧开始节点 / 新开始节点)
- oldEndVnode / newEndVnode (旧结束节点 / 新结束节点)
- oldStartVnode / newEndVnode (旧开始节点 / 新结束节点)
- oldEndVnode / newStartVnode (旧结束节点 / 新开始节点)
新/旧开始节点、新旧结束节点

如果 新旧开始节点是 sameNode (key 和 sel 相同)
- 调用 patchNode() 对比和更新节点- 把旧开始和新开始的索引往后移动: `oldStartIdx++`,`newStartIdx++`
如果新旧开始节点不是 sameNode,从后面开始比较旧结束节点和新结束节点,如果新旧结束节点是 sameNode
- 调用 patchNode() 对比和更新节点- 把旧结束和新开始的索引往前移动:`oldEndIdx--`,`newEndInx--`
旧开始节点/新结束节点

如果 旧开始节点和新结束节点是 sameNode (key 和 sel 相同)
- 调用 patchNode() 对比和更新节点- 把旧开始节点对应的 DOM 元素,移动到右边,更新索引:`oldStartIdx = oldEndIdx + 1`,`newEndIdx--`
旧结束节点/新开始节点

如果 旧结束节点和新开始节点是 sameNode (key 和 sel 相同)
- 调用 patchNode() 对比和更新节点- 把旧结束节点对应的 DOM 元素,移动到左边,更新索引:`oldEndIdx = 0;oldStartIdx++`
非上述四种情况

- 如果不是以上四种情况
- 遍历新节点,使用 newStartNode 的 key 在老节点数组中找相同节点
- 如果没有找到,说明 newStartNode 是新节点
- 创建新节点对应的 DOM 元素,插入到 DOM 树中
- 如果找到了
- 判断新节点和找到的老节点的 sel 选择器是否相同
- 如果不相同,说明节点被修改了
- 重新创建对应的 DOM 元素,插入到 DOM 树中
- 如果相同,把 elmToMove 对应的 DOM 元素,移动到左边
循环结束
- 当老节点的所有子节点先遍历完 (oldStartIdx > oldEndIdx),循环结束
新节点的所有子节点先遍历完 (newStartIdx > newEndIdx),循环结束
oldStartIdx > oldEndIdx
- 如果老节点的数组先遍历完(oldStartIdx > oldEndIdx)
- 说明新节点有剩余,把剩余节点批量插入到右边

- 说明新节点有剩余,把剩余节点批量插入到右边
- 如果老节点的数组先遍历完(oldStartIdx > oldEndIdx)
- newStartIdx > newEndIdx
- 如果新节点的数组先遍历完(newStartIdx > newEndIdx)
- 说明老节点有剩余,把剩余节点批量删除

- 说明老节点有剩余,把剩余节点批量删除
- 如果新节点的数组先遍历完(newStartIdx > newEndIdx)
updateChildren 函数
function updateChildren (parentElm: Node,oldCh: VNode[],newCh: VNode[],insertedVnodeQueue: VNodeQueue) {let oldStartIdx = 0let newStartIdx = 0let oldEndIdx = oldCh.length - 1let oldStartVnode = oldCh[0]let oldEndVnode = oldCh[oldEndIdx]let newEndIdx = newCh.length - 1let newStartVnode = newCh[0]let newEndVnode = newCh[newEndIdx]let oldKeyToIdx: KeyToIndexMap | undefinedlet idxInOld: numberlet elmToMove: VNodelet before: anywhile (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]// 如果是新的vnodeif (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 anyapi.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].elmaddVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)} else {// 如果新节点数组先遍历完成,说明老节点有剩余// 批量删除老节点removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)}}}
key的意义
key 的作用就是在同一个父节点下,标识相同标签的唯一一个元素,避免可能出现的渲染错误。
