本文的敘事線索與程式碼示例均來自High Performance Redux,特此表示感謝。之所以感謝是因為最近一直想系統的整理在 React + Redux 技術棧下的效能優化方案,但苦於找不到切入點。在查閱資料的過程中,這份 Presentation 給了我很大的啟發,它的很多觀點一針見血,也與我的想法不謀而合。於是這篇文章也是參照它的講解線索來依次展開我想表達的知識點
或許你已經聽說過很多的第三方優化方案,比如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) {
switch (action.type) {
case "MARK":
return state.map(
item =>
action.id === item.id ? { ...item, marked: !item.marked } : item
);
default:
return state;
}
}
class App extends Component {
render() {
const { items, markItem } = this.props;
return (
<div>
{items.map(({ id, marked }) => (
<Item key={id} id={id} marked={marked} onClick={markItem} />
))}
</div>
);
}
}
function mapStateToProps(state) {
return state;
}
const markItem = id => ({ type: "MARK", id });
export default connect(mapStateToProps, { markItem })(App);
複製程式碼
這段關鍵的程式碼體現了幾個關鍵的事實:
- 列表每一項(
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) {
return state;
}
function items(state = {}, action) {
switch (action.type) {
case "MARK":
const item = state[action.id];
return {
...state,
[action.id]: { ...item, marked: !item.marked }
};
default:
return state;
}
}
function itemsReducer(state = {}, action) {
return {
ids: ids(state.ids, action),
items: items(state.items, action)
};
}
const store = createStore(itemsReducer);
class App extends Component {
render() {
const { ids } = this.props;
return (
<div>
{ids.map(id => {
return <Item key={id} id={id} />;
})}
</div>
);
}
}
// App.js:
function mapStateToProps(state) {
return { ids: state.ids };
}
// Item.js
function mapStateToProps(state, props) {
const { id } = props;
const { items } = state;
return {
item: items[id]
};
}
const markItem = id => ({ type: "MARK", id });
export default connect(mapStateToProps, { markItem })(Item);
複製程式碼
在這種思維模式下,Item
元件直接與 Store 相連,每次點選時通過 id 直接找到items
狀態字典中的資訊進行修改。因為App
只關心ids
狀態,而在這個需求中不涉及增刪改,所以ids
狀態永遠不會發生改變,在Mounted
之後,App
再也不會更新了。所以現在無論你如何點選列表項,只有被點選的列表項會更新。
很多年前我寫過一篇文章:《在 Node.js 中搭建快取管理模組》,裡面提到過相同的解決思路,有更詳細的敘述
在這一小節的結尾我要告訴大家一個壞訊息:雖然我們可以精心設計狀態的資料結構,但在實際工作中用來展示資料的控制元件,比如表格或者列表,都有各自獨立的資料結構的要求,所以最終的優化效果並非是理想狀態
阻止渲染的發生
讓我們回到最初發生事故的程式碼,它的問題在於每次在渲染需要高亮的程式碼時,無需高亮的程式碼也被渲染了一遍。如果能避免這些無辜程式碼的渲染,那麼同樣也是一種效能上的提升。
你肯定已經知道在 React 元件生命週期就存在這樣一個函式 shoudlComponentUpdate
可以決定是否繼續渲染,預設情況下它返回true
,即始終要重新渲染,你也可以重寫它讓它返回false
,阻止渲染。
利用這個生命週期函式,我們限定只允許marked
屬性發生前後發生變更的元件進行重新渲染:
class Item extends Component {
constructor() {
//...
}
shouldComponentUpdate(nextProps) {
if (this.props["marked"] === nextProps["marked"]) {
return false;
}
return true;
}
複製程式碼
雖然每次點選時App
元件仍然會重新渲染,但是成功阻止了其他 9999 個Item
元件的渲染
事實上 React 已經為我們實現了類似的機制。你可以不重寫shouldComponentUpdate
, 而是選擇繼承React.PureComponent
:
class Item extends React.PureComponent
複製程式碼
PureComponent
與Component
不同在於它已經為你實現了shouldComponentUpdate
生命週期函式,並且在函式對改變前後的 props 和 state 做了“淺對比”(shallow comparison),這裡的“淺”和“淺拷貝”裡的淺是同一個概念,即比較引用,而不比較巢狀物件裡更深層次的值。話說回來 React 也無法為你比較巢狀更深的值,一方面這也耗時的操作,違背了shouldComponentUpdate
的初衷,另一方面複雜的狀態下決定是否重新渲染元件也會有複雜的規則,簡單的比較是否發生了更改並不妥當
反面教材(anti-pattern)
殘酷的現實是,即使你理解了以上的知識點,你可能仍然對日常程式碼中的效能陷阱渾然不知,
比如設定預設值的時候:
<RadioGroup options={this.props.options || []} />
複製程式碼
如果每次 this.props.options
值都是 null
的話,意味著每次傳遞給<RadioGroup />
都是字面量陣列[]
,但字面量陣列和new Array()
效果是一樣的,始終生成新的例項,所以表面上看雖然每次傳遞給元件的都是相同的空陣列,其實對元件來說每次都是新的屬性,都會引起渲染。所以正確的方式應該將一些常用值以變數的形式儲存下來:
const DEFAULT_OPTIONS = []
<RadioGroup options={this.props.options || DEFAULT_OPTIONS} />
複製程式碼
又比如給事件繫結函式的時候
<Button onClick={this.update.bind(this)} />
複製程式碼
或者
<Button
onClick={() => {
console.log("Click");
}}
/>
複製程式碼
在這兩種情況下,對於元件來說每次繫結的都是新的函式,所以也會造成重新渲染。關於如何在eslint
中加入對.bind
方法和箭頭函式的檢測,以及解決之道請參考No .bind() or Arrow Functions in JSX Props (react/jsx-no-bind)
結尾
下一篇我們學習如何藉助第三方類庫,比如immutablejs
和reselect
對專案進行優化
這篇文章同時也發表在我的 知乎前端專欄,歡迎大家關注