深度理解 Virtual DOM

吳佰清發表於2017-04-18

目錄:

1 前言

2 技術發展史

3 Virtual DOM 演算法

4 Virtual DOM 實現

5 Virtual DOM 樹的差異(Diff演算法)

6 結語

7 參考連結

 

1 前言

我會盡量把 Virtual DOM 應用場景、實現思路、演算法講述清楚,希望大家閱讀後,能讓你

深入理解 Virtual DOM。

 

2 技術發展史

寫一個像下面的應用程式,這個表格可以根據不同的欄位進行升序或者降序。

最容易的方案是在你的 JavaScript 程式碼裡面儲存這樣的資料:

var sortKey = "name" // 排序的欄位,名稱(name)、年齡(age)
var sortType = "ASC" // 升序還是逆序
var data = [{...}, {...}, {..}, ..] // 表格資料

用三個變數分別儲存當前排序的欄位、排序方向、還有表格資料;然後給表格頭部加

點選事件,當使用者點選排序欄位時,會根據上面幾個變數儲存的值來對內容進行排序,

然後用 JS 操作 DOM,更新頁面的排序狀態和表格內容。

 

這麼做會導致一個問題:應用越來越複雜,需要在 JS 裡面維護的欄位也越來越多,

要監聽的事件和在事件中回撥更新頁面的 DOM 也越來越多,最終應用變得難以維護。

 

之後研究出了 MVC、MVP 的架構模式,希望從程式碼組織的方式來降低維護複雜應用的

難度。但是 MVC 架構沒辦法減少你所維護的狀態,也沒有降低狀態更新時需要對頁面

的更新操作,你需要操作的 DOM 還是需要操作,只是換了個地方而已。

 

既然狀態改變了要操作相應的 DOM 元素,為什麼不做一個東西可以讓檢視和狀態進行

繫結?讓狀態變更檢視自動跟著變更,就不用手動更新頁面了。這就是後來的 MVVM

式,只要在模版中宣告檢視元件是和什麼狀態進行繫結的,雙向繫結引擎就會在狀態

更新的時候自動更新檢視,MVVM 可以能很好的降低維護狀態以及減少檢視的複雜程度。

 

但這不是唯一辦法,還有一個非常直觀的方法,可以大大降低檢視更新的操作。一旦

態發生了變化,就用模版引擎重新渲染整個檢視,然後用新的檢視更換掉舊的檢視。

像上面的表格,當使用者點選的時,還是在 JS 裡面更新狀態,但是頁面更新就不用手

作 DOM 了,直接把整個表格用模版引擎重新渲染一遍,然後設定一下 innerHTML 。

麼這個方法會有個很大的問題,會導致 DOM 操作變慢,因為任何的狀態變更都要

新構造整個 DOM,價效比很低。對於區域性的小檢視的更新,這樣沒有問題(backbone

就是這麼幹的)。但對於大型檢視,需要更新頁面較多區域性檢視時,這樣的做法就非常

可取。

 

Virtual DOM 也是這麼做的,只是加了一些步驟來避免了整棵 DOM 樹變更。上面提供

的幾種方法,其實都在解決同一個問題,那就是維護狀態更新檢視。如果我們能夠很好

應對這個問題,就降低複雜性。

 

3 Virtual DOM 演算法

DOM 很慢,為啥說它慢,先看一下 Webkit 引擎,所有瀏覽器都遵循類似的工作流,只

是在細節處理有些不同。一旦瀏覽器接收到一個 HTML 檔案,渲染引擎 Render Engine

就開始解它,根據 HTML 元素 Elements 對應地生成 DOM 節點 Nodes,最終組成一

棵 DOM 樹。

 

構造了渲染樹以後,瀏覽器引擎開始著手佈局 Layout。佈局時,渲染樹上的每個節點根

其在螢幕上應該出現的精確位置,分配一組螢幕座標值。接著,瀏覽器將會通過遍歷渲染樹,

