深度剖析:如何實現一個 Virtual DOM 算法

做者:戴嘉華javascript

轉載請註明出處並保留原文連接( https://github.com/livoras/blog/issues/13 )和做者信息。html

目錄:

  • 1 前言前端

  • 2 對前端應用狀態管理思考java

  • 3 Virtual DOM 算法node

  • 4 算法實現git

    • 4.1 步驟一:用JS對象模擬DOM樹github

    • 4.2 步驟二:比較兩棵虛擬DOM樹的差別算法

    • 4.3 步驟三:把差別應用到真正的DOM樹上編程

  • 5 結語segmentfault

  • 6 References

1 前言

本文會在教你怎麼用 300~400 行代碼實現一個基本的 Virtual DOM 算法,而且嘗試儘可能把 Virtual DOM 的算法思路闡述清楚。但願在閱讀本文後,能讓你深刻理解 Virtual DOM 算法,給你現有前端的編程提供一些新的思考。

本文所實現的完整代碼存放在 Github

2 對前端應用狀態管理的思考

假如如今你須要寫一個像下面同樣的表格的應用程序,這個表格能夠根據不一樣的字段進行升序或者降序的展現。

sort-table

這個應用程序看起來很簡單,你能夠想出好幾種不一樣的方式來寫。最容易想到的多是,在你的 JavaScript 代碼裏面存儲這樣的數據:

var sortKey = "new" // 排序的字段,新增(new)、取消(cancel)、淨關注(gain)、累積(cumulate)人數
var sortType = 1 // 升序仍是逆序
var data = [{...}, {...}, {..}, ..] // 表格數據

用三個字段分別存儲當前排序的字段、排序方向、還有表格數據;而後給表格頭部加點擊事件:當用戶點擊特定的字段的時候,根據上面幾個字段存儲的內容來對內容進行排序,而後用 JS 或者 jQuery 操做 DOM,更新頁面的排序狀態(表頭的那幾個箭頭表示當前排序狀態,也須要更新)和表格內容。

這樣作會致使的後果就是,隨着應用程序愈來愈複雜,須要在JS裏面維護的字段也愈來愈多,須要監聽事件和在事件回調用更新頁面的DOM操做也愈來愈多,應用程序會變得很是難維護。後來人們使用了 MVC、MVP 的架構模式,但願能從代碼組織方式來下降維護這種複雜應用程序的難度。可是 MVC 架構沒辦法減小你所維護的狀態,也沒有下降狀態更新你須要對頁面的更新操做(前端來講就是DOM操做),你須要操做的DOM仍是須要操做,只是換了個地方。

既然狀態改變了要操做相應的DOM元素,爲何不作一個東西可讓視圖和狀態進行綁定,狀態變動了視圖自動變動,就不用手動更新頁面了。這就是後來人們想出了 MVVM 模式,只要在模版中聲明視圖組件是和什麼狀態進行綁定的,雙向綁定引擎就會在狀態更新的時候自動更新視圖(關於MV*模式的內容,能夠看這篇介紹)。

MVVM 能夠很好的下降咱們維護狀態 -> 視圖的複雜程度(大大減小代碼中的視圖更新邏輯)。可是這不是惟一的辦法,還有一個很是直觀的方法,能夠大大下降視圖更新的操做:一旦狀態發生了變化,就用模版引擎從新渲染整個視圖,而後用新的視圖更換掉舊的視圖。就像上面的表格,當用戶點擊的時候,仍是在JS裏面更新狀態,可是頁面更新就不用手動操做 DOM 了,直接把整個表格用模版引擎從新渲染一遍,而後設置一下innerHTML就完事了。

聽到這樣的作法,經驗豐富的你必定第一時間意識這樣的作法會致使不少的問題。最大的問題就是這樣作會很慢,由於即便一個小小的狀態變動都要從新構造整棵 DOM,性價比過低;並且這樣作的話,inputtextarea的會失去原有的焦點。最後的結論會是:對於局部的小視圖的更新,沒有問題(Backbone就是這麼幹的);可是對於大型視圖,如全局應用狀態變動的時候,須要更新頁面較多局部視圖的時候,這樣的作法不可取。

可是這裏要明白和記住這種作法,由於後面你會發現,其實 Virtual DOM 就是這麼作的,只是加了一些特別的步驟來避免了整棵 DOM 樹變動

另一點須要注意的就是,上面提供的幾種方法,其實都在解決同一個問題:維護狀態,更新視圖。在通常的應用當中,若是可以很好方案來應對這個問題,那麼就幾乎下降了大部分複雜性。

3 Virtual DOM算法

DOM是很慢的。若是咱們把一個簡單的div元素的屬性都打印出來,你會看到:

dom-attr

而這僅僅是第一層。真正的 DOM 元素很是龐大,這是由於標準就是這麼設計的。並且操做它們的時候你要當心翼翼,輕微的觸碰可能就會致使頁面重排,這但是殺死性能的罪魁禍首。

相對於 DOM 對象,原生的 JavaScript 對象處理起來更快,並且更簡單。DOM 樹上的結構、屬性信息咱們均可以很容易地用 JavaScript 對象表示出來:

var element = {
  tagName: 'ul', // 節點標籤名
  props: { // DOM的屬性,用一個對象存儲鍵值對
    id: 'list'
  },
  children: [ // 該節點的子節點
    {tagName: 'li', props: {class: 'item'}, children: ["Item 1"]},
    {tagName: 'li', props: {class: 'item'}, children: ["Item 2"]},
    {tagName: 'li', props: {class: 'item'}, children: ["Item 3"]},
  ]
}

上面對應的HTML寫法是:

<ul id='list'>
  <li class='item'>Item 1</li>
  <li class='item'>Item 2</li>
  <li class='item'>Item 3</li>
</ul>

既然原來 DOM 樹的信息均可以用 JavaScript 對象來表示,反過來,你就能夠根據這個用 JavaScript 對象表示的樹結構來構建一棵真正的DOM樹。

以前的章節所說的,狀態變動->從新渲染整個視圖的方式能夠稍微修改一下:用 JavaScript 對象表示 DOM 信息和結構,當狀態變動的時候,從新渲染這個 JavaScript 的對象結構。固然這樣作其實沒什麼卵用,由於真正的頁面其實沒有改變。

可是能夠用新渲染的對象樹去和舊的樹進行對比,記錄這兩棵樹差別。記錄下來的不一樣就是咱們須要對頁面真正的 DOM 操做,而後把它們應用在真正的 DOM 樹上,頁面就變動了。這樣就能夠作到:視圖的結構確實是整個全新渲染了,可是最後操做DOM的時候確實只變動有不一樣的地方。

這就是所謂的 Virtual DOM 算法。包括幾個步驟:

  1. 用 JavaScript 對象結構表示 DOM 樹的結構;而後用這個樹構建一個真正的 DOM 樹,插到文檔當中

  2. 當狀態變動的時候,從新構造一棵新的對象樹。而後用新的樹和舊的樹進行比較,記錄兩棵樹差別

  3. 把2所記錄的差別應用到步驟1所構建的真正的DOM樹上,視圖就更新了

Virtual DOM 本質上就是在 JS 和 DOM 之間作了一個緩存。能夠類比 CPU 和硬盤,既然硬盤這麼慢,咱們就在它們之間加個緩存:既然 DOM 這麼慢,咱們就在它們 JS 和 DOM 之間加個緩存。CPU(JS)只操做內存(Virtual DOM),最後的時候再把變動寫入硬盤(DOM)。

4 算法實現

4.1 步驟一:用JS對象模擬DOM樹

用 JavaScript 來表示一個 DOM 節點是很簡單的事情,你只須要記錄它的節點類型、屬性,還有子節點:

element.js

function Element (tagName, props, children) {
  this.tagName = tagName
  this.props = props
  this.children = children
}

module.exports = function (tagName, props, children) {
  return new Element(tagName, props, children)
}

例如上面的 DOM 結構就能夠簡單的表示:

var el = require('./element')

var ul = el('ul', {id: 'list'}, [
  el('li', {class: 'item'}, ['Item 1']),
  el('li', {class: 'item'}, ['Item 2']),
  el('li', {class: 'item'}, ['Item 3'])
])

如今ul只是一個 JavaScript 對象表示的 DOM 結構,頁面上並無這個結構。咱們能夠根據這個ul構建真正的<ul>

Element.prototype.render = function () {
  var el = document.createElement(this.tagName) // 根據tagName構建
  var props = this.props

  for (var propName in props) { // 設置節點的DOM屬性
    var propValue = props[propName]
    el.setAttribute(propName, propValue)
  }

  var children = this.children || []

  children.forEach(function (child) {
    var childEl = (child instanceof Element)
      ? child.render() // 若是子節點也是虛擬DOM,遞歸構建DOM節點
      : document.createTextNode(child) // 若是字符串,只構建文本節點
    el.appendChild(childEl)
  })

  return el
}

render方法會根據tagName構建一個真正的DOM節點,而後設置這個節點的屬性,最後遞歸地把本身的子節點也構建起來。因此只須要:

var ulRoot = ul.render()
document.body.appendChild(ulRoot)

上面的ulRoot是真正的DOM節點,把它塞入文檔中,這樣body裏面就有了真正的<ul>的DOM結構:

<ul id='list'>
  <li class='item'>Item 1</li>
  <li class='item'>Item 2</li>
  <li class='item'>Item 3</li>
</ul>

完整代碼可見 element.js

4.2 步驟二:比較兩棵虛擬DOM樹的差別

正如你所預料的,比較兩棵DOM樹的差別是 Virtual DOM 算法最核心的部分,這也是所謂的 Virtual DOM 的 diff 算法。兩個樹的徹底的 diff 算法是一個時間複雜度爲 O(n^3) 的問題。可是在前端當中,你不多會跨越層級地移動DOM元素。因此 Virtual DOM 只會對同一個層級的元素進行對比:

compare-in-level

上面的div只會和同一層級的div對比,第二層級的只會跟第二層級對比。這樣算法複雜度就能夠達到 O(n)。

4.2.1 深度優先遍歷,記錄差別

在實際的代碼中,會對新舊兩棵樹進行一個深度優先的遍歷,這樣每一個節點都會有一個惟一的標記:

dfs-walk

在深度優先遍歷的時候,每遍歷到一個節點就把該節點和新的的樹進行對比。若是有差別的話就記錄到一個對象裏面。

// diff 函數,對比兩棵樹
function diff (oldTree, newTree) {
  var index = 0 // 當前節點的標誌
  var patches = {} // 用來記錄每一個節點差別的對象
  dfsWalk(oldTree, newTree, index, patches)
  return patches
}

// 對兩棵樹進行深度優先遍歷
function dfsWalk (oldNode, newNode, index, patches) {
  // 對比oldNode和newNode的不一樣,記錄下來
  patches[index] = [...]
  
  diffChildren(oldNode.children, newNode.children, index, patches)
}

// 遍歷子節點
function diffChildren (oldChildren, newChildren, index, patches) {
  var leftNode = null
  var currentNodeIndex = index
  oldChildren.forEach(function (child, i) {
    var newChild = newChildren[i]
    currentNodeIndex = (leftNode && leftNode.count) // 計算節點的標識
      ? currentNodeIndex + leftNode.count + 1
      : currentNodeIndex + 1
    dfsWalk(child, newChild, currentNodeIndex, patches) // 深度遍歷子節點
    leftNode = child
  })
}

例如,上面的div和新的div有差別,當前的標記是0,那麼:

patches[0] = [{difference}, {difference}, ...] // 用數組存儲新舊節點的不一樣

同理ppatches[1]ulpatches[3],類推。

4.2.2 差別類型

上面說的節點的差別指的是什麼呢?對 DOM 操做可能會:

  1. 替換掉原來的節點,例如把上面的div換成了section

  2. 移動、刪除、新增子節點,例如上面div的子節點,把pul順序互換

  3. 修改了節點的屬性

  4. 對於文本節點,文本內容可能會改變。例如修改上面的文本節點2內容爲Virtual DOM 2

因此咱們定義了幾種差別類型:

var REPLACE = 0
var REORDER = 1
var PROPS = 2
var TEXT = 3

對於節點替換,很簡單。判斷新舊節點的tagName和是否是同樣的,若是不同的說明須要替換掉。如div換成section,就記錄下:

patches[0] = [{
  type: REPALCE,
  node: newNode // el('section', props, children)
}]

若是給div新增了屬性idcontainer,就記錄下:

patches[0] = [{
  type: REPALCE,
  node: newNode // el('section', props, children)
}, {
  type: PROPS,
  props: {
    id: "container"
  }
}]

若是是文本節點,如上面的文本節點2,就記錄下:

patches[2] = [{
  type: TEXT,
  content: "Virtual DOM2"
}]

那若是把我div的子節點從新排序呢?例如p, ul, div的順序換成了div, p, ul。這個該怎麼對比?若是按照同層級進行順序對比的話,它們都會被替換掉。如pdivtagName不一樣,p會被div所替代。最終,三個節點都會被替換,這樣DOM開銷就很是大。而其實是不須要替換節點,而只須要通過節點移動就能夠達到,咱們只需知道怎麼進行移動。

這牽涉到兩個列表的對比算法,須要另外起一個小節來討論。

4.2.3 列表對比算法

假設如今能夠英文字母惟一地標識每個子節點:

舊的節點順序:

a b c d e f g h i

如今對節點進行了刪除、插入、移動的操做。新增j節點,刪除e節點,移動h節點:

新的節點順序:

a b c h d f g h i j

如今知道了新舊的順序,求最小的插入、刪除操做(移動能夠當作是刪除和插入操做的結合)。這個問題抽象出來實際上是字符串的最小編輯距離問題(Edition Distance),最多見的解決算法是 Levenshtein Distance,經過動態規劃求解,時間複雜度爲 O(M * N)。可是咱們並不須要真的達到最小的操做,咱們只須要優化一些比較常見的移動狀況,犧牲必定DOM操做,讓算法時間複雜度達到線性的(O(max(M, N))。具體算法細節比較多,這裏不累述,有興趣能夠參考代碼

咱們可以獲取到某個父節點的子節點的操做,就能夠記錄下來:

patches[0] = [{
  type: REORDER,
  moves: [{remove or insert}, {remove or insert}, ...]
}]

可是要注意的是,由於tagName是可重複的,不能用這個來進行對比。因此須要給子節點加上惟一標識key,列表對比的時候,使用key進行對比,這樣才能複用老的 DOM 樹上的節點。

這樣,咱們就能夠經過深度優先遍歷兩棵樹,每層的節點進行對比,記錄下每一個節點的差別了。完整 diff 算法代碼可見 diff.js

4.3 步驟三:把差別應用到真正的DOM樹上

由於步驟一所構建的 JavaScript 對象樹和render出來真正的DOM樹的信息、結構是同樣的。因此咱們能夠對那棵DOM樹也進行深度優先的遍歷,遍歷的時候從步驟二生成的patches對象中找出當前遍歷的節點差別,而後進行 DOM 操做。

function patch (node, patches) {
  var walker = {index: 0}
  dfsWalk(node, walker, patches)
}

function dfsWalk (node, walker, patches) {
  var currentPatches = patches[walker.index] // 從patches拿出當前節點的差別

  var len = node.childNodes
    ? node.childNodes.length
    : 0
  for (var i = 0; i < len; i++) { // 深度遍歷子節點
    var child = node.childNodes[i]
    walker.index++
    dfsWalk(child, walker, patches)
  }

  if (currentPatches) {
    applyPatches(node, currentPatches) // 對當前節點進行DOM操做
  }
}

applyPatches,根據不一樣類型的差別對當前節點進行 DOM 操做:

function applyPatches (node, currentPatches) {
  currentPatches.forEach(function (currentPatch) {
    switch (currentPatch.type) {
      case REPLACE:
        node.parentNode.replaceChild(currentPatch.node.render(), node)
        break
      case REORDER:
        reorderChildren(node, currentPatch.moves)
        break
      case PROPS:
        setProps(node, currentPatch.props)
        break
      case TEXT:
        node.textContent = currentPatch.content
        break
      default:
        throw new Error('Unknown patch type ' + currentPatch.type)
    }
  })
}

完整代碼可見 patch.js

5 結語

Virtual DOM 算法主要是實現上面步驟的三個函數:elementdiffpatch。而後就能夠實際的進行使用:

// 1. 構建虛擬DOM
var tree = el('div', {'id': 'container'}, [
    el('h1', {style: 'color: blue'}, ['simple virtal dom']),
    el('p', ['Hello, virtual-dom']),
    el('ul', [el('li')])
])

// 2. 經過虛擬DOM構建真正的DOM
var root = tree.render()
document.body.appendChild(root)

// 3. 生成新的虛擬DOM
var newTree = el('div', {'id': 'container'}, [
    el('h1', {style: 'color: red'}, ['simple virtal dom']),
    el('p', ['Hello, virtual-dom']),
    el('ul', [el('li'), el('li')])
])

// 4. 比較兩棵虛擬DOM樹的不一樣
var patches = diff(tree, newTree)

// 5. 在真正的DOM元素上應用變動
patch(root, patches)

固然這是很是粗糙的實踐,實際中還須要處理事件監聽等;生成虛擬 DOM 的時候也能夠加入 JSX 語法。這些事情都作了的話,就能夠構造一個簡單的ReactJS了。

本文所實現的完整代碼存放在 Github,僅供學習。

6 References

https://github.com/Matt-Esch/virtual-dom/blob/master/vtree/diff.js

相關文章
相關標籤/搜索