Redux 並不慢,只是你使用姿勢不對 —— 一份優化指南

檻外畸人發表於2019-03-03

如何優化使用了 Redux 的 React 應用不是那麼顯而易見的,但其實又是非常簡單直接的。本文即是一份帶有若干示例的簡短指南。

在優化使用了 Redux 的 React 應用的時候,我經常聽人說 Redux 很慢。其實在 99% 的情況下,效能低下都和不必要的渲染有關(這一論斷也適用於其他框架),因為 DOM 更新的代價是昂貴的。通過本文,你將學會如何在使用 Redux 的 React 應用中避免不必要的渲染。

一般來講,要在 Redux store 更新的時候同步更新 React 元件,需要用到 React 和 Redux 的官方繫結庫中的 connect 高階元件。
connect 是一個將你的元件進行包裹的函式,它返回一個高階元件,該高階元件會監聽 Redux store,當有狀態更新時就重新渲染自身及其後代元件。

React 和 Redux 的官方繫結庫 —— react-redux 快速入門

connect 高階元件實際上已經被優化過了。為了理解如何更好地使用它,必須先理解它是如何工作的。

實際上,Redux 和 react-redux 都是非常小的庫,因此其原始碼也並非高深莫測。我鼓勵人們通讀原始碼,或者至少讀一部分。如果你想更進一步的話,可以自己實現一個,這能讓你深入理解為什麼它要作如此設計。

閒言少敘,讓我們稍微深入地研究一下 react-redux 的工作機制。前面已經提過,react-redux 的核心是 connect 高階元件,其函式簽名如下:

return function connect(
  mapStateToProps,
  mapDispatchToProps,
  mergeProps,
  {
    pure = true,
    areStatesEqual = strictEqual,
    areOwnPropsEqual = shallowEqual,
    areStatePropsEqual = shallowEqual,
    areMergedPropsEqual = shallowEqual,
    ...extraOptions
  } = {}
) {
...
}複製程式碼

順便說一下 —— 只有 mapStateToProps 這一個引數是必須的,而且大多數情況下只會用到前兩個引數。此處我引用這個函式簽名是為了闡明 react-redux 的工作機制。

所有傳給 connect 函式的引數都用於生成一個物件,該物件則會作為屬性傳給被包裹的元件。mapStateToProps 用於將 Redux store 的狀態對映成一個物件,mapDispatchToProps 用於產生一個包含函式的物件 —— 這些函式一般都是動作生成器(action creators)。mergeProps 則接收 3 個引數:statePropsdispatchPropsownProps,前兩個分別是 mapStateToPropsmapDispatchToProps 的返回結果,最後一個則是繼承自元件本身的屬性。預設情況下,mergeProps 會將上述引數簡單地合併到一個物件中;但是你也可以傳遞一個函式給 mergePropsconnect 則會使用這個函式為被包裹的元件生成屬性。

connect 函式的第四個引數是一個屬性可選的物件,具體包含 5 個可選屬性:一個布林值 pure 以及其他四個用於決定元件是否需要重新渲染的函式(應當返回布林值)。pure 預設為 true,如果設為 false,connect 高階元件則會跳過所有的優化選項,而且那四個函式也就不起任何作用了。我個人認為不太可能有這類應用場景,但是如果你想關閉優化功能的話可以將其設為 false。

mergeProps 返回的物件會和上一個屬性物件作比較,如果 connect 高階元件認為屬性物件所有改變的話就會重新渲染元件。為了理解 react-redux 是如何判斷屬性是否有變化的,請參考 shallowEqual 函式。如果該函式返回 true,則元件不會渲染;反之,元件將會重新渲染。shallowEqual 負責進行屬性物件的比較,下文是其部分程式碼,基本表明了其工作原理:

for (let i = 0; i < keysA.length; i++) {
  if (!hasOwn.call(objB, keysA[i]) ||
      !is(objA[keysA[i]], objB[keysA[i]])) {
    return false
  }
}複製程式碼

概括來講,這段程式碼做了這些工作:

遍歷物件 A 中的所有屬性,檢查物件 B 中是否存在同名屬性。然後檢查 A 和 B 同名屬性的屬性值是否相等。如果這些檢查有一個返回 false,則物件 A 和 B 便被認為是不等的,元件也就會重新渲染。

這引出一條黃金法則:

只給元件傳遞其渲染所必須的資料

這可能有點難以理解,所以讓我們結合一些例子來細細分析一下。

將和 Redux 有連線的元件拆分開來

我見過很多人這樣做:用一個容器元件監聽一大堆狀態,然後通過屬性傳遞下去。

const BigComponent = ({ a, b, c, d }) => (
  <div>
    <CompA a={a} />
    <CompB b={b} />
    <CompC c={c} />
  </div>
);

const ConnectedBigComponent = connect(
  ({ a, b, c }) => ({ a, b, c })
);複製程式碼

