如何理解虛擬DOM?
React的虛擬的DOM的原理是什麼?是怎麼實現的?誰能舉個簡單例子來更好的理解虛擬DOM是什麼
寫一個就知道了。剛好寫了一篇博客剛好可以回答這個問題,copy過來:深度剖析:如何實現一個 Virtual DOM 演算法 · Issue #13 · livoras/blog · GitHub
目錄:
- 1 前言
- 2 對前端應用狀態管理思考
- 3 Virtual DOM 演算法
- 4 演算法實現
- 4.1 步驟一:用JS對象模擬DOM樹
- 4.2 步驟二:比較兩棵虛擬DOM樹的差異
- 4.3 步驟三:把差異應用到真正的DOM樹上
- 5 結語
- 6 References
本文會在教你怎麼用 300~400 行代碼實現一個基本的 Virtual DOM 演算法,並且嘗試盡量把 Virtual DOM 的演算法思路闡述清楚。希望在閱讀本文後,能讓你深入理解 Virtual DOM 演算法,給你現有前端的編程提供一些新的思考。
本文所實現的完整代碼存放在 Github。
2 對前端應用狀態管理的思考假如現在你需要寫一個像下面一樣的表格的應用程序,這個表格可以根據不同的欄位進行升序或者降序的展示。
這個應用程序看起來很簡單,你可以想出好幾種不同的方式來寫。最容易想到的可能是,在你的 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,性價比太低;而且這樣做的話,input和textarea的會失去原有的焦點。最後的結論會是:對於局部的小視圖的更新,沒有問題(Backbone就是這麼乾的);但是對於大型視圖,如全局應用狀態變更的時候,需要更新頁面較多局部視圖的時候,這樣的做法不可取。
但是這裡要明白和記住這種做法,因為後面你會發現,其實 Virtual DOM 就是這麼做的,只是加了一些特別的步驟來避免了整棵 DOM 樹變更。
另外一點需要注意的就是,上面提供的幾種方法,其實都在解決同一個問題:維護狀態,更新視圖。在一般的應用當中,如果能夠很好方案來應對這個問題,那麼就幾乎降低了大部分複雜性。
3 Virtual DOM演算法
DOM是很慢的。如果我們把一個簡單的div元素的屬性都列印出來,你會看到:
而這僅僅是第一層。真正的 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寫法是:
&
-
&
- Item 1& &
- Item 2& &
- Item 3& &
既然原來 DOM 樹的信息都可以用 JavaScript 對象來表示,反過來,你就可以根據這個用 JavaScript 對象表示的樹結構來構建一棵真正的DOM樹。
之前的章節所說的,狀態變更-&>重新渲染整個視圖的方式可以稍微修改一下:用 JavaScript 對象表示 DOM 信息和結構,當狀態變更的時候,重新渲染這個 JavaScript 的對象結構。當然這樣做其實沒什麼卵用,因為真正的頁面其實沒有改變。
但是可以用新渲染的對象樹去和舊的樹進行對比,記錄這兩棵樹差異。記錄下來的不同就是我們需要對頁面真正的 DOM 操作,然後把它們應用在真正的 DOM 樹上,頁面就變更了。這樣就可以做到:視圖的結構確實是整個全新渲染了,但是最後操作DOM的時候確實只變更有不同的地方。
這就是所謂的 Virtual DOM 演算法。包括幾個步驟:
- 用 JavaScript 對象結構表示 DOM 樹的結構;然後用這個樹構建一個真正的 DOM 樹,插到文檔當中
- 當狀態變更的時候,重新構造一棵新的對象樹。然後用新的樹和舊的樹進行比較,記錄兩棵樹差異
- 把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構建真正的&
- :
- Item 1& &
- Item 2& &
- Item 3& &
- 替換掉原來的節點,例如把上面的div換成了section
- 移動、刪除、新增子節點,例如上面div的子節點,把p和ul順序互換
- 修改了節點的屬性
- 對於文本節點,文本內容可能會改變。例如修改上面的文本節點2內容為Virtual DOM 2。
- 0& &
- 1& &
- 2& &
- 3& &
- 4& &
- 0& &
- 1& &
- 2& &
- 3& &
- 6& &
- 7& &
- 8& &
- 9& &
- 10& &
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裡面就有了真正的&
- 的DOM結構:
&
-
&
完整代碼可見 element.js。
4.2 步驟二:比較兩棵虛擬DOM樹的差異正如你所預料的,比較兩棵DOM樹的差異是 Virtual DOM 演算法最核心的部分,這也是所謂的 Virtual DOM 的 diff 演算法。兩個樹的完全的 diff 演算法是一個時間複雜度為 O(n^3) 的問題。但是在前端當中,你很少會跨越層級地移動DOM元素。所以 Virtual DOM 只會對同一個層級的元素進行對比:
上面的div只會和同一層級的div對比,第二層級的只會跟第二層級對比。這樣演算法複雜度就可以達到 O(n)。
4.2.1 深度優先遍歷,記錄差異在實際的代碼中,會對新舊兩棵樹進行一個深度優先的遍歷,這樣每個節點都會有一個唯一的標記:
在深度優先遍歷的時候,每遍歷到一個節點就把該節點和新的的樹進行對比。如果有差異的話就記錄到一個對象裡面。
// 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}, ...] // 用數組存儲新舊節點的不同
同理p是patches[1],ul是patches[3],類推。
4.2.2 差異類型上面說的節點的差異指的是什麼呢?對 DOM 操作可能會:
所以我們定義了幾種差異類型:
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新增了屬性id為container,就記錄下:
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。這個該怎麼對比?如果按照同層級進行順序對比的話,它們都會被替換掉。如p和div的tagName不同,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 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 演算法主要是實現上面步驟的三個函數:element,diff,patch。然後就可以實際的進行使用:
// 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 Referencesvirtual-dom/diff.js at master · Matt-Esch/virtual-dom · GitHub
比如說,你有一個數組,被展示成了一個列表,之前有5個數組項:
[0, 1, 2, 3, 4]
展示成了:
&
&
現在,給數組新增了10個元素,變成了從0-14這樣的狀況,思考一下如果要創建li節點,是怎麼處理?
最圡的方式,是不是這樣?
for (var i=5; i&<15; i++) {
var li = document.createElement("li");
li.innerHTML = arr[i];
ul.appendChild(li);
}
這裡其實就有問題,因為性能是不好的,批量操作應當被合併。
東風吹,戰鼓擂,廠里來了新同事,車間主任召集大家開個會:今天我們車間新來了關羽同志,來自河東解良,大家歡迎,然後就是各種套話,一不小心15分鐘過去了,大家都聚在一起,抓了革命沒促生產。
明天又來了張飛同志,後天來了趙雲,然後馬超黃忠魏延,每來一個人,有很多事情都得來這麼一遍,如果這群人是同一天來,一次也就30分鐘就都介紹完了,每天來一個,總的就花了一個多小時,所以,從總的時間成本來說,批量介面都是比多次調用單個介面的總和要節省的,因為可以有一些內部優化。
所以,我們在剛才的例子里,一般會用一個合併好的html字元串,或者DocumentFragment先做好批量的創建合併,再一次追加到DOM樹去。
這當然是手動的情形了,考慮在一個抽象度較高的框架/庫中,需要提供一些對細節的屏蔽,讓使用者不要自己去考慮這些東西。
比如我們剛才創建li,如果給你搞成這樣:
var li = new Li();
li.html(i);
ul.append(li);
寫的時候讓你不考慮細節,由框架/庫自己處理內部的批量合併,那用起來就會方便很多。怎麼做到這樣呢?
我們讓你new的這個Li,你以為我在創建li元素,其實不一定啊,說不定我的Li實現是這樣:function Li() {}
Li.prototype = {
html: function(str) {}
}
然後,ul.append(li),其實ul也是個類似的東西,到最後,你認為就創建DOM啦?其實都是JavaScript對象實例,我們知道,純JS的東西比DOM操作快不知多少倍了,所以這裡隨便你怎麼折騰,我就是只給你操作數據,讓你覺得在操作DOM,就達到虛擬的目的了。在最後,由框架/庫幫你一次把整個DOM的最終狀態映射出來,中間過程全部捨棄,性能就優化得比較好了。
正因為如此,能夠實現服務端與瀏覽器端的同構,比如說有一段JS用於根據某種數據生成DOM,我可以選擇把這段JS放在瀏覽器中執行,也可以放在服務端執行,反正在中間過程操作的都是純數據,最終才批量生成HTML結構。雖然Virtual DOM確實是性能杠杠的,但是其實可以說它是無心插柳的一個結果。
React的核心思想:
一個Component拯救世界,忘掉煩惱,從此不再操心界面。1. Virtual Dom快,有兩個前提
1.1 Javascript很快Chrome剛出來的時候,在Chrome里跑Javascript非常快,給了其它瀏覽器很大壓力。而現在經過幾輪你追我趕,各主流瀏覽器的Javascript執行速度都很快了。Julia有一個Benchmark,Julia Benchmarks, 可以看到Javascript跟C語言很接近了,也就幾倍的差距,跟Java基本也是一個量級。所以說,單純的Javascript其實速度是很快的。多說一句,這種benchmark並不是絕對的依據,因為用這個語言寫這個跑得快,並不代表一定是用這個語言寫那個也跑得快。1.2 DOM很慢
關於什麼CSS,什麼layout那些我不懂,就不瞎說了,咱就說說DOM的結構。當你用document.createElement()創建一個空的Element的時候(比如創建一個空的div),有以下這幾頁的東西需要實現(當然,這不是標準,只是個大概的意思):HTMLElement - Web API InterfacesElement - Web API InterfacesGlobalEventHandlers非常非常多,並且還有不少嵌套引用。你可以在Chrome console裏手動調用document.createElement 然後插入DOM里看看效果。這還是一個空的Elemnt,啥內容也沒有,就這麼複雜。所以說DOM的操作非常慢是可以理解的。不是瀏覽器不想好好實現DOM,而是DOM設計得太複雜,沒辦法。而更糟糕的是,我們(以及很多框架)在調用DOM的API的時候做得不好,導致整個過程更加的慢。React的Virtual Dom解決的是這一部分問題,它並不能解決DOM本身慢的問題。
比如說,現在你的list是這樣,&- &
- &
當然還有其它一些例子能夠優化我們對DOM的操作,就不舉例子了。(實際上是因為我舉不出例子。。。)
2. 關於React
2.1 介面和設計在React的設計里,是完全不需要你操作DOM的。在React里其實根本就沒有DOM這個概念的存在,只有Component。當你寫好一個Component以後,Component會完全負責UI,你不需要也不應該去也不能夠指揮Component怎麼顯示,你只能告訴它你想要顯示一個香蕉還是兩個梨。隔離DOM並不是因為DOM慢(當然DOM確實慢),而是把界面和業務完全隔離,操作數據的只關心數據,操作界面的只關心界面。可以想像成把MVC裡面的Controller分成兩個部分,一部分合併到M裡面去,一部分合併到V裡面去,就剩下MV,沒有C了。。。其實M也並不是Model了。推薦看一下Pete Hunt的這個Talk https://youtu.be/DgVS-zXgMTk重複一遍,React的意思是,我提供一個Component,然後你只管給我數據,界面的事情完全不用你操心,我保證會把界面變成你想要的樣子。
你可以把一個React的Component想像成一個Pure Function,只要你給的數據是[1, 2, 3],我保證顯示的是[1, 2, 3]。沒有什麼刪除一個Element,添加一個Element這樣的事情。NO。你要我顯示什麼就給我一個完整的列表。說到這裡,插一句別的,我一開始看到這裡還以為這樣的處理方式比較適合一般的WEB應用,寫遊戲啊什麼的可能這個模式不太好用,然後我就看到Pete Hunt那個Talk,說DOOM 3就是這麼乾的。
。。。。。。眼淚都下來了,大神們的思路果然我是摸不著邊的,洗洗睡吧。睡醒了接著說。React其實需要從Imperative Programming轉換到Declarative Programming去理解。你不要一步一步告訴我這件事情怎麼做,什麼先和面再剁餡,NO,告訴我你想要煎餅還是月餅,我會想辦法去做的,不要來干擾我。你只需要告訴我有這麼一個列表[1, 3, 6]需要顯示就行了,不要告訴我怎麼顯示,我會想辦法的,我保證美得冒泡,各種神奇的效果,亮瞎你的鈦合金狗眼。
行了行了,你真啰嗦。
。。。
再說幾句瞎扯的話,Flux雖然說的是單向的Data Flow,但是實際上就是單向的Observer。
Store-&>View-&>Action-&>Store(箭頭是數據流向,實現上可以理解為View監聽Store,View直接trigger action,然後Store監聽Action)等等,不是說Component是pure function不跟誰綁定嗎,為啥View要監聽Store?你這個騙子。怪不得都沒有人給你點贊。。。。。。。我們還是繼續說React把,Flux是什麼鬼,我反正沒聽過。2.2 實現
OK,那麼,如何實現React呢?其實對於React來說,最容易實現的辦法是每次完全摧毀整個DOM,然後重新建立一個全新的DOM。因為一個Component是一個Pure function,根本就沒有State這個概念,我又不知道DOM現在是什麼樣子,那最簡單的辦法當然是只要你給新數據,我就把整個DOM刪了,然後根據你給的數據重新生成一個DOM咯。等等,Virtual DOM哪兒去了?
事實是這樣的,最簡單實現React的方式雖然說非常簡單,但是效率實在是太低了,你居然要全部都刪了重建DOM,DOM本身已經很慢了,你還這麼去用,誰能忍啊?
然後Virtual DOM就來救場了。
Virtual DOM和DOM是啥關係呢?
首先,Virtual DOM並沒有完全實現DOM,Virtual DOM最主要的還是保留了Element之間的層次關係和一些基本屬性。因為DOM實在是太複雜,一個空的Element都複雜得能讓你崩潰,並且幾乎所有內容我根本不關心好嗎。所以Virtual DOM里每一個Element實際上只有幾個屬性,並且沒有那麼多亂七八糟的引用。所以哪怕是直接把Virtual DOM刪了,根據新傳進來的數據重新創建一個新的Virtual DOM出來都非常非常非常快。(每一個component的render函數就是在做這個事情,給新的virtual dom提供input)所以,引入了Virtual DOM之後,React是這麼乾的:
你給我一個數據,我根據這個數據生成一個全新的Virtual DOM,然後跟我上一次生成的Virtual DOM去 diff,得到一個Patch,然後把這個Patch打到瀏覽器的DOM上去。完事。有點像版本控制打patch的思路。
假設在任意時候有,VirtualDom1 == DOM1 (組織結構相同)當有新數據來的時候,我生成VirtualDom2,然後去和VirtualDom1做diff,得到一個Patch。然後將這個Patch去應用到DOM1上,得到DOM2。如果一切正常,那麼有VirtualDom2 == DOM2。這裡你可以做一些小實驗,去破壞VirtualDom1 == DOM1這個假設(手動在DOM里刪除一些Element,這時候VirtualDom里的Element沒有被刪除,所以兩邊不一樣了)。
然後給新的數據,你會發現生成的界面就不是你想要的那個界面了。最後,回到為什麼Virtual Dom快這個問題上。
其實是由於每次生成virtual dom很快,diff生成patch也比較快,而在對DOM進行patch的時候,我能夠根據Patch的內容,優化一部分DOM操作,比如之前1.2里的那個例子。重點就在最後,哪怕是我生成了virtual dom,哪怕是我跑了diff,但是我根據patch簡化了那些DOM操作省下來的時間依然很可觀。所以總體上來說,還是比較快。簡單發散一下思路,如果哪一天,DOM本身的已經操作非常非常非常快了,並且我們手動對於DOM的操作都是精心設計優化過後的,那麼加上了VirtualDom還會快嗎?
當然不行了,畢竟你多做了這麼多額外的工作。但是那一天會來到嗎?誒,大不了到時候不用Virtual DOM。這是普通的Html標籤寫法
&
&React&
//javascript dom
var a = document.createElement("a")
a.setAttribute("class", "link")
a.setAttribute("href", "https://github.com/facebook/react")
a.appendChild(document.createTextNode("React"))
var a = React.createElement("a", {
className: "link",
href: "https://github.com/facebook/react"
}, "React")
所有html結構,都可以用js dom來構造,而且能將構造的步驟封裝起來,做到「數據-dom結構」的映射。
緩存初始數據,新數據進來時,與舊數據對比,找到差異,根據差異本身的性質進行dom操作;無差異,則不作為。
dom本身在js中就是一種數據結構,console.dir(document.body),在控制台可以看到body的數據結構。然而,dom相關的數據豐富而且複雜,我們其實只關心少數元素的少數屬性。
建立一個javascript plain object,非常輕量,用它保存我們真正關心的與dom相關的少數數據;對它進行操作,然後對比操作前後的差異,再根據映射關係去操作真正的dom,無疑能提高性能。
這就是虛擬DOM的理念。
==========
說虛擬DOM的理念,沒人贊。只好 show code 了,說虛擬DOM的實踐。
React.createElement 的方法名,看似創建了一個Element,實質只是一個輕量級的數據結構,其最簡形式如下:
var a = {
type: "a",
props: {
children: "React",
className: "link",
href: "facebook/react · GitHub"
},
_isReactElement: true
}
React.render(a, document.body)
在線DEMO(進去點 run with JS): JS Bin - Collaborative JavaScript Debugging
如上,React.render(ReactElement, DOM) 中所謂的 ReactElement,是指私有屬性_isReactElement 為 true 的一種數據結構,而非真正的Element。
React.createClass 所謂的組件,實質是這樣的:
var type = function (props, context) {
this.props = props
}
type.prototype.render = function() {
return {
type: "a",
props: this.props,
_isReactElement: true
}
}
var a = {
type: type,
props: {
children: "React",
className: "link",
href: "facebook/react · GitHub"
},
_isReactElement: true
}
React.render(a, document.body)
在線DEMO(進去點 run with JS):JS Bin - Collaborative JavaScript Debugging
當對象的type屬性是字元串時,表示為普通html標籤,當type為函數時,它被當做構造函數,props屬性被傳入這個構造函數,生成實例,再調用實例的render方法,render方法返回的數據結構才用以渲染。
虛擬DOM只是個名字。@徐飛 兄講述了虛擬DOM的概念,那麼我來結合React說一下在React中虛擬DOM究竟做了什麼。瀏覽器上呈現的html文檔,本質上來說是一種xml,那麼我們可以用一種樹狀結構把這個html文檔描述出來:
html
+----head
+ +----...
+
+----body
+----div
+ +----...
+
+----div
+----...
React在呈現的過程中,會首先根據render的結果將這個樹狀結構在js里創建出來(注意,這個時候並沒有操作DOM),這個樹狀結構就是虛擬DOM層。用樓上的例子,根據一個array[1, 2, 3, 4, 5]去渲染一個列表,那麼虛擬DOM應該類似以下結構:
ul
+----li(1)
+----li(2)
+----li(3)
+----li(4)
+----li(5)
這時再根據這個虛擬DOM渲染成實際DOM。然後重點來了,如果array內容發生變化了怎麼辦,比如我們刪除了3這個元素,重新render,虛擬DOM會發生相應變化:
ul
+----li(1)
+----li(2)
+----li(4)
+----li(5)
React會將這個新的虛擬DOM和正在呈現的虛擬DOM進行對比,並找出其中的差異,然後用最少的DOM操作完成這個更新。(參考React』s diff algorithm)
這裡需要注意到,以上這些操作都是在js里完成的(生成虛擬DOM,比對),並沒有實際操作DOM元素,比對完畢後找出的差異才會實際操作DOM元素,比如移除掉一個節點,更新其他節點的屬性。對比angularjs,沒有比對的這個過程,直接移除掉所有模板元素的DOM,再重新添加。我們曾經結合angularjs和d3js,模板中有一個佔位符用d3js來繪製圖表,結果數據變化時,d3js繪製的圖表被移除了,替換回了佔位符,不得不重新繪製圖表,而不是更新圖表數據,結果就是圖表會有一瞬間的空白狀態。要知道為什麼要用Virtual DOM,就要知道Virtual DOM到底好在哪
我來說一個最簡單的例子吧
假設這裡有三個套娃
儘管我們不知道套娃裡面有n層,但是我們可以確定的是,n肯定是一個確切的數字,n就代表了裡面的第n層的套娃
常規上,如果我們要把灰套娃中的第三層(g3)和紫(p3)套娃中的第三層做一個互換,會經過一下手續:
打開灰色套娃*2
取出g3
打開紫色套娃*2
取出p3
p3+灰色套娃裝配
g3+紫色套娃裝配
那如果要交換第n個
只需
打開灰色套娃*n-1
取出gn
打開紫色套娃*n-1
取出pn
pn+灰色套娃裝配
gn+紫色套娃裝配
看著很簡單啊,假若套娃代表了DOM NODE,這些操作在代碼層面上還可以更加高效
通過對DOM樹的逐層解析(打開套娃),獲取對應節點(取出),然後經過appendTo(裝配),事情就完成了
但是由於我們的web頁面是堆疊式的布局,DOM之間的位置會相互影響(這種情景前提是撇除絕對定位之類的脫離DOM流的那些節點),位置影響就是會重繪,重繪過多過重會有什麼效果,很簡單,你把它認為會卡就好了
由於DOM的這種會互相影響的性質,所以我需要變換一下套娃s的擺放了
如圖
如果我依然要交換g3和p3,然後我還不能讓PDD動太多太廣(減少重繪次數與規模)
Virtual DOM是怎麼做的呢
首先,我們還是要MAP這些DOM節點。但是我們不能每次都MAP一遍,我們把MAP到的DOM樹存在js里
然後js運行畢竟比逐步DOM操作要記者不少,所以我們先用js把DOM關係重新梳理了
g.g3 swap p.p3
然後就是最一顆賽艇的分步了
我們把先前的套娃全部摧毀,這時pdd理應就是掉下來了
然後我們直接在原本套娃的位置按照js裡面的DOM數據生成新的套娃
如果不深究下去的話,這樣就是Virtual DOM的基本方案了
通過js對DOM的數據做處理,而不是直接操作DOM,換句話說,我們操作的是Virtual DOM(數據),不是DOM。
這樣就夠了嗎?
當然不是了,畢竟你看,PDD在這個過程中還是移動了3個套娃的距離
如果這裡有n個套娃,那PDD就要掉落n個套娃的距離了
所以我們還有優化的空間
如果我們將舊的DOM結構和新的DOM結構比一比(Diff),只改變有變化的地方,這時PDD就不會掉啦
那麼這時,我們只需改變套娃裡面的第N層就好了
這樣最外面的套娃就不會動了,然後就可以PDD就不會掉下來了
之後,一切就順理成章的解決了。
從來不相信facebook所謂vdom很牛逼不負責任的言論,沒有研究過webkit渲染原理就不要說什麼直接操作DOM慢,難道diff就不耗性能?深度優先顯然是很爛的。有本事打開Paint Flash和FPS meter來比較下?
簡單來說,就是用一個輕量級的dom結構(用js模擬實現),來模擬重量級的dom結構,進而通過輕量級結構的操作來減少重量級結構的操作,最終達到性能優化的目的。
內部實現大致如下:
- 對於CompositeComponent,執行render方法,得到renderedElement,這樣就構成了父元素與子元素的層級結構
- 對於DOMComponent,通過props.children,形成父元素與資源的層級結構
- 有父子之間的關係後,通過遞歸渲染的方式,就得到了一個輕量級的虛擬DOM
樓上的都回答挺好的,自己回答這個問題,算是自己最近的一個總結吧。
從用Egret寫HTML5遊戲,轉到純web開發。今年5月份開始ReactJS ,差不多正好5個月的時間,中間邊學邊寫公司項目。
最近出去找工作,被問到ReactJS實現原理,自己是各種懵逼的。自己只能回來惡補知識了。各種搜索,找到了一些文章。https://medium.com/@deathmood/how-to-write-your-own-virtual-dom-ee74acc13060#.16vb88o6p
中文翻譯 如何實現 Virtual DOM // 九月煙樹
其中也找到目前排名第一作者的博客,然後細細的拜讀了一下。發現最終的重點回到了演算法上面,也就是Virtual DOM 中的diff 演算法,然後就是DOM 的基本操作了。
想起大家經常說的,基礎才是重點。現在經歷過了,才知道其中的緣由。才有點理解一些公司為什麼面試主要就考些演算法了。
現在惡補基礎知識和演算法,過段時間再去找工作。有事沒事多刷刷演算法題https://leetcode.com/problemset/algorithms/我錄了一個視頻,就是講如何理解這個 虛擬dom的。
點擊查看:誘人的 react 視頻教程 - 基礎篇 #9 react 如何更新 dom
50行代碼實現Virtual DOM
大神太多了,你在vm中批量,可是在webkit渲染時也是批量嗎,同時給你蹭出來10個標籤?c++它還是要給循環遍及,遞歸生成標籤對象,然後添加dom中。這種的高效是在於實現了一個真正狀態機,擺脫了dom操作,偉大在於此。
樓主,我想問下, 你的diff.js 所對應的的 var listDiff = require("list-diff2")這個文件在git上怎麼沒有啊。可以分享一下嗎。
你最好不要把他看成virtual dom,其實就是內存里的一大堆原始的javascript對象,和dom一樣,維持著一個樹形的結構,這顆樹和dom沒半點毛關係,你操作這些原始的對象,virtual dom的代碼根據一些對應來操作dom。
virtual dom的作者認為大多數人都是傻x,他操作dom效率比較高,所以搞的這項目。嘻嘻,其實也不是啦,批量操作dom肯定效率高點,不過估計最終瀏覽器會開放一種效率高點的操作dom的介面。推薦閱讀:
※如何用React做一個MVC項目?
※在 componentWillUnmount 中到底應該清除哪些變數?
※store的組織是扁平化好,還是分層級樹狀的好?大型的項目store該怎麼組織?
※新手學習前端開發加了很多技術群有必要天天看群聊天記錄學習嗎?
※截止到2017年7月,手淘內部還在用vue嗎,有替換成react嗎?