vue原始碼學習-虛擬dom
真實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節點
<div id="real-container">
<p>Real DOM</p>
<div>cannot update</div>
<ul>
<li className="item">Item 1</li>
<li className="item">Item 2</li>
<li className="item">Item 3</li>
</ul>
</div>
使用JS來模擬DOM節點實現虛擬DOM
cont tree = Element('div',{id: 'virtual-container},[
Elemet('p',{},['Virtual DOM']),
Element('div',{},['before update']),
Element('ul',{},[
Element('li',{class: 'item'},['Item 1']),
Element('li',{class: 'item'},['item 2']),
Element('li',{class: 'Item'},['Item 3'])
])
]
)
const root tree.render()
document.getElmentById('virtualDom').appendChild(root)
其中Element方法具體怎麼實現的呢?
function Element(tagName,props,children) {
console.log(this)
if (!(this instanceof Element)) {
return new Element(tagName, props, children)
}
this.tagName = tagName
this.props = props || {}
this.children = children || []
let count = 0
this.children.forEach((child) => {
if (child instanceof Element) {
count+=child.count
}
count++
})
this.count = count
}
第一個引數是節點名(如div),第二個引數是節點的屬性(如class),第三個引數是子節點(如ul的li)。除了這三個引數會被儲存在物件上外,還儲存了key和count。其相當於想成了虛擬DOM樹。
有了JS物件後,最終還需要將其對映成真實的dom
Element.prototype.render = function(){
const el = document.createElement(this.tagName)
const props = this.props
for(const propName in props){
setAttr(el, propName, props[propName])
}
this.children.forEach((child) => {
const childEl = (child instanceof Element) ? child.render():document.createTextNode(child)
})
}
我們已經完成了建立虛擬DOM並將其對映成真實DOM,這樣所有的更新都可以先反應在虛擬DOM上,如何反應?需要用到diff演算法
vue核心之虛擬DOM(vdom)
在實際程式碼中,會對新舊兩棵樹進行一個深度的遍歷,每個節點都會有一個標記,每遍歷到一個節點就把該節點和新的樹進行對比,如果有差異就記錄到一個物件中。下面我們建立一個樹,用於和之前的樹進行比較,來看看diff演算法是怎麼操作的。
diff操作
在實際程式碼中,會對新舊兩棵樹進行一個深度的遍歷,每個節點都會有一個標記。每到一個節點就把該節點和新的樹進行對比,如果有差異就記錄到一個物件中。
下面我們建立一個新樹,用於和之前的樹進行比較,來看看diff演算法是怎麼操作的。old tree
const tree = Elment('div',{id: 'virtural-container'},[
Element('p',{},['Virtual Dom']),
Element('div',{},['before update']),
Element('ul',{},[
Element('li',{class:'item'},['Item 1']),
Element('li',{class:'item'},['Item 2']),
Element('li',{class:'item'},['Item 3'])
])
])
const root = tree.render()
doucument.getElementById('virtualDom').appendChild(root)
new tree
const tree = Elment('div',{id: 'virtural-container'},[
Element('h3',{},['Virtual Dom']), // REPLACE
Element('div',{},['after update']), // TEXT
Element('ul',{calss: 'marginLeft10'},[ //PROPS
Element('li',{class:'item'},['Item 1']),
// Element('li',{class:'item'},['Item 2']), // REORDER remove
Element('li',{class:'item'},['Item 3'])
])
])
平層diff,只有一下4種情況:
- 節點型別變了例如下圖中的p變成了h3。我們將這個過程稱之為REPLACE。直接將舊節點解除安裝並裝載新節點。舊節點包括下面的子節點都將被解除安裝,如果心機誒單和舊節點僅僅是型別不同,但下面的所有子節點都一樣時,這樣做的效率不高。但為了避免O(n^3)的時間複雜度,這樣時值得的。這樣提醒了開發者,應該避免無謂的節點型別的變化,例如執行時將div變成p沒有意義。
- 節點型別一樣,僅僅屬性或屬性值變了。我們將這個過程稱之為PROPS,此時不會觸發節點解除安裝和裝載,而是節點更新。
function diffProps(oldNode,newNode){
const oldProps = oldNode.props
const newProps = newOld.props
let key
const propsPatches = {}
let isSame = true
for(key in oldProps) {
if(newProps[key] !== oldProps[key]) {
isSame = false
propsPatches[key] = newProps[key]
}
}
for(key in newProps) {
if(!oldProps.hasOwnProperty(key)){
isSame = false
propsPatches[key] = newProps[key]
}
}
return isSame? null : propsPatches
}
- 文字變了,文字也是一個Text Node,也比較簡單,直接修改文字內容就行了,我們將這個過程稱之為TEXT。
- 移動/增加/刪除子節點我們將這個過程稱為REORDER。看一個例子,在A、B、C、D、E五個節點的B和C中的BC兩個節點中間加入一個F節點。
我們簡單粗暴的做法是遍歷每一個新虛擬DOM的節點,與舊虛擬DOM對比相應節點對比,在舊DOM中是否存在,不同就解除安裝原來的安上新的。這樣會對F後邊每一個節點進行操作。解除安裝C,裝載,解除安裝D,裝載C,解除安裝E,裝載D,裝載E。效率太低。
如果我們在JSX裡為陣列或列舉型元素增加上key後,它能夠根據key,直接找到具體位置進行操作,效率比較高。常見的最小編輯距離問題,可以用Levenshtein Distance演算法來實現,時間複雜度是O(M*N),但通常我們只要一些簡單的移動就能滿足需要,降低精確性,將時間複雜度降低到O(max(M,N))即可。
對映成真實DOM
虛擬DOM有了,diff有了,現在就可以將diff應用到真實DOM上了,深度遍歷DOM將diff的內容更新進去。
function dfsWalk(node,walker,patches) {
const currentPatches = patches[walker.index]
const len = node.childNodes? node.childNodes.length:0
for(let i = 0; i< len;i++){
walker.index++
dfsWalk(node.childNodes[i],walker,patches)
}
if(currentPatches){
applyPatches(node,currentPatches)
}
}
function applyPatches(node, currentPatches) {
currentPatches.forEach((currentPatch) => {
switch (currentPatch.type) {
case REPLACE:{
const newNode = (typeof currentPatch.node === 'string')?document.createTextNode(currentPatch.node):node.patentNode.replaceChild(newNode,node)
break;
}
case REORDER:
reorderChildren(node, currentPatch.moves)
break
case PROPS:
setProps(node,currentPatch.props)
break
case TEXT:
if(node.textContent) {
node.textContent = currentPatch.content
} else {
node.nodeValue = currentPatch.content
}
break
default:
throw new Error(`unknown patch type ${currentPatch.type}`)
}
})
}
我們會有兩個虛擬DOM(js物件,new/old進行比較diff),使用者互動我們操作資料變化new虛擬DOM,old虛擬DOM會對映成實際DOM(js物件生成的dom文件)通過DOM fragment操作給瀏覽器渲染。當修改new虛擬dom,會把newDom和oldDOM通過diff演算法比較,得出diff結果資料表(用4中變換情況表示)。在把diff結果表通過DOM fragmeng更新到瀏覽器DOM中
虛擬DOM的存在的意義
vdom的真正意思是為了實現跨平臺,服務端渲染,以及提供一個性能還算不錯的dom更新策略。vdom讓整個mvvm框架靈活起來。
diff演算法只是為了虛擬DOM比較替換效率更高,通過diff演算法得到diff演算法結果資料表(需要進行哪些操作記錄表)。原本操作的DOM在vue這邊還是要操作的,子不過用到了js的DOM fragment來操作dom(統一計算出所有變化後統一更新一次DOM)進行瀏覽器DOM一次性更新。起始DOM fragment我們不用平時開發也能用,但是這樣程式設計師寫業務程式碼就用把DOM操作放到fragment裡,這就是框架的價值,程式設計師才能專注於寫業務程式碼。
瞭解vue虛擬DOM的參考——snabbdom.js
如果要我們自己去四線一個虛擬dom,大概過程應該有以下三步
- compile,如何把真實DOM編譯成vnode
- diff,我們要如何知道oldVnode和newVnde之間的變化。
- patch,如果把這些變化用打補丁的方式更新到真實的dom上去。