React的貢獻在于它提出的組件化、虛擬DOM、幫助整個前端進入工程化、將函數式編程的思想帶給前端。
這里記錄一下學習過程時關于虛擬DOM算法的實現。
一、Virtual DOM
在React中,render 執行的結果得到的并不是真正的 DOM 節點,結果僅僅是輕量級的 JavaScript 對象,我們稱之為virtual DOM。
真正的 DOM 元素屬性非常多,每次操作很有可能引起回流(Reflow)和重繪(Repaint)。
相對于真正的 DOM 對象,原生的 JavaScript 對象處理起來更快,而且更簡單。DOM 樹上的結構、屬性信息我們都可以很容易地用 JavaScript 對象表示出來:
var element = {
tagName: 'ul', // 節點標簽名
props: { // DOM的屬性,用一個對象存儲鍵值對
id: 'list'
},
children: [ // 該節點的子節點
{tagName: 'li', props: {class: 'item'}, children: ["Item 1"]}
]
}
對應的HTML:
<ul id='list'>
<li class='item'>Item 1</li>
</ul>
所以可以用 JavaScript 對象表示的樹結構來構建一棵真正的DOM樹。用 JavaScript 對象表示 DOM 信息和結構,當狀態變更的時候,重新渲染這個 JavaScript 的對象結構。然后新渲染的對象樹去和舊的樹進行對比,記錄這兩棵樹差異。記錄下來的不同就是我們需要對頁面真正的 DOM 操作,然后把它們應用在真正的 DOM 樹上,頁面就變更了。
Virtual DOM 算法包括幾個步驟:
- 用 JavaScript 對象結構表示 DOM 樹的結構,用這個樹構建一個真正的 DOM 樹,插到文檔當中。
- 當狀態變更的時候,重新構造一棵新的對象樹。將新的樹和舊的樹進行比較,記錄兩棵樹差異。
- 將差異應用到步驟1所構建的真正的DOM樹上,視圖就更新了。
Virtual DOM 本質上就是在 JS 和 DOM 之間做了一個緩存。因為DOM 很慢,JS只操作Virtual DOM,最后的時候再把變更渲染到DOM。
二、算法實現
2.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'])
])
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 ul = ul.render()
document.body.appendChild(ulRoot)
上面的ul是真正的DOM節點,把它塞入文檔中,這樣body里面就有了真正的<ul>
的DOM結構:
<ul id='list'>
<li class='item'>Item 1</li>
</ul>
2.2. 比較兩棵DOM樹的差異
傳統 diff 算法的復雜度為 O(n3),React 通過制定大膽的策略:
- Web UI 中 DOM 節點跨層級的移動操作特別少,可以忽略不計。
- 兩個相同組件產生類似的DOM結構,不同的組件產生不同的DOM結構。
- 對于同一層次的一組子節點,它們可以通過唯一的id進行區分。
將 O(n3) 復雜度的問題轉換成 O(n) 復雜度的問題。
通過diff策略,React 分別對 tree diff、component diff 以及 element diff 進行算法優化。
-
tree diff
基于策略一,React 對樹的算法進行了簡潔明了的優化,即對樹進行分層比較,兩棵樹只會對同一層次的節點進行比較。當發現節點已經不存在,則該節點及其子節點會被完全刪除掉,不會用于進一步的比較。這樣只需要對樹進行一次遍歷,便能完成整個 DOM 樹的比較。 - component diff
- 如果是同一類型的組件,按照原策略繼續比較 virtual DOM tree。
- 如果不是,則將該組件判斷為 dirty component,從而替換整個組件下的所有子節點。
- 對于同一類型的組件,有可能其 Virtual DOM 沒有任何變化,如果能夠確切的知道這點那可以節省大量的 diff 運算時間,因此 React 允許用戶通過 shouldComponentUpdate() 來判斷該組件是否需要進行 diff。
-
element diff
當節點處于同一層級時,React diff 提供了三種節點操作,分別為:INSERT_MARKUP(插入)、MOVE_EXISTING(移動)和 REMOVE_NODE(刪除)。
- INSERT_MARKUP:新的 component 類型不在老集合里, 即是全新的節點,需要對新節點執行插入操作。
- MOVE_EXISTING:在老集合有新 component 類型,且 element 是可更新的類型,generateComponentChildren 已調用 receiveComponent,這種情況下 prevChild=nextChild,就需要做移動操作,可以復用以前的 DOM 節點。
- REMOVE_NODE:老 component 類型,在新集合里也有,但對應的 element 不同則不能直接復用和更新,需要執行刪除操作,或者老 component 不在新集合里的,也需要執行刪除操作。
React對其進行了優化,當節點相同時,只是由于位置發生變化。允許開發者對同一層級的同組子節點,添加唯一 key 進行區分。
新老集合進行 diff 差異化對比,通過 key 發現新老集合中的節點都是相同的節點,因此無需進行節點刪除和創建,只需要將老集合中節點的位置進行移動,更新為新集合中節點的位置。
2.3把差異應用到真正的DOM樹上