呼叫每個節點的 Paint 方法來繪製這些 Render 物件。Paint 方法根據瀏覽器平臺,使用不

同的 UI後端 API(Agnostic UI Backend API)通過繪製,最終將在螢幕上展示內容。只要

在這過程中進行一次 DOM 更新,整個渲染流程都會重做一遍。

 

把一個簡單的 div 元素的屬性都列印出來,你會看這些。

align, onwaiting, onvolumechange, ontimeupdate, onsuspend, onsubmit,
onstalled, onshow, onselect, onseeking, onseeked, onscroll, onresize,
onreset, onratechange, onprogress, onplaying, onplay, onpause,
onmousewheel, onmouseup, onmouseover, onmouseout, onmousemove,
onmouseleave, onmouseenter, onmousedown, onloadstart,
onloadedmetadata, onloadeddata, onload, onkeyup, onkeypress,
onkeydown, oninvalid, oninput, onfocus, onerror, onended, onemptied,
ondurationchange, ondrop, ondragstart, ondragover, ondragleave,
ondragenter, ondragend, ondrag, ondblclick, oncuechange,
oncontextmenu, onclose, onclick, onchange, oncanplaythrough,
oncanplay, oncancel, onblur, onabort, spellcheck, isContentEditable,
contentEditable, outerText, innerText, accessKey, hidden,
webkitdropzone, draggable, tabIndex, dir, translate, lang, title,
childElementCount, lastElementChild, firstElementChild, children,
nextElementSibling, previousElementSibling, onwheel,
onwebkitfullscreenerror, onwebkitfullscreenchange, onselectstart,
onsearch, onpaste, oncut, oncopy, onbeforepaste, onbeforecut,
onbeforecopy, webkitShadowRoot, dataset, classList, className,
outerHTML, innerHTML, scrollHeight, scrollWidth, scrollTop,
scrollLeft, clientHeight, clientWidth, clientTop, clientLeft,
offsetParent, offsetHeight, offsetWidth, offsetTop, offsetLeft,
localName, prefix, namespaceURI, id, style, attributes, tagName,
parentElement, textContent, baseURI, ownerDocument, nextSibling,
previousSibling, lastChild, firstChild, childNodes, parentNode,
nodeType, nodeValue, nodeName

來看看空的 div 元素有多少屬性要實現,這還只是第一層的自有屬性,沒包括原型鏈繼承而來

的。如果觸發了頁面事件,就就會導致頁面重排。相對於 DOM 物件,原生的 JavaScript 處理

起來才會更快且更簡單。

 

DOM 樹上的結構、屬性資訊我們都可以很容易地用 JavaScript 物件表示出來。

var olE = {
  tagName: 'ol', // 標籤名
  props: { // 屬性用物件儲存鍵值對
    id: 'ol-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 寫法是:

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

DOM 我們都可以用 JavaScript 物件來表示。那反過來,就可以用 JavaScript 物件表示的樹結

構來構建一個真正的 DOM 。當狀態變更時,重新渲染這個 JavaScript 的物件結構,實現檢視

的變更,結構根據變更的地方重新渲染。

 

這就是所謂的 Virtual DOM 演算法:

用 JavaScript 物件結構表示 DOM 樹的結構;然後用這個樹構建一個真正的 DOM 樹,插到文

檔當中當狀態變更時,重新構造一棵新的物件樹。然後用新的樹和舊的樹進行比較兩個數的差異。

然後把差異更新到久的樹上,整個檢視就更新了Virtual DOM 本質就是在 JS 和 DOM 之間做

了一個快取。既然已經知道 DOM 慢,就在 JS 和 DOM 之間加個快取。JS 先操作 Virtual DOM

對比排序/變更,最後再把整個變更寫入真實 DOM。

 

4 Virtual DOM實現

用 JavaScript 表示一個 DOM 節點非(wo)常(cui)的(niu)簡(bi)單(ne),只需要記

錄它的節點型別、屬性,還有子節點。

export default Ele = (tagName, props, children) => {
  this.tagName = tagName
  this.props = props
  this.children = children
}
<ol id='ol-list'>
  <li class='item'>Item 1</li>
  <li class='item'>Item 2</li>
  <li class='item'>Item 3</li>
</ol>

例如上面的 DOM 結構就可以簡單的表示:

import * as el from 'Ele';
var ol = el('ol', {id: 'ol-list'}, [
  el('li', {class: 'item'}, ['Item 1']),
  el('li', {class: 'item'}, ['Item 2']),
  el('li', {class: 'item'}, ['Item 3'])
]);

現在 ol 只是一個 JavaScript 物件表示的 DOM 結構,但頁面上並沒有這個結構。我們可以根據這

個 ol 構建來生成真正的 ol。新增一個 render 方法,根據 tagName 構建一個真正的 DOM,然

後生成 DOM 屬性、連線子結構等等。

Ele.prototype.render = function () {
  var e = document.createElement(this.tagName); // 建立元素
  var props = this.props;
 
  for (var propName in props) { // 設定 DOM 屬性
    var propValue = props[propName];
    e.setAttribute(propName, propValue);
  }
 
  var children = this.children || [];
 
  children.forEach(function (child) {
    var childE = (child instanceof Element)
      ? child.render() // 子節點也是虛擬 DOM,遞迴構建
      : document.createTextNode(child); // 字串,構建文字節點
    e.appendChild(childE);
  });
 
  return e;
}

最後只需要 render。

var olE = Ele.render()
document.body.appendChild(olE);

上面的 olE 是真正的 DOM 節點,把它 append 到 body 中,這樣就有了真正的 ol DOM 元素。

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

 

5 Virtual DOM 樹的差異介紹(Diff演算法)

比較兩個 DOM 樹的差異是 Virtual DOM 演算法最核心的部分,這也是所謂的 Virtual DOM 的

diff 演算法。在前端當中,很少會跨越層級地移動 DOM 元素。所以 Virtual DOM 只會對同一個

層級的元素進行對比,下面的 div 只會和同一層級的 div 對比,第二層級的只會跟第二層級對

比。採用的是深度優先遍歷,來記錄差異,這樣每個節點都會有一個唯一的標記。

 

差異是指的是什麼呢?DOM 替換掉原來的節點,如把上面的 div 換成了 section 進行移動、刪

除、新增子節點,例如上面 div 的子節點,把 p 和 span 順序互換修改了節點的屬性。對於文字

節點,文字內容可能會改變。

 

如果我把左側的 p、span、div 反過來變成 div、p、span 怎麼辦?按照差異正常會被替換掉,

但這樣 DOM開銷就會異常的大了。而 React 幫我們做到不需要替換節點,而只需要經過節點移

動就可以達到。至於怎麼變動,會牽扯到太多的對比演算法不一一介紹,有興趣的瞭解下列表對比

演算法,詳細見5參考連結 

 

6 結語

雖然只是非常粗糙的實踐,但我相信 Virtual DOM 的原理是講述通了。實際還需要處理事件監聽、

狀態監控。生成虛擬 DOM 時也可以加入 JSX 語法。當然這些事情都做了的話,就可以構造一個簡

單的ReactJS了。

 

7 參考連結

列表演算法(Edit distance):https://en.wikipedia.org/wiki/Edit_distance

列表演算法(Levenshtein distance):https://en.wikipedia.org/wiki/Levenshtein_distance

參考文章(圖):https://segmentfault.com/a/1190000000753400

參考文章(效能原理):http://www.williambrownstreet.net/blog/2014/04/faster-angularjs-rendering-angularjs-and-reactjs/

參考文章(瀏覽器工作流):http://www.jianshu.com/p/f75c1f0af3f0

參考文章(Webkit相關):https://webkit.org/blog/

參考程式碼(Vtree):https://github.com/Matt-Esch/virtual-dom/blob/master/vtree/diff.js

相關文章