一、真實DOM和其解析流程?
瀏覽器渲染引擎工作流程都差不多,大致分為5步,創建DOM樹——創建StyleRules——創建Render樹——布局Layout——繪制Painting
第一步,用HTML分析器,分析HTML元素,構建一顆DOM樹(標記化和樹構建)。
第二步,用CSS分析器,分析CSS文件和元素上的inline樣式,生成頁面的樣式表。
第三步,將DOM樹和樣式表,關聯起來,構建一顆Render樹(這一過程又稱為Attachment)。每個DOM節點都有attach方法,接受樣式信息,返回一個render對象(又名renderer)。這些render對象最終會被構建成一顆Render樹。
第四步,有了Render樹,瀏覽器開始布局,為每個Render樹上的節點確定一個在顯示屏上出現的精確坐標。
第五步,Render樹和節點顯示坐標都有了,就調用每個節點paint方法,把它們繪制出來。
DOM樹的構建是文檔加載完成開始的?構建DOM數是一個漸進過程,為達到更好用戶體驗,渲染引擎會盡快將內容顯示在屏幕上。它不必等到整個HTML文檔解析完畢之后才開始構建render數和布局。
Render樹是DOM樹和CSSOM樹構建完畢才開始構建的嗎?這三個過程在實際進行的時候又不是完全獨立,而是會有交叉。會造成一邊加載,一遍解析,一遍渲染的工作現象。
CSS的解析是從右往左逆向解析的(從DOM樹的下-上解析比上-下解析效率高),嵌套標簽越多,解析越慢。

二、JS操作真實DOM的代價!
用我們傳統的開發模式,原生JS或JQ操作DOM時,瀏覽器會從構建DOM樹開始從頭到尾執行一遍流程。在一次操作中,我需要更新10個DOM節點,瀏覽器收到第一個DOM請求后並不知道還有9次更新操作,因此會馬上執行流程,最終執行10次。例如,第一次計算完,緊接着下一個DOM更新請求,這個節點的坐標值就變了,前一次計算為無用功。計算DOM節點坐標值等都是白白浪費的性能。即使計算機硬件一直在迭代更新,操作DOM的代價仍舊是昂貴的,頻繁操作還是會出現頁面卡頓,影響用戶體驗。
三、為什么需要虛擬DOM,它有什么好處?
Web界面由DOM樹(樹的意思是數據結構)來構建,當其中一部分發生變化時,其實就是對應某個DOM節點發生了變化,
虛擬DOM就是為了解決瀏覽器性能問題而被設計出來的。如前,若一次操作中有10次更新DOM的動作,虛擬DOM不會立即操作DOM,而是將這10次更新的diff內容保存到本地一個JS對象中,最終將這個JS對象一次性attch到DOM樹上,再進行后續操作,避免大量無謂的計算量。所以,用JS對象模擬DOM節點的好處是,頁面的更新可以先全部反映在JS對象(虛擬DOM)上,操作內存中的JS對象的速度顯然要更快,等更新完成后,再將最終的JS對象映射成真實的DOM,交由瀏覽器去繪制。
四、實現虛擬DOM
例如一個真實的DOM節點。

我們用JS來模擬DOM節點實現虛擬DOM。

其中的Element方法具體怎么實現的呢?

第一個參數是節點名(如div),第二個參數是節點的屬性(如class),第三個參數是子節點(如ul的li)。除了這三個參數會被保存在對象上外,還保存了key和count。其相當於形成了虛擬DOM樹。

有了JS對象后,最終還需要將其映射成真實DOM

我們已經完成了創建虛擬DOM並將其映射成真實DOM,這樣所有的更新都可以先反應到虛擬DOM上,如何反應?需要用到Diff算法。
兩棵樹如果完全比較時間復雜度是O(n^3),但參照《深入淺出React和Redux》一書中的介紹,React的Diff算法的時間復雜度是O(n)。要實現這么低的時間復雜度,意味着只能平層的比較兩棵樹的節點,放棄了深度遍歷。這樣做,似乎犧牲掉了一定的精確性來換取速度,但考慮到現實中前端頁面通常也不會跨層移動DOM元素,這樣做是最優的。
深度優先遍歷,記錄差異
。。。。
Diff操作
在實際代碼中,會對新舊兩棵樹進行一個深度的遍歷,每個節點都會有一個標記。每遍歷到一個節點就把該節點和新的樹進行對比,如果有差異就記錄到一個對象中。
下面我們創建一棵新樹,用於和之前的樹進行比較,來看看Diff算法是怎么操作的。


