virtual-dom
(后文简称vdom
)的概念大规模的推广仍是得益于react
出现,virtual-dom
也是react
这个框架的很是重要的特性之一。相比于频繁的手动去操做dom
而带来性能问题,vdom
很好的将dom
作了一层映射关系,进而将在咱们本须要直接进行dom
的一系列操做,映射到了操做vdom
,而vdom
上定义了关于真实dom
的一些关键的信息,vdom
彻底是用js
去实现,和宿主浏览器没有任何联系,此外得益于js
的执行速度,将本来须要在真实dom
进行的建立节点
,删除节点
,添加节点
等一系列复杂的dom
操做所有放到vdom
中进行,这样就经过操做vdom
来提升直接操做的dom
的效率和性能。javascript
Vue
在2.0
版本也引入了vdom
。其vdom
算法是基于snabbdom算法所作的修改。vue
在Vue
的整个应用生命周期当中,每次须要更新视图的时候便会使用vdom
。那么在Vue
当中,vdom
是如何和Vue
这个框架融合在一块儿工做的呢?以及你们经常提到的vdom
的diff
算法又是怎样的呢?接下来就经过这篇文章简单的向你们介绍下Vue
当中的vdom
是如何去工做的。java
首先,咱们仍是来看下Vue
生命周期当中初始化的最后阶段:将vm
实例挂载到dom
上,源码在src/core/instancenode
Vue.prototype._init = function () { ... vm.$mount(vm.$options.el) // 其实是调用了mountComponent方法 ... }
mountComponent
函数的定义是:react
export function mountComponent ( vm: Component, el: ?Element, hydrating?: boolean ): Component { // vm.$el为真实的node vm.$el = el // 若是vm上没有挂载render函数 if (!vm.$options.render) { // 空节点 vm.$options.render = createEmptyVNode } // 钩子函数 callHook(vm, 'beforeMount') let updateComponent /* istanbul ignore if */ if (process.env.NODE_ENV !== 'production' && config.performance && mark) { ... } else { // updateComponent为监听函数, new Watcher(vm, updateComponent, noop) updateComponent = () => { // Vue.prototype._render 渲染函数 // vm._render() 返回一个VNode // 更新dom // vm._render()调用render函数,会返回一个VNode,在生成VNode的过程当中,会动态计算getter,同时推入到dep里面 vm._update(vm._render(), hydrating) } } // 新建一个_watcher对象 // vm实例上挂载的_watcher主要是为了更新DOM // vm/expression/cb vm._watcher = new Watcher(vm, updateComponent, noop) hydrating = false // manually mounted instance, call mounted on self // mounted is called for render-created child components in its inserted hook if (vm.$vnode == null) { vm._isMounted = true callHook(vm, 'mounted') } return vm }
注意上面的代码中定义了一个updateComponent
函数,这个函数执行的时候内部会调用vm._update(vm._render(), hyddrating)
方法,其中vm._render
方法会返回一个新的vnode
,(关于vm_render
是如何生成vnode
的建议你们看看vue
的关于compile
阶段的代码),而后传入vm._update
方法后,就用这个新的vnode
和老的vnode
进行diff
,最后完成dom
的更新工做。那么updateComponent
都是在何时去进行调用呢?git
vm._watcher = new Watcher(vm, updateComponent, noop)
实例化一个watcher
,在求值的过程当中this.value = this.lazy ? undefined : this.get()
,会调用this.get()
方法,所以在实例化的过程中Dep.target
会被设为这个watcher
,经过调用vm._render()
方法生成新的Vnode
并进行diff
的过程当中完成了模板当中变量依赖收集工做。即这个watcher
被添加到了在模板当中所绑定变量的依赖当中。一旦model
中的响应式的数据发生了变化,这些响应式的数据所维护的dep
数组便会调用dep.notify()
方法完成全部依赖遍历执行的工做,这里面就包括了视图的更新即updateComponent
方法的调用。github
updateComponent
方法的定义是:算法
updateComponent = () => { vm._update(vm._render(), hydrating) }
完成视图的更新工做事实上就是调用了vm._update
方法,这个方法接收的第一个参数是刚生成的Vnode
,调用的vm._update
方法的定义是express
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) { const vm: Component = this if (vm._isMounted) { callHook(vm, 'beforeUpdate') } const prevEl = vm.$el const prevVnode = vm._vnode const prevActiveInstance = activeInstance activeInstance = vm // 新的vnode vm._vnode = vnode // Vue.prototype.__patch__ is injected in entry points // based on the rendering backend used. // 若是须要diff的prevVnode不存在,那么就用新的vnode建立一个真实dom节点 if (!prevVnode) { // initial render // 第一个参数为真实的node节点 vm.$el = vm.__patch__( vm.$el, vnode, hydrating, false /* removeOnly */, vm.$options._parentElm, vm.$options._refElm ) } else { // updates // 若是须要diff的prevVnode存在,那么首先对prevVnode和vnode进行diff,并将须要的更新的dom操做已patch的形式打到prevVnode上,并完成真实dom的更新工做 vm.$el = vm.__patch__(prevVnode, vnode) } activeInstance = prevActiveInstance // update __vue__ reference if (prevEl) { prevEl.__vue__ = null } if (vm.$el) { vm.$el.__vue__ = vm } // if parent is an HOC, update its $el as well if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) { vm.$parent.$el = vm.$el } }
在这个方法当中最为关键的就是vm.__patch__
方法,这也是整个virtaul-dom
当中最为核心的方法,主要完成了prevVnode
和vnode
的diff
过程并根据须要操做的vdom
节点打patch
,最后生成新的真实dom
节点并完成视图的更新工做。数组
接下来就让咱们看下vm.__patch__
里面到底发生了什么:
function patch (oldVnode, vnode, hydrating, removeOnly, parentElm, refElm) { // 当oldVnode不存在时 if (isUndef(oldVnode)) { // 建立新的节点 createElm(vnode, insertedVnodeQueue, parentElm, refElm) } else { const isRealElement = isDef(oldVnode.nodeType) if (!isRealElement && sameVnode(oldVnode, vnode)) { // patch existing root node // 对oldVnode和vnode进行diff,并对oldVnode打patch patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly) } } }
在对oldVnode
和vnode
类型判断中有个sameVnode
方法,这个方法决定了是否须要对oldVnode
和vnode
进行diff
及patch
的过程。
function sameVnode (a, b) { return ( a.key === b.key && a.tag === b.tag && a.isComment === b.isComment && isDef(a.data) === isDef(b.data) && sameInputType(a, b) ) }
sameVnode
会对传入的2个vnode
进行基本属性的比较,只有当基本属性相同的状况下才认为这个2个vnode
只是局部发生了更新,而后才会对这2个vnode
进行diff
,若是2个vnode
的基本属性存在不一致的状况,那么就会直接跳过diff
的过程,进而依据vnode
新建一个真实的dom,同时删除老的dom
节点。
vnode
基本属性的定义能够参见源码:src/vdom/vnode.js
里面对于vnode
的定义。
constructor ( tag?: string, data?: VNodeData, // 关于这个节点的data值,包括attrs,style,hook等 children?: ?Array<VNode>, // 子vdom节点 text?: string, // 文本内容 elm?: Node, // 真实的dom节点 context?: Component, // 建立这个vdom的上下文 componentOptions?: VNodeComponentOptions ) { this.tag = tag this.data = data this.children = children this.text = text this.elm = elm this.ns = undefined this.context = context this.functionalContext = undefined this.key = data && data.key this.componentOptions = componentOptions this.componentInstance = undefined this.parent = undefined this.raw = false this.isStatic = false this.isRootInsert = true this.isComment = false this.isCloned = false this.isOnce = false } // DEPRECATED: alias for componentInstance for backwards compat. /* istanbul ignore next */ get child (): Component | void { return this.componentInstance } }
每个vnode
都映射到一个真实的dom
节点上。其中几个比较重要的属性:
tag
属性即这个vnode
的标签属性
data
属性包含了最后渲染成真实dom
节点后,节点上的class
,attribute
,style
以及绑定的事件
children
属性是vnode
的子节点
text
属性是文本属性
elm
属性为这个vnode
对应的真实dom
节点
key
属性是vnode
的标记,在diff
过程当中能够提升diff
的效率,后文有讲解
好比,我定义了一个vnode
,它的数据结构是:
{ tag: 'div' data: { id: 'app', class: 'page-box' }, children: [ { tag: 'p', text: 'this is demo' } ] }
最后渲染出的实际的dom
结构就是:
<div id="app" class="page-box"> <p>this is demo</p> </div>
让咱们再回到patch
函数当中,在当oldVnode
不存在的时候,这个时候是root节点
初始化的过程,所以调用了createElm(vnode, insertedVnodeQueue, parentElm, refElm)
方法去建立一个新的节点。而当oldVnode
是vnode
且sameVnode(oldVnode, vnode)
2个节点的基本属性相同,那么就进入了2个节点的diff
过程。
diff
的过程主要是经过调用patchVnode
方法进行的:
function patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly) { ... }
if (isDef(data) && isPatchable(vnode)) { // cbs保存了hooks钩子函数: 'create', 'activate', 'update', 'remove', 'destroy' // 取出cbs保存的update钩子函数,依次调用,更新attrs/style/class/events/directives/refs等属性 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) }
更新真实dom
节点的data
属性,至关于对dom
节点进行了预处理的操做
接下来:
... const elm = vnode.elm = oldVnode.elm const oldCh = oldVnode.children const ch = vnode.children // 若是vnode没有文本节点 if (isUndef(vnode.text)) { // 若是oldVnode的children属性存在且vnode的属性也存在 if (isDef(oldCh) && isDef(ch)) { // updateChildren,对子节点进行diff if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly) } else if (isDef(ch)) { // 若是oldVnode的text存在,那么首先清空text的内容 if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '') // 而后将vnode的children添加进去 addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue) } else if (isDef(oldCh)) { // 删除elm下的oldchildren removeVnodes(elm, oldCh, 0, oldCh.length - 1) } else if (isDef(oldVnode.text)) { // oldVnode有子节点,而vnode没有,那么就清空这个节点 nodeOps.setTextContent(elm, '') } } else if (oldVnode.text !== vnode.text) { // 若是oldVnode和vnode文本属性不一样,那么直接更新真是dom节点的文本元素 nodeOps.setTextContent(elm, vnode.text) }
这其中的diff
过程当中又分了好几种状况,oldCh
为oldVnode
的子节点,ch
为Vnode
的子节点:
首先进行文本节点的判断,若oldVnode.text !== vnode.text
,那么就会直接进行文本节点的替换;
在vnode
没有文本节点的状况下,进入子节点的diff
;
当oldCh
和ch
都存在且不相同的状况下,调用updateChildren
对子节点进行diff
;
若oldCh
不存在,ch
存在,首先清空oldVnode
的文本节点,同时调用addVnodes
方法将ch
添加到elm
真实dom
节点当中;
若oldCh
存在,ch
不存在,则删除elm
真实节点下的oldCh
子节点;
若oldVnode
有文本节点,而vnode
没有,那么就清空这个文本节点。
这里着重分析下updateChildren
方法,它也是整个diff
过程当中最重要的环节:
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) { // 为oldCh和newCh分别创建索引,为以后遍历的依据 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, idxInOld, elmToMove, refElm // 直到oldCh或者newCh被遍历完后跳出循环 while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { if (isUndef(oldStartVnode)) { oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left } else if (isUndef(oldEndVnode)) { oldEndVnode = oldCh[--oldEndIdx] } 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)) { // Vnode moved right patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue) canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm)) oldStartVnode = oldCh[++oldStartIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue) // 插入到老的开始节点的前面 canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm) oldEndVnode = oldCh[--oldEndIdx] newStartVnode = newCh[++newStartIdx] } else { // 若是以上条件都不知足,那么这个时候开始比较key值,首先创建key和index索引的对应关系 if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : null // 若是idxInOld不存在 // 1. newStartVnode上存在这个key,可是oldKeyToIdx中不存在 // 2. newStartVnode上并无设置key属性 if (isUndef(idxInOld)) { // New element // 建立新的dom节点 // 插入到oldStartVnode.elm前面 // 参见createElm方法 createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm) newStartVnode = newCh[++newStartIdx] } else { elmToMove = oldCh[idxInOld] /* istanbul ignore if */ if (process.env.NODE_ENV !== 'production' && !elmToMove) { warn( 'It seems there are duplicate keys that is causing an update error. ' + 'Make sure each v-for item has a unique key.' ) // 将找到的key一致的oldVnode再和newStartVnode进行diff if (sameVnode(elmToMove, newStartVnode)) { patchVnode(elmToMove, newStartVnode, insertedVnodeQueue) oldCh[idxInOld] = undefined // 移动node节点 canMove && nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm) newStartVnode = newCh[++newStartIdx] } else { // same key but different element. treat as new element // 建立新的dom节点 createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm) newStartVnode = newCh[++newStartIdx] } } } } // 若是最后遍历的oldStartIdx大于oldEndIdx的话 if (oldStartIdx > oldEndIdx) { // 若是是老的vdom先被遍历完 refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm // 添加newVnode中剩余的节点到parentElm中 addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) } else if (newStartIdx > newEndIdx) { // 若是是新的vdom先被遍历完,则删除oldVnode里面全部的节点 // 删除剩余的节点 removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx) } }
在开始遍历diff
前,首先给oldCh
和newCh
分别分配一个startIndex
和endIndex
来做为遍历的索引,当oldCh
或者newCh
遍历完后(遍历完的条件就是oldCh
或者newCh
的startIndex >= endIndex
),就中止oldCh
和newCh
的diff
过程。接下来经过实例来看下整个diff
的过程(节点属性中不带key
的状况):
首先从第一个节点开始比较,不论是oldCh
仍是newCh
的起始或者终止节点都不存在sameVnode
,同时节点属性中是不带key
标记的,所以第一轮的diff
完后,newCh
的startVnode
被添加到oldStartVnode
的前面,同时newStartIndex
前移一位;
第二轮的diff
中,知足sameVnode(oldStartVnode, newStartVnode)
,所以对这2个vnode
进行diff
,最后将patch
打到oldStartVnode
上,同时oldStartVnode
和newStartIndex
都向前移动一位
第三轮的diff
中,知足sameVnode(oldEndVnode, newStartVnode)
,那么首先对oldEndVnode
和newStartVnode
进行diff
,并对oldEndVnode
进行patch
,并完成oldEndVnode
移位的操做,最后newStartIndex
前移一位,oldStartVnode
后移一位;
第四轮的diff
中,过程同步骤3;
第五轮的diff
中,同过程1;
遍历的过程结束后,newStartIdx > newEndIdx
,说明此时oldCh
存在多余的节点,那么最后就须要将这些多余的节点删除。
在vnode
不带key
的状况下,每一轮的diff
过程中都是起始
和结束
节点进行比较,直到oldCh
或者newCh
被遍历完。而当为vnode
引入key
属性后,在每一轮的diff
过程当中,当起始
和结束
节点都没有找到sameVnode
时,首先对oldCh
中进行key
值与索引的映射:
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : null
createKeyToOldIdx
方法,用以将oldCh
中的key
属性做为键
,而对应的节点的索引做为值
。而后再判断在newStartVnode
的属性中是否有key
,且是否在oldKeyToIndx
中找到对应的节点。
若是不存在这个key
,那么就将这个newStartVnode
做为新的节点建立且插入到原有的root
的子节点中:
if (isUndef(idxInOld)) { // New element // 建立新的dom节点 // 插入到oldStartVnode.elm前面 // 参见createElm方法 createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm) newStartVnode = newCh[++newStartIdx] }
若是存在这个key
,那么就取出oldCh
中的存在这个key
的vnode
,而后再进行diff
的过程:
elmToMove = oldCh[idxInOld] /* istanbul ignore if */ if (process.env.NODE_ENV !== 'production' && !elmToMove) { // 将找到的key一致的oldVnode再和newStartVnode进行diff if (sameVnode(elmToMove, newStartVnode)) { patchVnode(elmToMove, newStartVnode, insertedVnodeQueue) // 清空这个节点 oldCh[idxInOld] = undefined // 移动node节点 canMove && nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm) newStartVnode = newCh[++newStartIdx] } else { // same key but different element. treat as new element // 建立新的dom节点 createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm) newStartVnode = newCh[++newStartIdx] }
经过以上分析,给vdom
上添加key
属性后,遍历diff
的过程当中,当起始点
, 结束点
的搜寻
及diff
出现仍是没法匹配的状况下时,就会用key
来做为惟一标识,来进行diff
,这样就能够提升diff
效率。
带有Key
属性的vnode
的diff
过程可见下图:
注意在第一轮的diff
事后oldCh
上的B节点
被删除了,可是newCh
上的B节点
上elm
属性保持对oldCh
上B节点
的elm
引用。