1. 程式人生 > 其它 >vue原始碼學習-虛擬dom

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種情況:

  1. 節點型別變了例如下圖中的p變成了h3。我們將這個過程稱之為REPLACE。直接將舊節點解除安裝並裝載新節點。舊節點包括下面的子節點都將被解除安裝,如果心機誒單和舊節點僅僅是型別不同,但下面的所有子節點都一樣時,這樣做的效率不高。但為了避免O(n^3)的時間複雜度,這樣時值得的。這樣提醒了開發者,應該避免無謂的節點型別的變化,例如執行時將div變成p沒有意義。
  2. 節點型別一樣,僅僅屬性或屬性值變了。我們將這個過程稱之為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
}
  1. 文字變了,文字也是一個Text Node,也比較簡單,直接修改文字內容就行了,我們將這個過程稱之為TEXT。
  2. 移動/增加/刪除子節點我們將這個過程稱為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,大概過程應該有以下三步

  1. compile,如何把真實DOM編譯成vnode
  2. diff,我們要如何知道oldVnode和newVnde之間的變化。
  3. patch,如果把這些變化用打補丁的方式更新到真實的dom上去。