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
引用。