React 效能優化大挑戰:一次理解 Immutable data 跟 shouldComponentUpdate

發表於2018-01-08
前陣子正在重構公司的專案,試了一些東西之後發現自己對於 React 的渲染機制其實不太瞭解,不太知道 render 什麼時候會被觸發。而後來我發現不只我這樣,其實還有滿多人對這整個機制不太熟悉,因此決定寫這篇來分享自己的心得。其實不知道怎麼優化倒還好,更慘的事情是你自以為在優化,其實卻在拖慢效能,而根本的原因就是對 React 的整個機制還不夠熟。被「優化」過的 component 反而還變慢了!這個就嚴重了。因此,這篇文章會涵蓋到下面幾個主題:
  1. Component 跟 PureComponent 的差異
  2. shouldComponentUpdate 的作用
  3. React 的渲染機制
  4. 為什麼要用 Immutable data structures

為了判別你到底對以上這些理解多少,我們馬上進行幾個小測驗!有些有陷阱,請睜大眼睛看清楚啦!

React 小測驗

第一題

以下程式碼是個很簡單的網頁,就一個按鈕跟一個叫做Content的元件而已,而按鈕按下去之後會改變App這個 component 的 state。

請問:當你按下按鈕之後,console 會輸出什麼?

A. 什麼都沒有(App 跟 Content 的 render function 都沒被執行到)
B. 只有 render App!(只有 App 的 render function 被執行到)
C. render App! 以及 render content!(兩者的 render function 都被執行到)

第二題

以下程式碼也很簡單,分成三個元件:App、Table 跟 Row,由 App 傳遞 list 給 Table,Table 再用 map 把每一個 Row 都渲染出來。

而這段程式碼的問題就在於按下按鈕之後,App的 render function 被觸發,然後Table的 render function 也被觸發,所以重新渲染了一次整個列表。

可是呢,我們點選按鈕之後,list根本沒變,其實是不需要重新渲染的,所以聰明的小明把 Table 從 Component 變成 PureComponent,只要 state 跟 props 沒變就不會重新渲染,變成下面這樣:

把 Table 從 Component 換成 PureComponent 之後,如果我們再做一次同樣的操作,也就是按下change state按鈕改變 App 的 state,這時候會提升效率嗎?

A. 會,在這情況下 PureComponent 會比 Component 有效率
B. 不會,兩者差不多
C. 不會,在這情況下 Component 會比 PureComponent 有效率

第三題

接著讓我來看一個跟上一題很像的例子,只是這次換成按按鈕以後會改變 list:

這時候 Table 的 PureComponent 優化已經沒有用了,因為 list 已經變了,所以會觸發 render function。要繼續優化的話,比較常用的手段是把 Row 變成 PureComponent,這樣就可以確保相同的 Row 不會再次渲染。

請問:把 Row 從 Component 換成 PureComponent 之後,如果我們再做一次同樣的操作,也就是按下change state按鈕改變 list,這時候會提升效率嗎?

A. 會,在這情況下 PureComponent 會比 Component 有效率
B. 不會,兩者差不多
C. 不會,在這情況下 Component 會比 PureComponent 有效率

React 的 render 機制

在公佈答案之前,先幫大家簡單複習一下 React 是如何把你的畫面渲染出來的。

首先,大家都知道你在render這個 function 裡面可以回傳你想渲染的東西,例如說

要注意的是這邊 return 的東西不會直接就放到 DOM 上面去,而是會先經過一層 virtual DOM。其實你可以簡單把這個 virtual DOM 想成 JavaScript 的物件,例如說上面 Content render 出來的結果可能是:

最後一步則是 React 進行 virtual DOM diff,把上次的跟這次的做比較,並且把變動的部分更新到真的 DOM 上面去。

簡單來說呢,就是在 React Component 以及 DOM 之間新增了一層 virtual DOM,先把你要渲染的東西轉成 virtual DOM,再把需要更新的東西 update 到真的 DOM 上面去。

如此一來,就能夠減少觸碰到真的 DOM 的次數並且提升效能。

舉個例子,假設我們實作一個非常簡單的,按一個按鈕之後就會改變 state 的小范例:

在程式剛開始執行時,渲染的順序是這樣的:

  1. 呼叫 App 的 render
  2. 呼叫 Content 的 render
  3. 拿到 virtual DOM
  4. 跟上次的 virtual DOM 做比較
  5. 把改變的地方應用到真的 DOM

