React + Redux 性能優化
或許你已經聽說過很多的第三方優化方案,比如immutable.js
,reselect
,react-virtualized
等等,有關工具的故事下一篇再詳談。首先我們需要了解的是為什麼會出現性能問題,以及解決性能問題的思路是什麼。當你了解完這一切之後,你會發現其實很多性能問題不需要用第三方類庫解決,只需要在編寫代碼中稍加註意,或者稍稍調整數據結構就能有很大的改觀。
性能不是 patch,是 feature
每個人對性能都有自己的理解。其中有一種觀點認為,在程序開發的初期不需要關心性能,當程序規模變大並且出現瓶頸之後再來做性能的優化。我不同意這種觀點。性能不應該是後來居上的補丁,而應該是程序天生的一部分。從項目的第一天起,我們就應該考慮做一個10x project:即能夠運行 10k 個任務並且擁有 10 年壽命
退一步說即使你在項目的後期發現了瓶頸問題,公司層面不一定會給你足夠的排期解決這個問題,畢竟業務項目依然是優先的(還是要看這個性能問題有多「痛」);再退一步說,即使允許你展優化工作,經過長時間迭代開發後的項目已經和當初相比面目全非了:模塊數量龐大,代碼耦合嚴重,尤其是 Redux 項目牽一髮而動全身,再想對代碼進行優化的話會非常困難。從這個意義上來說,從一開始就將性能考慮進產品中去也是一種 future-proof 的體現,提高代碼的可維護性
從另一個角度看,代碼性能也是個人編程技藝的體現,一位優秀的程序員的代碼性能應當是有保障的。
存在性能問題的列表
前端框架喜歡把實現 Todo List 作為給新手的教程。我們這裡也拿一個 List 舉例。假設你需要實現一個列表,用戶點擊有高亮效果僅此而已。特別的地方在於這個列表有 10k 的行,是的,你沒看錯 10k 行(上面不是說好我們要做 10x project 嗎:p)
首先我們看一看基本款代碼,由App
組件和Item
組件構成,關鍵代碼如下:
function itemsReducer(state = initial_state, action) {n switch (action.type) {n case "MARK":n return state.map(n item =>n action.id === item.id ? { ...item, marked: !item.marked } : itemn );n default:n return state;n }n}nnclass App extends Component {n render() {n const { items, markItem } = this.props;n return (n <div>n {items.map(({ id, marked }) => (n <Item key={id} id={id} marked={marked} onClick={markItem} />n ))}n </div>n );n }n}nnfunction mapStateToProps(state) {n return state;n}nnconst markItem = id => ({ type: "MARK", id });nnexport default connect(mapStateToProps, { markItem })(App);n
這段關鍵的代碼體現了幾個關鍵的事實:
- 列表每一項(
item
)的數據結構是{ id, marked }
- 列表(
items
)的數據結構是數組類型:[{id1, marked}, {id2, marked}, {id3, marked}]
App
渲染列表是通過遍歷(map
)列表數組items
實現的- 當用戶點擊某一項時,把被點擊項的
id
傳遞給item
的 reducer,reducer 通過遍歷items
,挨個對比id
的方式找到需要被標記的項 - 重新標記完之後將新的數組返回
- 新的數組返回給
App
,App
再次進行渲染
如果你沒法將以上代碼片段和我敘述的事實拼湊在一起,可以在 github 上找到完整代碼瀏覽或者運行。
對於這樣的一個需求,相信絕大多數人的代碼都是這麼寫的。
但是上述代碼沒有告訴你的事實時,這的性能很差。當你嘗試點擊某個選項時,選項的高亮會延遲至少半秒秒鐘,用戶會感覺到列表響應變慢了。
這樣的延遲值並不是絕對:
- 這樣的現象只有在列表項數目眾多的情況下出現,比如說 10k。
- 在開發環境(
ENV === development
)下運行的代碼會比在生產環境(ENV === production
)下運行較慢 - 我個人 PC 的 CPU 配置是 1700x,不同電腦配置的延遲會有所不同
診斷
那麼問題出在哪裡?我們通過 Chrome 開發者工具一探究竟(還有很多其他的 React 相關的性能工具同樣也能洞察性能問題,比如 react-addons-perf, why-did-you-update,React Developer Tools 等等。但都存在或多或少的存在缺陷,使用 Chrome 開發者工具是最靠譜的)
- 本地啟動項目, 打開 Chrome 瀏覽器,在地址欄以訪問項目地址加上
react_perf
後綴的方式訪問項目頁面,比如我的項目地址是: http://localhost:3000/ 的話,實際請訪問 http://localhost:8080/?react_perf 。加上react_perf
後綴的用意是啟用 React 中的性能埋點,這些埋點用於統計 React 中某些操作的耗時,使用User Timing API
實現 - 打開 Chrome 開發者工具,切換到 performance 面板
- 點擊 performance 面板左上角的「錄製」按鈕,開始錄製性能信息
- 點擊列表中的任意一項
- 等被點擊項進入高亮狀態時,點擊「stop」按鈕停止錄製性能信息
- 接下來你就能看到點擊階段的性能大盤信息:
我們把目光聚焦到 CPU 活動最劇烈的那段時間內,
從圖表中可以看出,這部分的時間(712ms)消耗基本是由腳本引起的,準確來說是由點擊事件執行的腳本引起的,並且從函數的調用棧以及從時間排序中可以看出,時間基本上花費在updateComponent
函數中。
這已經能猜出一二,如果你還不確定這個函數究竟幹了什麼,不如展開User Timing
一欄看看更「通俗」的時間消耗
原來時間都花費在App
組件的更新上,每一次App
組件的更新,意味著每一個Item
組件也都要更新,意味著每一個Item
都要被重新渲染(執行render
函數)
如果你依然覺得對以上說法表示懷疑,或者說難以想像,可以直接在App
組件的render
函數和Item
組件的render
函數加上console.log
。那麼每次點擊時,你會看到App
里的console
和Item
里的console
都調用了 10k 次。注意此時頁面會響應的更慢了,因為在控制台輸出 10k 次console.log
也是需要代價的
更重要的知識點在於,只要組件的狀態(props
或者state
)發生了更改,那麼組件就會默認執行render
函數重新進行渲染(你也可以通過重寫shouldComponentUpdate
手動阻止這件事的發生,這是後面會提到的優化點)。同時要注意的事情是,執行render
函數並不意味著瀏覽器中的真實 DOM 樹需要修改。瀏覽器中的真實 DOM 是否需要發生修改,是由 React 最後比較 Virtual Tree 決定的。 我們都知道修改瀏覽器中的真實 DOM 是非常耗費性能的一件事,於是 React 為我們做出了優化。但是執行render
的代價仍然需要我們自己承擔
所以在這個例子中,每一次點擊列表項時,都會引起 store 中items
狀態的更改,並且返回的items
狀態總是新的數組,也就造成了每次點擊過後傳遞給App
組件的屬性都是新的
反擊
請記住下面這個公式
UI = f(state)
你在頁面上所見的,都是對狀態的映射。反過來說,只要組件狀態或者傳遞給組件的屬性沒有發生改變,那麼組件也不會重新進行渲染。我們可以利用這一點阻止App
的渲染,只要保證轉遞給App
組件的屬性不會發生改變即可。畢竟只修改一條列表項的數據卻結果造成了其他 9999 條數據的重新渲染是不合理的。
但是應該如何做才能保證修改數據的同時傳遞給App
的數據不發生變化?
通過更改數據結構
原本所有的items
信息都存在數組結構里,數組結構的一個重要特性是保證了訪問數據的順序一致性。現在我們把數據拆分為兩部分
- 數組結構
ids
:只保留 id 用於記錄數據順序,比如:[id1, id2, id3]
- 字典(對象)結構
items
:以key-value
的形式記錄每個數據項的具體信息:{id1: {marked: false}, id2: {marked: false}}
關鍵代碼如下:
function ids(state = [], action) {n return state;n}nnfunction items(state = {}, action) {n switch (action.type) {n case "MARK":n const item = state[action.id];n return {n ...state,n [action.id]: { ...item, marked: !item.marked }n };n default:n return state;n }n}nnfunction itemsReducer(state = {}, action) {n return {n ids: ids(state.ids, action),n items: items(state.items, action)n };n}nnconst store = createStore(itemsReducer);nnclass App extends Component {n render() {n const { ids } = this.props;n return (n <div>n {ids.map(id => {n return <Item key={id} id={id} />;n })}n </div>n );n }n}nn// App.js:nfunction mapStateToProps(state) {n return { ids: state.ids };n}n// Item.jsnfunction mapStateToProps(state, props) {n const { id } = props;n const { items } = state;n return {n item: items[id]n };n}nnconst markItem = id => ({ type: "MARK", id });nexport default connect(mapStateToProps, { markItem })(Item);n
在這種思維模式下,Item
組件直接與 Store 相連,每次點擊時通過 id 直接找到items
狀態字典中的信息進行修改。因為App
只關心ids
狀態,而在這個需求中不涉及增刪改,所以ids
狀態永遠不會發生改變,在Mounted
之後,App
再也不會更新了。所以現在無論你如何點擊列表項,只有被點擊的列表項會更新。
很多年前我寫過一篇文章:《在 Node.js 中搭建緩存管理模塊》,裡面提到過相同的解決思路,有更詳細的敘述
在這一小節的結尾我要告訴大家一個壞消息:雖然我們可以精心設計狀態的數據結構,但在實際工作中用來展示數據的控制項,比如表格或者列表,都有各自獨立的數據結構的要求,所以最終的優化效果並非是理想狀態
阻止渲染的發生
讓我們回到最初發生事故的代碼,它的問題在於每次在渲染需要高亮的代碼時,無需高亮的代碼也被渲染了一遍。如果能避免這些無辜代碼的渲染,那麼同樣也是一種性能上的提升。
你肯定已經知道在 React 組件生命周期就存在這樣一個函數 shoudlComponentUpdate
可以決定是否繼續渲染,默認情況下它返回true
,即始終要重新渲染,你也可以重寫它讓它返回false
,阻止渲染。
利用這個生命周期函數,我們限定只允許marked
屬性發生前後發生變更的組件進行重新渲染:
class Item extends Component {n constructor() {n //...n }n shouldComponentUpdate(nextProps) {n if (this.props["marked"] === nextProps["marked"]) {n return false;n }n return true;n }n
雖然每次點擊時App
組件仍然會重新渲染,但是成功阻止了其他 9999 個Item
組件的渲染
事實上 React 已經為我們實現了類似的機制。你可以不重寫shouldComponentUpdate
, 而是選擇繼承React.PureComponent
:
class Item extends React.PureComponent n
PureComponent
與Component
不同在於它已經為你實現了shouldComponentUpdate
生命周期函數,並且在函數對改變前後的 props 和 state 做了「淺對比」(shallow comparison),這裡的「淺」和「淺拷貝」里的淺是同一個概念,即比較引用,而不比較嵌套對象里更深層次的值。話說回來 React 也無法為你比較嵌套更深的值,一方面這也耗時的操作,違背了shouldComponentUpdate
的初衷,另一方面複雜的狀態下決定是否重新渲染組件也會有複雜的規則,簡單的比較是否發生了更改並不妥當
反面教材(anti-pattern)
殘酷的現實是,即使你理解了以上的知識點,你可能仍然對日常代碼中的性能陷阱渾然不知,
比如設置預設值的時候:
<RadioGroup options={this.props.options || []} />n
如果每次 this.props.options
值都是 null
的話,意味著每次傳遞給<RadioGroup />
都是字面量數組[]
,但字面量數組和new Array()
效果是一樣的,始終生成新的實例,所以表面上看雖然每次傳遞給組件的都是相同的空數組,其實對組件來說每次都是新的屬性,都會引起渲染。所以正確的方式應該將一些常用值以變數的形式保存下來:
const DEFAULT_OPTIONS = []n<RadioGroup options={this.props.options || DEFAULT_OPTIONS} />n
又比如給事件綁定函數的時候
<Button onClick={this.update.bind(this)} />n
或者
<Buttonn onClick={() => {n console.log("Click");n }}n/>n
在這兩種情況下,對於組件來說每次綁定的都是新的函數,所以也會造成重新渲染。關於如何在eslint
中加入對.bind
方法和箭頭函數的檢測,以及解決之道請參考No .bind() or Arrow Functions in JSX Props (react/jsx-no-bind)
推薦閱讀:
※React 父組件引發子組件重渲的時候,如何保持子組件的狀態更新不受影響?
※React V16 錯誤處理(componentDidCatch 示例)
※【譯】React 16 測試版本
TAG:React |