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

李熠發表於2018-01-03

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

或許你已經聽說過很多的第三方優化方案,比如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) {
  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);
複製程式碼

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

  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-updateReact Developer Tools 等等。但都存在或多或少的存在缺陷,使用 Chrome 開發者工具是最靠譜的)

  • 本地啟動專案, 開啟 Chrome 瀏覽器,在位址列以訪問專案地址加上react_perf字尾的方式訪問專案頁面,比如我的專案地址是: http://localhost:3000/ 的話,實際請訪問 http://localhost:8080/?react_perf 。加上react_perf字尾的用意是啟用 React 中的效能埋點,這些埋點用於統計 React 中某些操作的耗時,使用User Timing API實現
  • 開啟 Chrome 開發者工具,切換到 performance 皮膚
    React + Redux 效能優化(一):理論篇
  • 點選 performance 皮膚左上角的“錄製”按鈕,開始錄製效能資訊

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

  • 點選列表中的任意一項
  • 等被點選項進入高亮狀態時,點選“stop”按鈕停止錄製效能資訊
    React + Redux 效能優化(一):理論篇
  • 接下來你就能看到點選階段的效能大盤資訊:
    React + Redux 效能優化(一):理論篇

我們把目光聚焦到 CPU 活動最劇烈的那段時間內,

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

從圖表中可以看出,這部分的時間(712ms)消耗基本是由指令碼引起的,準確來說是由點選事件執行的指令碼引起的,並且從函式的呼叫棧以及從時間排序中可以看出,時間基本上花費在updateComponent函式中。

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

這已經能猜出一二,如果你還不確定這個函式究竟幹了什麼,不如展開User Timing一欄看看更“通俗”的時間消耗

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

原來時間都花費在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對專案進行優化

這篇文章同時也發表在我的 知乎前端專欄,歡迎大家關注

參考資料

相關文章