這時候的 virtual DOM 整體應該會長得像這樣:

當你按下按鈕,改變 state 了以後,執行順序都跟剛剛一樣:

  1. 呼叫 App 的 render
  2. 呼叫 Content 的 render
  3. 拿到 virtual DOM

這時候拿到的 virtual DOM 應該會長得像這樣:

而 React 的 virtual DOM diff 演演算法,就會發現只有一個地方改變,然後把那邊的文字替換掉,其他部分都不會動到。

其實官方檔案把這一段寫得很好:

When you use React, at a single point in time you can think of the render() function as creating a tree of React elements. On the next state or props update, that render() function will return a different tree of React elements. React then needs to figure out how to efficiently update the UI to match the most recent tree.

大意就是你可以想像成 render function 會回傳一個 React elements 的 tree,然後 React 會把這次的 tree 跟上次的做比較,並且找出如何有效率地把這差異 update 到 UI 上面去。

所以說呢,如果你要成功更新畫面,你必須經過兩個步驟:

  1. render function
  2. virtual DOM diff

因此,要優化效能的話你有兩個方向,那就是:

  1. 不要觸發 render function
  2. 保持 virtual DOM 的一致

我們先從後者開始吧!

提升 React 效能:保持 virtual DOM 的一致

因為有了 virtual DOM 這一層的守護,通常你不必太擔心 React 的效能。

像是我們開頭問答的第一題:

你每次按下按鈕之後,由於 App 的 state 改變了,所以會先觸發 App 的 render function,而因為裡面有回傳<Content />,所以也會觸發 Content 的 render function。

因此你每按一次按鈕,這兩個 component 的 render function 就會個別被呼叫一次。所以答案是C. render App! 以及 render content!(兩者的 render function 都被執行到)

可是儘管如此,真的 DOM 不會有任何變化。因為在 virtual DOM diff 的時候,React 會發現你這次跟上次的 virtual DOM 長得一模一樣(因為沒有東西改變嘛),就不會對 DOM 做任何操作。

如果能儘量維持 virtual DOM 的結構相似的話,可以減少一些不必要的操作,在這點上其實可以做的優化還很多,可以參考官方檔案,裡面寫的很詳細。

提升 React 效能:不要觸發 render function

雖然不必太過擔心,但是 virtual DOM diff 也是需要執行時間的。雖然說速度很快,但再快也比不上完全不呼叫來的快,你說是吧。

對於這種「我們已經明確知道不該有變化」的情形,我們連 render 都不該呼叫,因為沒必要嘛,再怎麼呼叫都是一樣的結果。如果 render 沒有被呼叫的話,連 virtual DOM diff 都不需要執行,又提升了一些效能。

你應該有聽過shouldComponentUpdate這個 function,就是來做這件事的。如果你在這個 function 中回傳 false,就不會重新呼叫 render function。

加上去之後,你會發現無論你按多次按鈕,Content 的 render function 都不會被觸發。

但是這個東西請小心使用,一個不注意你就會碰到 state 跟 UI 搭不上的情形,例如說 state 明明變成 world,可是 UI 顯示的還是 Hello:

在上面的例子中,按下按鈕之後 state 確實變成world,但是因為 Content 的shouldComponentUpdate永遠都回傳 false,所以不會再次觸發 render,就看不到對應的新的 state 的畫面了。

不過這有點極端,因為通常不會永遠都回傳 false,除非你真的確定這個 component 完全不需要 re-render。

比起這個,有一個更合理的判斷基準是:

如果每一個 props 跟 state 都沒有變,那就回傳 false

假設this.props是:

nextProps是:

那在比較的時候就會發現props.text變了,就可以順理成章的呼叫 render function。還有另外一點是這邊用shallowEqual來比較前後的差異,而不是用deepEqual

這是出於效能上的考量。別忘了,你要執行這樣的比較也是會吃資源的,尤其是在你的 object 很深很深的時候,要比較的東西可就多了,因此我們會傾向用shallowEqual,只要比較一層即可。

另外,前面有提到PureComponent這個東西,其實就是 React 提供的另外一種元件,差別就是在於它自動幫你加上上面那一段的比較。如果你想看原始碼的話,在這邊

講到這邊,就可以來公佈第二題的解答了,答案是:A. 會,在這情況下 PureComponent 會比 Component 有效率,因為繼承了 PureComponent 之後,只要 props 跟 state 沒變,就不會執行 render function,也不會執行 virtual DOM diff,節省了許多開銷。

