目前工作中用到了React,搭配一起使用了Immutable.js。之前沒有靜下來思考一下為什麼React社群這麼推崇搭配一起使用Immutable。正好想寫篇文章分析一下這個問題。之前我翻譯了React官方文件中Advanced Guides中關於一致化處理(Reconciliation)和效能優化(Optimizing Performance)就涉及到這方面的內容。
React效能優化
一提到React,大家第一時間就想到的虛擬DOM(Virtual DOM)和伴隨其帶來的高效能。但是React提供的是宣告式的API(declarative API),好的一方面是讓我們編寫程式更加方便,但另一方面,卻使得我們不太瞭解內部細節。
一致化處理(Reconciliation)
React採用的是虛擬DOM,每次屬性(props)和狀態(state)發生變化的時候,render
函式返回不同的元素樹,React會檢測當前返回的元素樹和上次渲染的元素樹之前的差異,然後找出何如高效的更新UI。
上圖展示的就是一個元素樹,React比較兩次元素樹差異的時候,首先從根節點開始。如果元素型別不相同時,該節點以下(包括當前節點)的元素樹都會被銷燬,樹的根節點以下的任何元件都會被解除安裝,因此狀態(state)也會丟失。如果元素節點型別是相同的,那就需要區分是DOM元素還是元件。如果是DOM元素,會保持節點相同,僅更新改變的屬性。而如果是元件的話,會保持元件例項不變,僅更新元件例項的屬性,因此元件例項的狀態(state)就會被保留下來。比較完當前節點,然後會遞迴遍歷比較子元素。
一致化處理(Reconciliation)包括的就是React元素的比較以及對應的React元素不同時對DOM的更新,即可理解為React 內部將虛擬 DOM 同步更新到真實 DOM 的過程,包括新舊虛擬 DOM 的比較及計算最小 DOM 操作。我們可以看到促使React效能提升的一個重要點就是避免一致化處理。
shouldComponentUpdate
React使用shouldComponentUpdate
來判別元件是否會因為當前屬性(props)和狀態(state)變化而導致元件輸出變化。預設的shouldComponentUpdate
會在props和state發生變化時返回true
,表示元件會重新渲染,從而呼叫render
函式。當然了在首次渲染的時候和使用forceUpdate
的時候,是不會經過shouldComponentUpdate
判斷。shouldComponentUpdate
作為效能優化的一個非常有用且簡單的方法,非常實用。
舉例
我們以React官網中的圖作為例項:
SCU代表shouldComponentUpdate,紅色SCU表示shouldComponentUpdate返回true
,綠色的SCU表示shouldComponentUpdate返回false
。vDOMEq代表渲染的React元素是否相等。紅色vDOMEq表示React元素不相等,綠色的vDOMEq表示React元素相等。元素節點為紅色表示需要對該節點進行一致化處理,節點顏色為綠色表示不需要對其進行一致化處理。
表示對於兩棵元素樹,React會同步比較。在比較C1節點,因為SCU返回的false,需要對其進行diff,vDOMEq返回的是false,故需要一致化處理,存在DOM元素的更新。迭代遞迴到C2,因為SCU返回的是true,以C2為根節點的整個子樹,都不需要diff判斷。但是C3的SCU返回true,需要進行diff比較。C3的子節點C6因為SCU返回true需要進行diff比較,並且因為vDOMEq返回的false,因此C6不可避免進行DOM的更新。對於C8來講,通過比較渲染元素而不需要進行一致化處理,而C7因為shouldComponentUpdate返回false從而不需要進行diff。
因此我們可以發現,如果能夠合理地編寫shouldComponentUpdate
函式,從而能避免不必要的一致化處理,使得效能可以極大提高。一般shouldComponentUpdate
會比較props
和state
中的屬性是否發生改變(淺比較)來判定是否shouldComponentUpdate
是否需要返回true
從而觸發一致化處理。我們可以通過繼承React.PureComponent
或者通過引入PureRenderMixin模組來達到目的。但是這也存在一個問題:
class ListOfWords extends React.PureComponent {
render() {
return <div>{this.props.words.join(',')}</div>;
}
}
class WordAdder extends React.Component {
constructor(props) {
super(props);
this.state = {
words: ['marklar']
};
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
// This section is bad style and causes a bug
const words = this.state.words;
words.push('marklar');
this.setState({words: words});
}
render() {
return (
<div>
<button onClick={this.handleClick} />
<ListOfWords words={this.state.words} />
</div>
);
}
}複製程式碼
元件展現一個以逗號分隔的單詞列表,在父元件WordAdder
,當你點選一個按鈕時會給列表新增一個單詞,但實際上,上面的程式碼是存在問題的,ListOfWords
繼承的React.PureComponent
,當你每次點選按鈕會給WordAdder
元件中的this.state.words
新增新的單詞。因此ListOfWords
中的shouldComponentUpdate
在判斷this.props.words
和nextProps.words
實際是相等的,因此返回了false
。所以,ListOfWords
是不會被重新渲染的,因為React.PureComponent
中的shouldComponentUpdate
進行的是淺比較(shallow comparison),但是如果真的進行深比較,那麼比較的效能損耗又太大,不禁讓我們得出一個結論:
共享的可變狀態是萬惡之源
這時候Immutable.js橫空出世
Immutable Data
Immutable Data是指一旦建立,就不能被更改的資料。對Immutable物件的修改都會返回新的Immutable物件。並且目前的Immutable庫,都實現了結構共享,即如果物件樹中一個節點發生變化,只修改這個節點和受它影響的父節點,其它節點則進行共享,避免了deepCopy把所有節點都複製一遍帶來的效能損耗。比較兩個Immutable物件是否相同,只需要使用===
就可以輕鬆判別。因此如果React傳入的資料是Immutable Data,那麼React就能高效地比較前後屬性的變化,從而決定shouldComponentUpdate
的返回值。解決了上面存在的問題。
但是引入Immutable Data也不是沒有代價的,畢竟Immutable Data需要引入新的API,並且需要引入新的庫,在原有的專案中引入Immutable Data也是有風險和代價的而且還需要開發者轉變原有的思維(畢竟天下沒有白吃的午餐)。