現在,一旦 abc 中的任何一個發生改變,BigComponent 以及 CompACompBCompC 都會重新渲染。

其實應該將元件拆分開來,而無需過分擔心使用了太多的 connect

const ConnectedA = connect(CompA, ({ a }) => ({ a }));
const ConnectedB = connect(CompB, ({ b }) => ({ b }));
const ConnectedC = connect(CompC, ({ c }) => ({ c }));

const BigComponent = () => (
  <div>
    <ConnectedA a={a} />
    <ConnectedB b={b} />
    <ConnectedC c={c} />
  </div>
);複製程式碼

如此一來,CompA 只有在 a 發生改變後才會重新渲染,CompB 只有在 b 發生改變後才會重新渲染,CompC 也是類似的。如果 abc 更新很頻繁的話,那每次更新我們僅僅只是重新渲染一個元件而不是一下渲染三個。就這三個元件來講區別可能不會很明顯,但要是元件再多一些就比較明顯了。

轉變元件狀態,使之儘可能地小

這裡有一個人為構造(稍有改動)的例子:

你有一個很大的列表,比如說有 300 多個列表項:

<List>
  {this.props.items.map(({ content, itemId }) => (
    <ListItem
      onClick={selectItem}
      content={content}
      itemId={itemId}
      key={itemId}
    />
  ))}
</List>複製程式碼

點選一個列表項便會觸發一個動作,同時更新 store 中的值 selectedItem。每一個列表項都通過 Redux 獲取 selectedItem 的值:

const ListItem = connect(
  ({ selectedItem }) => ({ selectedItem })
)(SimpleListItem);複製程式碼

這裡我們只給元件傳遞了其所必須的狀態,這是對的。但是,當 selectedItem 發生變化時,所有 ListItem 都會重新渲染,因為我們從 selectedItem 返回的物件發生了變化,之前是 { selectedItem: 123 } 而現在是 { selectedItem: 120 }

記住一點,我們使用了 selectedItem 的值來檢查當前列表項是否被選中了。但是實際上元件只需要知道它有沒有被選中即可, 本質上就是個 Boolean。布林值用在這裡簡直完美,因為它僅僅有 truefalse 兩種狀態。如果我們返回一個布林值而不是 selectedItem,那當那個布林值發生改變時只有兩個元件會被重新渲染,這正是我們期望的結果。mapStateToProps 實際上會將元件的 props 作為第二個引數,我們可以利用這一點來確定當前元件是否是被選中的那一項。程式碼如下:

const ListItem = connect(
  ({ selectedItem }, { itemId }) => ({ isSelected: selectedItem === itemId })
)(SimpleListItem);複製程式碼

如此一來,無論 selectedItem 如何變化,只有兩個元件會被重新渲染 —— 當前選中的 ListItem 和那個被取消選擇的 ListItem

保持資料扁平

Redux 文件 中作為最佳實踐提到了這點。保持 store 扁平有很多好處。但就本文而言,巢狀會造成一個問題,因為我們希望狀態更新粒度儘量小以使應用執行儘量快。比如說我們有這樣一種深淺套的狀態:

{
  articles: [{
    comments: [{
      users: [{
      }]
    }]
  }],
  ...
}複製程式碼

為了優化 ArticleCommentUser 元件,它們都需要訂閱 articles,而後在層層巢狀的屬性中找到所需要的狀態。其實如果將狀態展開成這樣會更加合理:

{
  articles: [{
    ...
  }],
  comments: [{
    articleId: ..,
    userId: ...,
    ...
  }],
  users: [{
    ...
  }]
}複製程式碼

之後用自己的對映函式獲取評論和使用者資訊即可。更多關於狀態扁平化的內容可以參閱 Redux 文件

福利:兩個選擇 Redux 狀態的庫

這一部分完全是可選的。一般來講上述那些建議足夠你編寫出高效的 react 和 Redux 應用了。但還有兩個可以大大簡化狀態選擇的庫:

Reselect 是為 Redux 應用編寫 selectors 所必不可少的工具。根據其官方文件:

  • Selectors 可以計算衍生資料,可以讓 Redux 做到儲存儘可能少的狀態。
  • Selectors 是高效的,只有在某個引數發生變化時才被重新計算。
  • Selectors 是可組合的。它們可以用作其他 selectors 的輸入。

對於介面複雜、狀態繁多、更新頻繁的應用,reselect 可以大大提高應用執行效率。

Ramda 是一個由許多高階函式組成、功能強大的函式庫。 換句話說,就是許多用於建立函式的函式。由於我們的對映函式也不過只是函式而已,所以我們可以利用 Ramda 方便地建立 selectors。Ramda 可以完成所有 selectors 可以完成的工作,而且還不止於此。Ramda cookbook 中介紹了一些 Ramda 的應用示例。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章