標籤:

React + Redux 性能優化

或許你已經聽說過很多的第三方優化方案,比如immutable.jsreselectreact-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

這段關鍵的代碼體現了幾個關鍵的事實:

  1. 列表每一項(item)的數據結構是{ id, marked }
  2. 列表(items)的數據結構是數組類型:[{id1, marked}, {id2, marked}, {id3, marked}]
  3. App渲染列表是通過遍歷(map)列表數組items實現的
  4. 當用戶點擊某一項時,把被點擊項的id傳遞給item的 reducer,reducer 通過遍歷 items,挨個對比id的方式找到需要被標記的項
  5. 重新標記完之後將新的數組返回
  6. 新的數組返回給AppApp再次進行渲染

如果你沒法將以上代碼片段和我敘述的事實拼湊在一起,可以在 github 上找到完整代碼瀏覽或者運行。

對於這樣的一個需求,相信絕大多數人的代碼都是這麼寫的。

但是上述代碼沒有告訴你的事實時,這的性能很差。當你嘗試點擊某個選項時,選項的高亮會延遲至少半秒秒鐘,用戶會感覺到列表響應變慢了。

這樣的延遲值並不是絕對:

  1. 這樣的現象只有在列表項數目眾多的情況下出現,比如說 10k。
  2. 在開發環境(ENV === development)下運行的代碼會比在生產環境(ENV === production)下運行較慢
  3. 我個人 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里的consoleItem里的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信息都存在數組結構里,數組結構的一個重要特性是保證了訪問數據的順序一致性。現在我們把數據拆分為兩部分

  1. 數組結構ids:只保留 id 用於記錄數據順序,比如:[id1, id2, id3]
  2. 字典(對象)結構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

PureComponentComponent不同在於它已經為你實現了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 |