Vue原始碼解析:虛擬dom比較原理
通過對 Vue2.0 原始碼閱讀,想寫一寫自己的理解,能力有限故從 尤大佬2016.4.11第一次提交 開始讀,準備陸續寫:
- 模版字串轉AST語法樹
- AST語法樹轉render函式
- Vue雙向繫結原理
- Vue虛擬dom比較原理
其中包含自己的理解和原始碼的分析,儘量通俗易懂!由於是2.0的最早提交,所以和最新版本有很多差異、bug,後續將陸續補充,敬請諒解! 包含中文註釋的Vue原始碼 已上傳...
開始
先說一下為什麼會有虛擬dom比較這一階段,我們知道了Vue是資料驅動檢視(資料的變化將引起檢視的變化),但你發現某個資料改變時,檢視是區域性重新整理而不是整個重新渲染,如何精準的找到資料對應的檢視並進行更新呢?那就需要拿到資料改變前後的dom結構,找到差異點並進行更新!
虛擬dom實質上是針對真實dom提煉出的 簡單物件 。就像一個簡單的div包含200多個屬性,但真正需要的可能只有 tagName
,所以對真實dom直接操作將大大影響效能!
簡化後的虛擬節點(vnode)大致包含以下屬性:
{ tag: 'div',// 標籤名 data: {},// 屬性資料,包括class、style、event、props、attrs等 children: [],// 子節點陣列,也是vnode結構 text: undefined,// 文字 elm: undefined,// 真實dom key: undefined// 節點標識 }
虛擬dom的比較,就是找出新節點(vnode)和舊節點(oldVnode)之間的差異,然後對差異進行打補丁(patch)。大致流程如下
整個過程還是比較簡單的,新舊節點如果不相似,直接根據新節點建立dom;如果相似,先是對data比較,包括class、style、event、props、attrs等,有不同就呼叫對應的update函式,然後是對子節點的比較,子節點的比較用到了 diff演算法 ,這應該是這篇文章的重點和難點吧。
值得注意的是,在 Children Compare
過程中,如果找到了相似的 childVnode
,那它們將 遞迴 進入新的打補丁過程。
原始碼解析
這次的原始碼解析寫簡潔一點,寫太多發現自己都不願意看 (┬_┬)
開始
先來看 patch()
函式:
function patch (oldVnode, vnode) { var elm, parent; if (sameVnode(oldVnode, vnode)) { // 相似就去打補丁(增刪改) patchVnode(oldVnode, vnode); } else { // 不相似就整個覆蓋 elm = oldVnode.elm; parent = api.parentNode(elm); createElm(vnode); if (parent !== null) { api.insertBefore(parent, vnode.elm, api.nextSibling(elm)); removeVnodes(parent, [oldVnode], 0, 0); } } return vnode.elm; }
patch()
函式接收新舊vnode兩個引數,傳入的這兩個引數有個很大的區別:oldVnode的 elm
指向真實dom,而vnode的 elm
為undefined...但經過 patch()
方法後,vnode的 elm
也將指向這個(更新過的)真實dom。
判斷新舊vnode是否相似的 sameVnode()
方法很簡單,就是比較 tag 和 key 是否一致。
function sameVnode (a, b) { return a.key === b.key && a.tag === b.tag; }
打補丁
對於 新舊vnode不一致 的處理方法很簡單,就是根據vnode建立真實dom,代替oldVnode中的 elm
插入DOM文件。
對於 新舊vnode一致 的處理,就是我們前面經常說到的打補丁了。具體什麼是打補丁?看看 patchVnode()
方法就知道了:
function patchVnode (oldVnode, vnode) { // 新節點引用舊節點的dom let elm = vnode.elm = oldVnode.elm; const oldCh = oldVnode.children; const ch = vnode.children; // 呼叫update鉤子 if (vnode.data) { updateAttrs(oldVnode, vnode); updateClass(oldVnode, vnode); updateEventListeners(oldVnode, vnode); updateProps(oldVnode, vnode); updateStyle(oldVnode, vnode); } // 判斷是否為文字節點 if (vnode.text == undefined) { 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) } else if (isDef(oldCh)) { removeVnodes(elm, oldCh, 0, oldCh.length - 1) } else if (isDef(oldVnode.text)) { api.setTextContent(elm, '') } } else if (oldVnode.text !== vnode.text) { api.setTextContent(elm, vnode.text) } }
打補丁其實就是呼叫各種 updateXXX()
函式,更新真實dom的各個屬性。每個的update函式都類似,就拿 updateAttrs()
舉例看看:
function updateAttrs (oldVnode, vnode) { let key, cur, old const elm = vnode.elm const oldAttrs = oldVnode.data.attrs || {} const attrs = vnode.data.attrs || {} // 更新/新增屬性 for (key in attrs) { cur = attrs[key] old = oldAttrs[key] if (old !== cur) { if (booleanAttrsDict[key] && cur == null) { elm.removeAttribute(key) } else { elm.setAttribute(key, cur) } } } // 刪除新節點不存在的屬性 for (key in oldAttrs) { if (!(key in attrs)) { elm.removeAttribute(key) } } }
屬性( Attribute
)的更新函式的大致思路就是:
setAttribute() removeAttribute()
你會發現裡面有個 booleanAttrsDict[key]
的判斷,是用於判斷在不在布林型別屬性字典中。
eg: <video autoplay></video>
,想關閉自動播放,需要移除該屬性。
所有資料比較完後,就到子節點的比較了。先判斷當前vnode是否為文字節點,如果是文字節點就不用考慮子節點的比較;若是元素節點,就需要分三種情況考慮:
- 新舊節點都有children,那就進入子節點的比較(diff演算法);
- 新節點有children,舊節點沒有,那就迴圈建立dom節點;
- 新節點沒有children,舊節點有,那就迴圈刪除dom節點。
後面兩種情況都比較簡單,我們直接對第一種情況, 子節點的比較 進行分析。
diff演算法
子節點比較這部分程式碼比較多,先說說原理後面再貼程式碼。先看一張子節點比較的圖:
圖中的 oldCh
和 newCh
分別表示新舊子節點陣列,它們都有自己的頭尾指標 oldStartIdx
, oldEndIdx
, newStartIdx
, newEndIdx
,數組裡面儲存的是vnode,為了容易理解就用a,b,c,d等代替,它們表示不同型別標籤(div,span,p)的vnode物件。
子節點的比較實質上就是迴圈進行頭尾節點比較。迴圈結束的標誌就是:舊子節點陣列或新子節點陣列遍歷完,(即 oldStartIdx > oldEndIdx || newStartIdx > newEndIdx
)。大概看一下 迴圈流程 :
- 第一步 頭頭比較 。若相似,舊頭新頭指標後移(即
oldStartIdx++
&&newStartIdx++
),真實dom不變,進入下一次迴圈;不相似,進入第二步。 - 第二步 尾尾比較 。若相似,舊尾新尾指標前移(即
oldEndIdx--
&&newEndIdx--
),真實dom不變,進入下一次迴圈;不相似,進入第三步。 - 第三步 頭尾比較 。若相似,舊頭指標後移,新尾指標前移(即
oldStartIdx++
&&newEndIdx--
),未確認dom序列中的頭移到尾,進入下一次迴圈;不相似,進入第四步。 - 第四步 尾頭比較 。若相似,舊尾指標前移,新頭指標後移(即
oldEndIdx--
&&newStartIdx++
),未確認dom序列中的尾移到頭,進入下一次迴圈;不相似,進入第五步。 - 第五步 若節點有key且在舊子節點陣列中找到sameVnode(tag和key都一致),則將其dom移動到當前真實dom序列的頭部,新頭指標後移(即
newStartIdx++
);否則,vnode對應的dom(vnode[newStartIdx].elm
)插入當前真實dom序列的頭部,新頭指標後移(即newStartIdx++
)。
先看看沒有key的情況,放個動圖看得更清楚些!
相信看完圖片有更好的理解到diff演算法的精髓,整個過程還是比較簡單的。上圖中一共進入了6次迴圈,涉及了每一種情況,逐個敘述一下:
- 第一次是頭頭相似(都是
a
),dom不改變,新舊頭指標均後移。a
節點確認後,真實dom序列為:a,b,c,d,e,f
,未確認dom序列為:b,c,d,e,f
; - 第二次是尾尾相似(都是
f
),dom不改變,新舊尾指標均前移。f
節點確認後,真實dom序列為:a,b,c,d,e,f
,未確認dom序列為:b,c,d,e
; - 第三次是頭尾相似(都是
b
),當前剩餘真實dom序列中的頭移到尾,舊頭指標後移,新尾指標前移。b
節點確認後,真實dom序列為:a,c,d,e,b,f
,未確認dom序列為:c,d,e
; - 第四次是尾頭相似(都是
e
),當前剩餘真實dom序列中的尾移到頭,舊尾指標前移,新頭指標後移。e
節點確認後,真實dom序列為:a,e,c,d,b,f
,未確認dom序列為:c,d
; - 第五次是均不相似,直接插入到未確認dom序列頭部。
g
節點插入後,真實dom序列為:a,e,g,c,d,b,f
,未確認dom序列為:c,d
; - 第六次是均不相似,直接插入到未確認dom序列頭部。
h
節點插入後,真實dom序列為:a,e,g,h,c,d,b,f
,未確認dom序列為:c,d
;
但結束迴圈後,有兩種情況需要考慮:
- 新的位元組點陣列(newCh)被遍歷完(
newStartIdx > newEndIdx
)。那就需要把多餘的舊dom(oldStartIdx -> oldEndIdx
)都刪除,上述例子中就是c,d
; - 新的位元組點陣列(oldCh)被遍歷完(
oldStartIdx > oldEndIdx
)。那就需要把多餘的新dom(newStartIdx -> newEndIdx
)都新增。
上面說了這麼多都是沒有key的情況,說添加了 :key
可以優化 v-for
的效能,到底是怎麼回事呢?因為 v-for
大部分情況下生成的都是相同 tag
的標籤,如果沒有key標識,那麼相當於每次 頭頭比較 都能成功。你想想如果你往 v-for
繫結的陣列頭部push資料,那麼整個dom將全部重新整理一遍(如果陣列每項內容都不一樣),那加了 key
會有什麼幫助呢?這邊引用一張圖:
有 key
的情況,其實就是多了一步匹配查詢的過程。也就是上面迴圈流程中的第五步,會嘗試去舊子節點陣列中找到與當前新子節點相似的節點,減少dom的操作!
有興趣的可以看看程式碼:
function updateChildren (parentElm, 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, before while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { if (isUndef(oldStartVnode)) { oldStartVnode = oldCh[++oldStartIdx] // 未定義表示被移動過 } else if (isUndef(oldEndVnode)) { oldEndVnode = oldCh[--oldEndIdx] } else if (sameVnode(oldStartVnode, newStartVnode)) { // 頭頭相似 patchVnode(oldStartVnode, newStartVnode) oldStartVnode = oldCh[++oldStartIdx] newStartVnode = newCh[++newStartIdx] } else if (sameVnode(oldEndVnode, newEndVnode)) { // 尾尾相似 patchVnode(oldEndVnode, newEndVnode) oldEndVnode = oldCh[--oldEndIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldStartVnode, newEndVnode)) { // 頭尾相似 patchVnode(oldStartVnode, newEndVnode) api.insertBefore(parentElm, oldStartVnode.elm, api.nextSibling(oldEndVnode.elm)) oldStartVnode = oldCh[++oldStartIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldEndVnode, newStartVnode)) { // 尾頭相似 patchVnode(oldEndVnode, newStartVnode) api.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm) oldEndVnode = oldCh[--oldEndIdx] newStartVnode = newCh[++newStartIdx] } else { // 根據舊子節點的key,生成map對映 if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 在舊子節點陣列中,找到和newStartVnode相似節點的下標 idxInOld = oldKeyToIdx[newStartVnode.key] if (isUndef(idxInOld)) { // 沒有key,建立並插入dom api.insertBefore(parentElm, createElm(newStartVnode), oldStartVnode.elm) newStartVnode = newCh[++newStartIdx] } else { // 有key,找到對應dom ,移動該dom並在oldCh中置為undefined elmToMove = oldCh[idxInOld] patchVnode(elmToMove, newStartVnode) oldCh[idxInOld] = undefined api.insertBefore(parentElm, elmToMove.elm, oldStartVnode.elm) newStartVnode = newCh[++newStartIdx] } } } // 迴圈結束時,刪除/新增多餘dom if (oldStartIdx > oldEndIdx) { before = isUndef(newCh[newEndIdx+1]) ? null : newCh[newEndIdx + 1].elm addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) } else if (newStartIdx > newEndIdx) { removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx) } }
最後
希望看完這篇對虛擬dom的比較會有一定的瞭解!如果有什麼錯誤記得悄悄告訴我啊哈哈。
文筆還是不好,希望大家能理解o(︶︿︶)o
4篇文章寫了兩個月......真是佩服自己的執行力!但發現寫部落格好像確實挺費時的(┬_┬),不過以後一定會經常寫,先兩週一篇?:smile: