目錄:
我會盡量把 Virtual DOM 應用場景、實現思路、算法講述清楚,希望大家閱讀后,能讓你
深入理解 Virtual DOM。
寫一個像下面的應用程序,這個表格可以根據不同的字段進行升序或者降序。
最容易的方案是在你的 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 樹變更。上面提供
的幾種方法,其實都在解決同一個問題,那就是維護狀態更新視圖。如果我們能夠很好來
應對這個問題,就降低復雜性。
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。
用 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>
比較兩個 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參考鏈接。
雖然只是非常粗糙的實踐,但我相信 Virtual DOM 的原理是講述通了。實際還需要處理事件監聽、
狀態監控。生成虛擬 DOM 時也可以加入 JSX 語法。當然這些事情都做了的話,就可以構造一個簡
單的ReactJS了。
列表算法(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