理解 Vue 2 中虚拟 DOM(VDOM)的实现原理和 Diff 算法的核心机制,包括 VNode 的创建、patch 流程、以及双端 diff 算法的实现细节。
vue版本:以
vue@2.7.16代码为参考,可能会包含部分 vue3 polyfill 代码。
VDOM 存在的原因
- DOM 性能开销过大:直接操作 DOM 的性能开销比较大,如果修改 DOM 树去执行更新,可能会引起回流和重绘,VDOM 通过维护一个 VDOM 树,通过 diff 和 patch 优化更新流程,计算出最小化更新操作,再一次性同步到真实 DOM 状态,减少 DOM 操作次数。
- 开发人员关注如何编写代码:VDOM 给开发者提供声明式的 UI 编程模型,让开发人员可以把关注点从"如何编写改变 UI 的代码"转变为 “如何描述 UI 的最终状态”,让框架本身去负责 DOM 的更新操作。
- 跨平台:VDOM 是平台无关的 JS 对象结构,小程序、web、uniapp 都可以利用 VDOM 抽象结构来让渲染器消费。
- 细粒度可控和优化空间:key 可以帮助识别列表节点复用,避免重建节点,后续只需更新。vue3 中引入了静态节点优化,同时引入了异步更新队列 nextTick 来支持批量更新操作。
其中 diff 算法是 VDOM 中最核心的部分。
h 函数/createElement 函数
在 Vue 2 中,用户编写 template 模板语法,之后模板语法会被编译成 render 函数,render 函数内部会调用createElement()去渲染节点。
h()函数是在编写函数式组件时用到的渲染函数。
// 创建带子元素的元素h("div",{class:"container"},[h("span","Child 1"),h("span","Child 2")]);在 Vue 2 中,h()是对createElement()的再封装:
// src/v3/h.tsexportfunctionh(type:any,props?:any,children?:any){if(!currentInstance){returncreateElement(currentInstance!,type,props,children,2,true)}}// src/shared/util.tsexportfunctionisPrimitive(value:any):boolean{return(typeofvalue==='string'||typeofvalue==='number'||// $flow-disable-linetypeofvalue==='symbol'||typeofvalue==='boolean')}// src/core/vdom/create-element.tsexportfunctioncreateElement(context:Component,tag:any,data:any,children:any,normalizationType:any,alwaysNormalize:boolean):VNode|Array<VNode>{// 判断传入的 data 是否为数组还是原始值if(isArray(data)||isPrimitive(data)){normalizationType=children children=data data=undefined}if(isTrue(alwaysNormalize)){normalizationType=ALWAYS_NORMALIZE}return_createElement(context,tag,data,children,normalizationType)}exportfunction_createElement(context:Component,tag?:string|Component|Function|Object,data?:VNodeData,children?:any,normalizationType?:number):VNode|Array<VNode>{// 1. 检查 data 是否为响应式对象,是的话不应该作为 vnode 的data对象使用(不应该使用)if(isDef(data)&&isDef((dataasany).__ob__)){returncreateEmptyVNode()}// 2. 处理 :is 指令if(isDef(data)&&isDef(data.is)){tag=data.is}// 3. 处理 :is 为 falsy 值的情况if(!tag){returncreateEmptyVNode()}// 4. 处理 scoped slot(单个函数作为 children)if(isArray(children)&&isFunction(children[0])){data=data||{}data.scopedSlots={default:children[0]}children.length=0}// 5. 规范化 childrenif(normalizationType===ALWAYS_NORMALIZE){children=normalizeChildren(children)}elseif(normalizationType===SIMPLE_NORMALIZE){children=simpleNormalizeChildren(children)}// 6. 创建 VNodeletvnode,ns// 字符串标签if(typeoftag==='string'){letCtor ns=(context.$vnode&&context.$vnode.ns)||config.getTagNamespace(tag)if(config.isReservedTag(tag)){// 平台内置元素(div, span 等)// platform built-in elementsvnode=newVNode(config.parsePlatformTagName(tag),data,children,undefined,undefined,context)}elseif((!data||!data.pre)&&isDef((Ctor=resolveAsset(context.$options,'components',tag)))){// 组件// componentvnode=createComponent(Ctor,data,context,children,tag)}else{// 未知元素或命名空间元素// unknown or unlisted namespaced elements// check at runtime because it may get assigned a namespace when its// parent normalizes childrenvnode=newVNode(tag,data,children,undefined,undefined,context)}}else{// 直接传入组件选项或构造函数// direct component options / constructorvnode=createComponent(tagasany,data,context,children)}// 7. 应用命名空间if(isArray(vnode)){returnvnode}elseif(isDef(vnode)){if(isDef(ns))applyNS(vnode,ns)if(isDef(data))registerDeepBindings(data)returnvnode}else{returncreateEmptyVNode()}}VNode
VNode(Virtual Node)是用于描述 DOM 节点的 JavaScript 对象。
概念理解
VNode:虚拟节点,是 Vue 用来描述真实 DOM 节点的轻量级 JavaScript 对象,包含了节点的所有必要信息,但不包含实际的 DOM 操作。
VNode 通常包含以下属性:
- 当前 VNode 的标签名或者组件
- 属性和事件
- 子 vnode 数组
- 文本内容
- 对应的真实 DOM 节点
- key(唯一标识)
- context(组件实例)
constvnode={tag:"div",// 标签名或组件data:{// 属性、事件等attrs:{id:"app"},on:{click:handler},},children:[],// 子 VNodetext:undefined,// 文本内容elm:undefined,// 对应的真实 DOMkey:"unique-key",// 唯一标识context:vm,// 组件实例// ... 其他属性};Vue 2 的 VDOM 和 Diff 算法
当数据发生改变时,响应式属性的setter方法会调用Dep.notify通知所有订阅者Watcher,订阅者就会调用patch给真实的DOM打补丁,更新相应的视图。
进入 patch 步骤的流程
在 Vue 中会有两个步骤会走到 patch:
- 首次渲染:
mountComponent() → Watcher → updateComponent() → patch()。 - 数据更新:
setter → Watcher → updateComponent() → patch()。
mountComponent()中会执行new Watcher()操作。
Watcher 将updateComponent()作为回调函数传入,可以看到是调用 。_update(vm._render())获取新的 VNode 后执行更新操作。
// src/core/instance/lifecycle.tsexportfunctionmountComponent(vm:Component,el:Element|null|undefined,hydrating?:boolean):Component{vm.$el=el;if(!vm.$options.render){// @ts-expect-error invalid typevm.$options.render=createEmptyVNode;}callHook(vm,"beforeMount");letupdateComponent;// 核心关键,当组件更新时,会触发该方法!!!updateComponent=()=>{vm._update(vm._render(),hydrating);};constwatcherOptions:WatcherOptions={before(){if(vm._isMounted&&!vm._isDestroyed){callHook(vm,"beforeUpdate");}},};// watcher 初始化newWatcher(vm,updateComponent,noop,watcherOptions,true/* isRenderWatcher */);hydrating=false;// flush buffer for flush: "pre" watchers queued in setup()constpreWatchers=vm._preWatchers;if(preWatchers){for(leti=0;i<preWatchers.length;i++){preWatchers[i].run();}}// 挂载相关if(vm.$vnode==null){vm._isMounted=true;callHook(vm,"mounted");}returnvm;}在_update()的内部,就调用了__patch__()比对节点去完成更新。
// src/core/instance/lifecycle.tsVue.prototype._update=function(vnode){constprevVNode=this._vnode;this._vnode=vnode;if(!prevVNode){// 初次渲染this.$el=this.__patch__(this.$el,vnode);}else{// 更新this.$el=this.__patch__(prevVNode,vnode);}};__patch__()来源于:
//src/platforms/web/runtime/index.ts// 在 web 平台创建 patch 函数Vue.prototype.__patch__=inBrowser?patch:noop;// src/platforms/web/runtime/patch.tsimport*asnodeOpsfrom"web/runtime/node-ops";import{createPatchFunction}from"core/vdom/patch";importbaseModulesfrom"core/vdom/modules/index";importplatformModulesfrom"web/runtime/modules/index";// the directive module should be applied last, after all// built-in modules have been applied.constmodules=platformModules.concat(baseModules);// 最终返回了 patch 函数exportconstpatch:Function=createPatchFunction({nodeOps,modules});patch:vnode 新旧节点更新的入口
patch 函数是 VNode 更新的入口,主要处理以下四种情况:
- 无新节点,旧节点销毁:没有新节点,直接触发旧节点的
destroy()钩子,销毁 vnode。 - 无旧节点,新节点创建:没有旧节点,说明是页面刚开始初始化的时候,此时,根本不需要比较了,直接全是新建,所以只调用
createElm()。 - 新旧节点一致,执行 patchVnode 更新 VNode:旧节点和新节点自身一样,通过
sameVnode()判断节点是否一样,一样时,直接调用patchVnode()去处理这两个节点。 - 新旧节点不一致,销毁旧 VNode,插入新 VNode:旧节点和新节点自身不一样,当两个节点不一样的时候,直接创建新节点,删除旧节点。
//src/core/vdom/patch.tsfunctionpatch(oldVnode,vnode,hydrating,removeOnly){if(isUndef(vnode)){// 没有新节点,直接执行 destroy 钩子函数if(isDef(oldVnode))invokeDestroyHook(oldVnode);return;}letisInitialPatch=false;constinsertedVnodeQueue=[];if(isUndef(oldVnode)){isInitialPatch=true;createElm(vnode,insertedVnodeQueue);// 没有旧节点,直接用新节点生成 DOM 元素}else{constisRealElement=isDef(oldVnode.nodeType);if(!isRealElement&&sameVnode(oldVnode,vnode)){// 判断旧节点和新节点自身一样,一致执行 patchVnodepatchVnode(oldVnode,vnode,insertedVnodeQueue,null,null,removeOnly);}else{// 否则直接销毁旧节点,根据新节点生成 DOM 元素if(isRealElement){if(oldVnode.nodeType===1&&oldVnode.hasAttribute(SSR_ATTR)){oldVnode.removeAttribute(SSR_ATTR);hydrating=true;}if(isTrue(hydrating)){if(hydrate(oldVnode,vnode,insertedVnodeQueue)){invokeInsertHook(vnode,insertedVnodeQueue,true);returnoldVnode;}}oldVnode=emptyNodeAt(oldVnode);}returnvnode.elm;}}}patchVnode:新旧同节点的内容更新过程
patchVnode主要做了几个判断:
- 新节点是否是文本节点:如果是,则直接更新 DOM 的文本内容为新节点的文本内容。
- 新节点和旧节点如果都有子节点:则处理比较更新子节点,进入双端 diff 流程。
- 只有新节点有子节点,旧节点没有:那么不用比较了,所有节点都是全新的,所以直接全部新建就好了,新建是指创建出所有新 DOM,并且添加进父节点。
- 只有旧节点有子节点而新节点没有:说明更新后的页面,旧节点全部都不见了,那么要做的,就是把所有的旧节点删除,也就是直接把 DOM 删除。
functionpatchVnode(oldVnode,vnode,insertedVnodeQueue,removeOnly){// 如果新旧节点一致,什么都不做if(oldVnode===vnode){return;}// 假如已创建,克隆复用VNodeif(isDef(vnode.elm)&&isDef(ownerArray)){// clone reused vnodevnode=ownerArray[index]=cloneVNode(vnode)}// 让 vnode.elm 引用到现在的真实 DOM,当 elm 修改时,vnode.elm 会同步变化constelm=(vnode.elm=oldVnode.elm);// 异步占位符if(isTrue(oldVnode.isAsyncPlaceholder)){if(isDef(vnode.asyncFactory.resolved)){hydrate(oldVnode.elm,vnode,insertedVnodeQueue);}else{vnode.isAsyncPlaceholder=true;}return;}// 如果新旧都是静态节点,并且具有相同的 key// 当 vnode 是克隆节点或是 v-once 指令控制的节点时,只需要把 oldVnode.elm 和 oldVnode.child 都复制到 vnode 上// 也不用再有其他操作if(isTrue(vnode.isStatic)&&isTrue(oldVnode.isStatic)&&vnode.key===oldVnode.key&&(isTrue(vnode.isCloned)||isTrue(vnode.isOnce))){vnode.componentInstance=oldVnode.componentInstance;return;}leti;constdata=vnode.data;if(isDef(data)&&isDef((i=data.hook))&&isDef((i=i.prepatch))){i(oldVnode,vnode);}constoldCh=oldVnode.children;constch=vnode.children;if(isDef(data)&&isPatchable(vnode)){for(i=0;i<cbs.update.length;++i)cbs.update[i](oldVnode,vnode);if(isDef((i=data.hook))&&isDef((i=i.update)))i(oldVnode,vnode);}// 如果 vnode 不是文本节点或者注释节点if(isUndef(vnode.text)){// 并且都有子节点if(isDef(oldCh)&&isDef(ch)){// 并且子节点不完全一致,则调用 updateChildrenif(oldCh!==ch)updateChildren(elm,oldCh,ch,insertedVnodeQueue,removeOnly);// 如果只有新的 vnode 有子节点}elseif(isDef(ch)){if(isDef(oldVnode.text))nodeOps.setTextContent(elm,"");// elm 已经引用了老的 DOM 节点,在老的 DOM 节点上添加子节点addVnodes(elm,null,ch,0,ch.length-1,insertedVnodeQueue);// 如果新 vnode 没有子节点,而旧 vnode 有子节点,直接删除老的 oldCh}elseif(isDef(oldCh)){removeVnodes(elm,oldCh,0,oldCh.length-1);// 如果老节点是文本节点}elseif(isDef(oldVnode.text)){nodeOps.setTextContent(elm,"");}// 如果新 vnode 和老 vnode 是文本节点或注释节点// 但是 vnode.text != oldVnode.text 时,只需要更新 vnode.elm 的文本内容就可以}elseif(oldVnode.text!==vnode.text){nodeOps.setTextContent(elm,vnode.text);}if(isDef(data)){if(isDef((i=data.hook))&&isDef((i=i.postpatch)))i(oldVnode,vnode);}}updateChildren:新旧 vnode 子节点的 diff 算法比较流程
通过比较新旧 vnode 子节点,就会通过双端 diff 来比较同层节点。
当节点一致时,就会递归调用patchVnode()进行更新。
Vue 2 通过四个指针(新旧节点的起始和结束位置)进行比较,尽可能复用节点:
- 当新老
VNode节点的start相同时,直接patchVnode(),同时新老VNode节点的开始索引都加 1。 - 当新老
VNode节点的end相同时,同样直接patchVnode(),同时新老VNode节点的结束索引都减 1。 - 当老
VNode节点的start和新VNode节点的end相同时,这时候在patchVnode()后,还需要将当前真实 DOM 节点移动到oldEndVnode的后面,同时老VNode节点开始索引加 1,新VNode节点的结束索引减 1。 - 当老
VNode节点的end和新VNode节点的start相同时,这时候在patchVnode()后,还需要将当前真实 DOM 节点移动到oldStartVnode的前面,同时老VNode节点结束索引减 1,新VNode节点的开始索引加 1。 - 如果都不满足以上四种情形,那说明没有相同的节点可以复用,则会分为以下两种情况:
- 从旧的
VNode为key值,对应index序列为value值的哈希表中找到与newStartVnode一致key的旧的VNode节点,再进行patchVnode(),同时将这个真实 DOM 移动到oldStartVnode对应的真实 DOM 的前面。 - 调用
createElm()创建一个新的 DOM 节点放到当前newStartIdx的位置。
- 从旧的
functionupdateChildren(parentElm,oldCh,newCh,insertedVnodeQueue,removeOnly){letoldStartIdx=0// 旧头索引letnewStartIdx=0// 新头索引letoldEndIdx=oldCh.length-1// 旧尾索引letnewEndIdx=newCh.length-1// 新尾索引letoldStartVnode=oldCh[0]// oldVnode的第一个childletoldEndVnode=oldCh[oldEndIdx]// oldVnode的最后一个childletnewStartVnode=newCh[0]// newVnode的第一个childletnewEndVnode=newCh[newEndIdx]// newVnode的最后一个childletoldKeyToIdx,idxInOld,vnodeToMove,refElm// removeOnly is a special flag used only by <transition-group>// to ensure removed elements stay in correct relative positions// during leaving transitionsconstcanMove=!removeOnly// 如果oldStartVnode和oldEndVnode重合,并且新的也都重合了,证明diff完了,循环结束while(oldStartIdx<=oldEndIdx&&newStartIdx<=newEndIdx){// 如果oldVnode的第一个child不存在if(isUndef(oldStartVnode)){// oldStart索引右移oldStartVnode=oldCh[++oldStartIdx]// Vnode has been moved left// 如果oldVnode的最后一个child不存在}elseif(isUndef(oldEndVnode)){// oldEnd索引左移oldEndVnode=oldCh[--oldEndIdx]// oldStartVnode 和 newStartVnode 是同一个节点}elseif(sameVnode(oldStartVnode,newStartVnode)){// patch oldStartVnode 和 newStartVnode,索引左移,继续循环patchVnode(oldStartVnode,newStartVnode,insertedVnodeQueue)oldStartVnode=oldCh[++oldStartIdx]newStartVnode=newCh[++newStartIdx]// oldEndVnode 和 newEndVnode 是同一个节点}elseif(sameVnode(oldEndVnode,newEndVnode)){// patch oldEndVnode 和 newEndVnode,索引右移,继续循环patchVnode(oldEndVnode,newEndVnode,insertedVnodeQueue)oldEndVnode=oldCh[--oldEndIdx]newEndVnode=newCh[--newEndIdx]// oldStartVnode 和 newEndVnode 是同一个节点}elseif(sameVnode(oldStartVnode,newEndVnode)){// Vnode moved right// patch oldStartVnode 和 newEndVnodepatchVnode(oldStartVnode,newEndVnode,insertedVnodeQueue)// 如果 removeOnly 是 false,则将 oldStartVnode.elm 移动到 oldEndVnode.elm 之后canMove&&nodeOps.insertBefore(parentElm,oldStartVnode.elm,nodeOps.nextSibling(oldEndVnode.elm))// oldStart 索引右移,newEnd 索引左移oldStartVnode=oldCh[++oldStartIdx]newEndVnode=newCh[--newEndIdx]// 如果 oldEndVnode 和 newStartVnode 是同一个节点}elseif(sameVnode(oldEndVnode,newStartVnode)){// Vnode moved left// patch oldEndVnode 和 newStartVnodepatchVnode(oldEndVnode,newStartVnode,insertedVnodeQueue)// 如果 removeOnly 是 false,则将 oldEndVnode.elm 移动到 oldStartVnode.elm 之前canMove&&nodeOps.insertBefore(parentElm,oldEndVnode.elm,oldStartVnode.elm)// oldEnd 索引左移,newStart 索引右移oldEndVnode=oldCh[--oldEndIdx]newStartVnode=newCh[++newStartIdx]// 如果都不匹配}else{if(isUndef(oldKeyToIdx))oldKeyToIdx=createKeyToOldIdx(oldCh,oldStartIdx,oldEndIdx)// 尝试在 oldChildren 中寻找和 newStartVnode 的具有相同的 key 的 VnodeidxInOld=isDef(newStartVnode.key)?oldKeyToIdx[newStartVnode.key]:findIdxInOld(newStartVnode,oldCh,oldStartIdx,oldEndIdx)// 如果未找到,说明 newStartVnode 是一个新的节点if(isUndef(idxInOld)){// New element// 创建一个新 VnodecreateElm(newStartVnode,insertedVnodeQueue,parentElm,oldStartVnode.elm)// 如果找到了和 newStartVnode 具有相同的 key 的 Vnode,叫 vnodeToMove}else{vnodeToMove=oldCh[idxInOld]/* istanbul ignore if */if(process.env.NODE_ENV!=='production'&&!vnodeToMove){warn('It seems there are duplicate keys that is causing an update error. '+'Make sure each v-for item has a unique key.')}// 比较两个具有相同的 key 的新节点是否是同一个节点// 不设 key,newCh 和 oldCh 只会进行头尾两端的相互比较,设 key 后,除了头尾两端的比较外,还会从用 key 生成的对象 oldKeyToIdx 中查找匹配的节点,所以为节点设置 key 可以更高效的利用 DOMif(sameVnode(vnodeToMove,newStartVnode)){// patch vnodeToMove 和 newStartVnodepatchVnode(vnodeToMove,newStartVnode,insertedVnodeQueue)// 清除oldCh[idxInOld]=undefined// 如果 removeOnly 是 false,则将找到的和 newStartVnode 具有相同的 key 的 Vnode,叫 vnodeToMove.elm// 移动到 oldStartVnode.elm 之前canMove&&nodeOps.insertBefore(parentElm,vnodeToMove.elm,oldStartVnode.elm)// 如果 key 相同,但是节点不相同,则创建一个新的节点}else{// same key but different element. treat as new elementcreateElm(newStartVnode,insertedVnodeQueue,parentElm,oldStartVnode.elm)}}// 右移newStartVnode=newCh[++newStartIdx]}}四种规则的具体原因和作用
假设:
- 旧列表(oldVnodes):指针
oldStartIdx(开始索引)、oldEndIdx(结束索引)。 - 新列表(newVnodes):指针
newStartIdx(开始索引)、newEndIdx(结束索引)。 - 节点复用的判断:
key相同(且标签名等基础属性一致)。
1. 当oldStartVnode.key === newStartVnode.key(新旧开始节点相同)
- 操作:直接
patchVnode()(复用节点,更新内容),然后oldStartIdx++、newStartIdx++。 - 原因:这是最理想的情况 —— 新旧列表头部节点完全一致,无需移动 DOM,直接更新内容即可。例如:旧列表
[A, B, C],新列表[A, B, D],首节点A相同,直接复用并后移指针。
2. 当oldEndVnode.key === newEndVnode.key(新旧结束节点相同)
- 操作:直接
patchVnode(),然后oldEndIdx--、newEndIdx-- - 原因:与规则 1 对称,尾部节点相同,无需移动,直接更新内容。例如:旧列表
[A, B, C],新列表[D, B, C],尾节点C相同,复用并前移指针
3. 当oldStartVnode.key === newEndVnode.key(旧开始节点 = 新结束节点)
- 操作:
patchVnode()后,将旧开始节点对应的真实 DOM移动到旧结束节点的后面,然后oldStartIdx++、newEndIdx--。 - 原因:这种情况说明该节点在新列表中被 “移到了尾部”。通过一次移动操作(而非删除再新增)复用节点,减少 DOM 操作成本。例如:旧列表
[A, B, C],新列表[B, C, A],旧首A与新尾A匹配,此时只需把A移动到C后面,即可符合新列表结构。
4. 当oldEndVnode.key === newStartVnode.key(旧结束节点 = 新开始节点)
- 操作:
patchVnode()后,将旧结束节点对应的真实 DOM移动到旧开始节点的前面,然后oldEndIdx--、newStartIdx++。 - 原因:与规则 3 对称,该节点在新列表中被 “移到了头部”,通过一次移动操作复用节点。例如:旧列表
[A, B, C],新列表[C, A, B],旧尾C与新首C匹配,把C移动到A前面即可。
双端 diff 算法的优势:
- 优先处理 “无需移动” 的节点:规则 1 和 2 直接复用头部 / 尾部相同的节点,避免无效操作。
- 用最少移动解决 “顺序调整”:规则 3 和 4 针对节点位置互换的场景,通过一次移动即可完成位置修正,比 “删除 + 新增” 高效得多(DOM 移动的成本远低于删除和创建)。
- 减少遍历范围:每处理完一个节点,指针就向中间收缩,逐步缩小对比范围,避免全量遍历(时间复杂度从 O(n²) 优化为 O(n))。
思考
1. Vue2 中所指的同层节点比较,是指哪一块?
同层节点比较,发生在同个新旧 vnode 节点的子节点children比较阶段,即双端 diff 阶段,不会发生跨层级节点的比较。
2. 为何要采用双端 diff?
如果是正常的子节点 diff,我们需要遍历新节点,然后再嵌套遍历旧节点,即 Vue 设计与实现中讲到的简单 diff 流程,时间复杂度是 O(n²),使用双端 diff,首尾指针比对,可以把实现复杂度压缩到 O(n)。
总结
- VDOM 存在的价值:通过维护虚拟 DOM 树,减少直接操作真实 DOM 的性能开销,提供声明式编程模型,支持跨平台渲染。
- Diff 算法流程:数据变化触发
setter → Watcher → updateComponent() → patch() → patchVnode() → updateChildren(),通过双端 diff 算法高效比较和更新节点。 - 双端 diff 算法:使用四个指针(新旧节点的起始和结束位置)进行比较,通过四种匹配规则优先处理无需移动的节点,用最少移动解决顺序调整,时间复杂度从 O(n²) 优化为 O(n)。
- key 复用节点:key 属性帮助识别相同节点并复用,避免不必要的重建。
参考内容
vuejs/vue at v2.7.16
深入理解 Vue.js 的虚拟 DOM 和 Diff 算法
Vue 设计与实现 - 第 10 章 双端 diff 算法