React + Redux 性能優化(一):理論篇

本文的敘事線索與代碼示例均來自High Performance Redux,特此表示感謝。之因此感謝是由於最近一直想系統的整理在 React + Redux 技術棧下的性能優化方案,但苦於找不到切入點。在查閱資料的過程當中,這份 Presentation 給了我很大的啓發,它的不少觀點一針見血,也與個人想法不謀而合。因而這篇文章也是參照它的講解線索來依次展開我想表達的知識點javascript

或許你已經據說過不少的第三方優化方案,好比immutable.jsreselectreact-virtualized等等,有關工具的故事下一篇再詳談。首先咱們須要瞭解的是爲何會出現性能問題,以及解決性能問題的思路是什麼。當你瞭解完這一切以後,你會發現其實不少性能問題不須要用第三方類庫解決,只須要在編寫代碼中稍加註意,或者稍稍調整數據結構就能有很大的改觀。前端

性能不是 patch,是 feature

每一個人對性能都有本身的理解。其中有一種觀點認爲,在程序開發的初期不須要關心性能,當程序規模變大而且出現瓶頸以後再來作性能的優化。我不一樣意這種觀點。性能不該該是後來居上的補丁,而應該是程序天生的一部分。從項目的第一天起,咱們就應該考慮作一個10x project:即可以運行 10k 個任務而且擁有 10 年壽命java

退一步說即便你在項目的後期發現了瓶頸問題,公司層面不必定會給你足夠的排期解決這個問題,畢竟業務項目依然是優先的(仍是要看這個性能問題有多「痛」);再退一步說,即便容許你展優化工做,通過長時間迭代開發後的項目已經和當初相比面目全非了:模塊數量龐大,代碼耦合嚴重,尤爲是 Redux 項目牽一髮而動全身,再想對代碼進行優化的話會很是困難。從這個意義上來講,從一開始就將性能考慮進產品中去也是一種 future-proof 的體現,提升代碼的可維護性node

從另外一個角度看,代碼性能也是我的編程技藝的體現,一位優秀的程序員的代碼性能應當是有保障的。react

存在性能問題的列表

前端框架喜歡把實現 Todo List 做爲給新手的教程。咱們這裏也拿一個 List 舉例。假設你須要實現一個列表,用戶點擊有高亮效果僅此而已。特別的地方在於這個列表有 10k 的行,是的,你沒看錯 10k 行(上面不是說好咱們要作 10x project 嗎:p)git

首先咱們看一看基本款代碼,由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); 複製代碼

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

  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 上找到完整代碼瀏覽或者運行。web

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

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

這樣的延遲值並非絕對:

  1. 這樣的現象只有在列表項數目衆多的狀況下出現,好比說 10k。
  2. 在開發環境(ENV === 'development')下運行的代碼會比在生產環境(ENV === 'production')下運行較慢
  3. 我我的 PC 的 CPU 配置是 1700x,不一樣電腦配置的延遲會有所不一樣

診斷

那麼問題出在哪裏?咱們經過 Chrome 開發者工具一探究竟(還有不少其餘的 React 相關的性能工具一樣也能洞察性能問題,好比 react-addons-perf, why-did-you-updateReact 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) {
  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 複製代碼

PureComponentComponent不一樣在於它已經爲你實現了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)

結尾

下一篇咱們學習如何藉助第三方類庫,好比immutablejsreselect對項目進行優化

這篇文章同時也發表在個人 知乎前端專欄,歡迎你們關注

參考資料

相關文章
相關標籤/搜索