平層Diff,只有以下4種情況:
1、節點類型變了,例如下圖中的P變成了H3。我們將這個過程稱之為REPLACE。直接將舊節點卸載並裝載新節點。舊節點包括下面的子節點都將被卸載,如果新節點和舊節點僅僅是類型不同,但下面的所有子節點都一樣時,這樣做效率不高。但為了避免O(n^3)的時間復雜度,這樣是值得的。這也提醒了開發者,應該避免無謂的節點類型的變化,例如運行時將div變成p沒有意義。
2、節點類型一樣,僅僅屬性或屬性值變了。我們將這個過程稱之為PROPS。此時不會觸發節點卸載和裝載,而是節點更新。

3、文本變了,文本對也是一個Text Node,也比較簡單,直接修改文字內容就行了,我們將這個過程稱之為TEXT。
4、移動/增加/刪除 子節點,我們將這個過程稱之為REORDER。看一個例子,在A、B、C、D、E五個節點的B和C中的BC兩個節點中間加入一個F節點。

我們簡單粗暴的做法是遍歷每一個新虛擬DOM的節點,與舊虛擬DOM對比相應節點對比,在舊DOM中是否存在,不同就卸載原來的按上新的。這樣會對F后邊每一個節點進行操作。卸載C,裝載F,卸載D,裝載C,卸載E,裝載D,裝載E。效率太低。

如果我們在JSX里為數組或枚舉型元素增加上key后,它能夠根據key,直接找到具體位置進行操作,效率比較高。常見的最小編輯距離問題,可以用Levenshtein Distance算法來實現,時間復雜度是O(M*N),但通常我們只要一些簡單的移動就能滿足需要,降低精確性,將時間復雜度降低到O(max(M,N))即可。

映射成真實DOM
虛擬DOM有了,Diff也有了,現在就可以將Diff應用到真實DOM上了。深度遍歷DOM將Diff的內容更新進去。


我們會有兩個虛擬DOM(js對象,new/old進行比較diff),用戶交互我們操作數據變化new虛擬DOM,old虛擬DOM會映射成實際DOM(js對象生成的DOM文檔)通過DOM fragment操作給瀏覽器渲染。當修改new虛擬DOM,會把newDOM和oldDOM通過diff算法比較,得出diff結果數據表(用4種變換情況表示)。再把diff結果表通過DOM fragment更新到瀏覽器DOM中。
虛擬DOM的存在的意義?vdom 的真正意義是為了實現跨平台,服務端渲染,以及提供一個性能還算不錯 Dom 更新策略。vdom 讓整個 mvvm 框架靈活了起來
Diff算法只是為了虛擬DOM比較替換效率更高,通過Diff算法得到diff算法結果數據表(需要進行哪些操作記錄表)。原本要操作的DOM在vue這邊還是要操作的,只不過用到了js的DOM fragment來操作dom(統一計算出所有變化后統一更新一次DOM)進行瀏覽器DOM一次性更新。其實DOM fragment我們不用平時發開也能用,但是這樣程序員寫業務代碼就用把DOM操作放到fragment里,這就是框架的價值,程序員才能專注於寫業務代碼。
=======================
先說一下為什么會有虛擬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
)的更新函數的大致思路就是:
- 遍歷vnode屬性,如果和oldVnode不一樣就調用
setAttribute()
修改; - 遍歷oldVnode屬性,如果不在vnode屬性中就調用
removeAttribute()
刪除。
你會發現里面有個booleanAttrsDict[key]
的判斷,是用於判斷在不在布爾類型屬性字典中。
['allowfullscreen', 'async', 'autofocus', 'autoplay', 'checked', 'compact', 'controls', 'declare', ......]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)) { // 頭尾相似