shallowEqual 與 Immutable data structures

你剛開始在學 React 的時候,可能會被告誡說如果要更改資料,不能夠這樣寫:

而是應該要這樣:

但你知道為什麼嗎?

這個就跟我們上面講到的東西有關了。如同上面所述,其實使用PureComponent是一件很正常的事情,因為 state 跟 props 如果沒變的話,本來就不該觸發 render function。

而剛剛也提過PureComponent會幫你shallowEqual state 跟 props,決定要不要呼叫 render function。

在這種情況下,如果你用了一開始講的那種寫法,就會產生問題,例如說:

在上面的程式碼中,其實this.state.objnewObject還是指向同一個物件,指向同一塊記憶體,所以當我們在做shallowEqual的時候,就會判斷出這兩個東西是相等的,就不會執行 render function 了。

在這時候,我們就需要 Immutable data,Immutable 翻成中文就是永遠不變的,意思就是:「當一個資料被建立之後,就永遠不會變了」。那如果我需要更改資料的話怎麼辦呢?你就只能創一個新的。

有了 Immutable 的概念之後,shallowEqual就不會出錯了,因為如果我們有新的資料,就可以保證它是一個新的 object,這也是為什麼我們在用setState的時候總是要產生一個新的物件,而不是直接對現有的做操作。

PureComponent 的陷阱

當我們遵守 Immutable 的規則之後,理所當然的就會想把所有的 Component 都設成 PureComponent,因為 PureComponent 的預設很合理嘛,資料沒變的話就不呼叫 render function,可以節省很多不必要的比較。

那讓我們回頭來看開場小測驗的最後一題:

我們把Row變成了 PureComponent,所以只要 state 跟 props 沒變,就不會 re-render,所以答案應該要是A. 會,在這情況下 PureComponent 會比 Component 有效率

錯,如果你把程式碼看更清楚一點,你會發現答案其實是C. 不會,在這情況下 Component 會比 PureComponent 有效率

你的前提是對的,「只要 state 跟 props 沒變,就不會 re-render,PureComponent 就會比 Component 更有效率」。但其實還有另外一句話也是對的:「如果你的 state 或 props 『永遠都會變』,那 PureComponent 並不會比較快」。

所以這兩種的使用時機差異在於:state 跟 props 到底常常會變還是不會變?

上述的例子中,陷阱在於itemStyle這個 props,我們每次 render 的時候都建立了一個新的物件,所以對 Row 來說,儘管 props.item 是一樣的,但是 props.style 卻是「每次都不一樣」。

如果你已經知道每次都會不一樣,那 PureComponent 這時候就無用武之地了,而且還更糟。為什麼?因為它幫你做了shallowEqual

別忘記了,shallowEqual也是需要執行時間的。

已經知道 props 的比較每次都失敗的話,那不如不要比還會來的比較快,所以在這個情形下,Component 會比 PureComponent 有效率,因為不用做shallowEqual

這就是我開頭提到的需要特別注意的部分。不要以為你把每個 Component 都換成 PureComponent 就天下太平,App 變超快,效能提升好幾倍。不去注意這些細節的話,就有可能把效能越弄越糟。

最後再強調一次,如果你已經預期到某個 component 的 props 或是 state 會「很頻繁變動」,那你根本不用換成 PureComponent,因為你實作之後反而會變得更慢。

總結

在研究這些效能相關的問題時,我最推薦這篇:React, Inline Functions, and Performance,解開了很多我心中的疑惑以及帶給我很多新的想法。

例如說文末提到的 PureComponent 有時候反而會變慢,也是從這篇文章看來的,真心推薦大家抽空去看看。

前陣子跟同事一起把一個專案打掉重做,原本的共識是儘量用 PureComponent,直到我看到這篇文並且仔細思考了一下,發現如果你不知道背後的原理,還是不要輕易使用比較好。因此我就提議改成全部用 Component,等我們碰到效能問題要來優化時再慢慢調整。

最後附上一句我很喜歡的話,從React 巢狀 Component 效能優化這篇看來的(這篇也是在講最後提到的 PureComponent 的問題):

雖然你知道可以優化,但不代表你應該優化。

參考資料:
High Performance React: 3 New Tools to Speed Up Your Apps
reactjs – Reconciliation
reactjs- Optimizing Performance
React is Slow, React is Fast: Optimizing React Apps in Practice
Efficient React Components: A Guide to Optimizing React Performance

相關文章