React + Redux 性能優化(二)工具篇: Immutablejs

建議在閱讀完上一篇React + Redux 性能優化(一):理論篇以後再開始本文的旅程,本文的不少概念和結論,都在上篇作了詳細的講解javascript

這會是一篇長文,咱們首先會討論使用 Immutable Data 的正當性;而後從功能上和性能上研究使用 Immutablejs 的技術的必要性html

我猜你更關心的是是否值得使用 Immutablejs,這裏先放上結論:推薦使用;但不必定必須使用。若是推薦指數最低一分最高十分的話,那麼打六分。前端

關於 Pure

不管是在 react 仍是 redux 中,pure 都是很是重要的概念。理解什麼是 pure 有助於咱們理解咱們爲何須要 Immutablejsjava

首先咱們要介紹什麼是Pure function (純函數), 來自維基百科:react

在程序設計中,若一個函數符合如下要求,則它可能被認爲是純函數:git

  • 此函數在相同的輸入值時,需產生相同的輸出。函數的輸出和輸入值之外的其餘隱藏信息或狀態無關,也和由I/O設備產生的外部輸出無關。
  • 該函數不能有語義上可觀察的函數反作用,諸如「觸發事件」,使輸出設備輸出,或更改輸出值之外物件的內容等。

簡單來講純函數的兩個特徵:1) 對於相同的輸入總有相同的輸出;2) 函數不依賴外部變量,也不會對外部產生影響(這種影響稱之爲「反作用(side effects)」)github

Reducer

redux 中規定 reducer 就是純函數。它接收前一個 state 狀態和 action 做爲參數,返回下一個狀態:redux

(previousState, action) => newState
複製代碼

保證 reducer 的「純粹(pure)」很是重要,你永遠不能在 reducer 中作如下三件事:api

  • 修改參數
  • 執行任何具備反作用的操做,好比調用 API
  • 調用任何不純粹的函數,好比Math.random()或者Date.now()

因此你會看到在 reducer 裏返回狀態是經過Object.assign({}, state)實現的(注意不要寫成Object.assign(state)這樣就修改了原狀態)。而至於調用 API 等異步或者具備「反作用」的操做,則能夠藉助於redux-thunk或者redux-saga性能優化

Pure Component

在上一篇中咱們談到過 Pure Component,準確說那是狹義上的React.PureComponent。廣義上的 Pure Compnoent 指的是 Stateless Component,也就是無狀態組件,也被稱爲 Dumb Component、 Presentational Component。從代碼上它的特徵是 1) 不維護本身的狀態,2) 只有render函數:

const HelloUser = ({userName}) => {
  return <div>{`Hello ${userName}`}</div>
}
複製代碼

顯而易見的是,這種形式的「純組件」和「純函數」有殊途同歸之妙,即對於相同的屬性傳入,組件老是輸出惟一的結果。

固然這樣形式的組件也喪失了一部分的能力,例如再也不擁有生命週期函數。

性能

上篇中咱們得出的一個很重要的結論是,只要組件的狀態(props或者state)發生了改變,那麼組件就會執行render函數進行從新渲染。除非你重寫shouldComponentUpdate周期函數經過返回false來阻止這件事的發生;又或者直接讓組件直接繼承PureComponent

而繼承PureComponent的原理也很簡單,它只不過代替你實現了shouldComponentUpdate函數:在函數內對如今和過去的props/state進行「淺對比」(shallow comparision,即僅僅是比較對象的引用而不是比較對象每一個屬性的值),若是發現對象先後沒有改變則不執行render函數對組件進行從新渲染

其實這樣一套類似邏輯在 Redux 中也屢次存在,在 redux 中也會對數據進行「淺對比」

首先是在react-redux

咱們一般會使用react-redux中的connect函數將程序狀態注入進組件中,例如:

import {conenct} from 'react-redux'

function mapStateToProps(state) {
  return {
    todos: state.todos,
    visibleTodos: getVisibleTodos(state),
  }
}

export default connect(mapStateToProps, mapDispatchToProps)(App)
複製代碼

代碼中組件App是被 react-redux 封裝的組件,react-redux會假設App是一個Pure Component,即對於惟一的propsstate有惟一的渲染結果。 因此react-redux首先會對根狀態(即上述代碼中mapStateToProps的第一個形參state)建立索引,進行淺對比,若是對比結果一致則不對組件進行從新渲染,不然繼續調用mapStateToProps函數;同時繼續對mapStateToProps返回的props對象裏的每個屬性的值(即上述代碼中的state.todos值和getVisibleTodos(state)值,而不是返回的props整個對象)建立索引。和shouldComponentUpdate相似,只有當淺對比失敗,即索引起生更改時纔會從新對封裝的組件進行渲染

就上面的代碼例子來講,只要state.todosgetVisibleTodos(state)的值不發生更改,那麼App組件就永遠不會再一次進行渲染。可是請注意下面的陷阱模式:

function mapStateToProps(state) {
  return {
    data: {
      todos: state.todos,
      visibleTodos: getVisibleTodos(state),
    }
  }
}
複製代碼

即便state.todosgetVisibleTodos(state)一樣再也不發生變化,可是由於每次mapStateToProps返回結果{ data: {...} }中的data都建立新的(字面量)對象,致使淺對比老是失敗,App依然會再次渲染

其次是在 combineReducers 中。

咱們都知道 Redux Store 鼓勵咱們把狀態對象劃分爲不一樣的碎片(slice)或者領域(domain,也能夠理解爲業務),而且爲這些不一樣的領域分別編寫 reducer 函數用於管理它們的狀態,最後使用官方提供的combineReducers函數將這些領域以及它們的 reducer 函數關聯起來,拼裝成一個總體的state

舉個例子

combineReducers({ todos: myTodosReducer, counter: myCounterReducer })
複製代碼

上述代碼中,程序的狀態是由{ todos, counter }兩個領域模型組成,同時myTodosReducermyCounterReducer分別爲各自領域的 reducer 函數

combineReducers會遍歷每一「對」領域(key是領域名稱、value是領域 reducer 函數),對於每一次遍歷:

  • 它會建立一個對當前碎片數據的引用
  • 調用 reducer 函數計算碎片數據新的狀態,而且返回
  • 爲 reducer 函數返回的新的碎片數據建立新的引用,將新的引用和當前數據引用進行淺對比,若是對比失敗了(同時意味着兩次引用不一致,意味着 reducer 返回的是一個新的對象),那麼將標識位hasChanged設置爲true

在通過一輪(這裏的一輪指的是把每個領域都遍歷了一遍)遍歷以後,combineReducer就獲得了一個新的狀態對象,經過hasChanged標識位咱們就能判斷出總體狀態是否發生了更改,若是爲true,新的狀態就會被返回給下游,若是是false,舊的當前狀態就會被返回給下游。這裏的下游指的是react-redux以及更下游的界面組件。

咱們已經知道了react-redux會對根狀態進行淺對比,若是引用發生了改變,才從新渲染組件。因此當狀態須要發生更改時,務必讓相應的 reducer 函數始終返回新的對象!修改原有對象的屬性值而後返回不會觸發組件的從新渲染!

因此咱們常看到的 reducer 函數寫法是最終經過 Object.assign 複製原狀態對象而且返回一個新的對象:

function myCounterReducer(state = { count: 0 }, action) {
  switch (action.type) {
    case "add":
      return Object.assign({}, state, { count: state.count + 1 });
    default:
      return state;
  }
}
複製代碼

錯誤的作法是僅僅修改原對象:

function myCounterReducer(state = { count: 0 }, action) {
  switch (action.type) {
    case "add":
      state.count++
      return state
    default:
      return state;
  }
}
複製代碼

有趣的事情是若是你此時在state.count++以後打印 state 的結果,你會發現state.count確實在每次add以後都有自增,可是組件卻始終不會渲染出來

Immutable Data 和 Immutablejs

結合以上兩個知識點,不管是從 reducer 的定義上,仍是從 redux 的工做機制上,咱們都走上了同一條Object.assign的模式,即不修改原狀態,只返回新狀態。可見 state 天生就是不可被更改的(Immutable)

可是使用Object.assign的方法卻不能算優雅,甚至有 hack 的嫌疑,畢竟Object.assign的本意是用來複制一個對象的屬性到另外一個對象的。因而咱們在這裏引入 Immutablejs,它爲咱們實現了幾類「不可更改」的數據結構,好比MapList,咱們舉幾個使用的例子。

好比咱們須要建立一個空對象,這裏使用 Immutablejs 中的 Map數據結構:

import {Map} from 'immutable'
const person = Map()
複製代碼

好像沒有什麼特別的。接下來咱們想給這個person實例添加age屬性,這裏須要使用Map自帶的set方法:

const personWithAge = person.set('age', 20)
複製代碼

接下來咱們把personpersonWithAge打印出來:

console.log(person.toJS())
console.log(personWithAge.toJS())
複製代碼

注意這裏不能直接打印person,不然你會獲得一個封裝以後的數據結構;而是要先調用toJS方法,將Map數據結構轉化爲普通的原生對象。 此時你獲得的結果是:

console.log(person.toJS()) // {}
console.log(personWithAge.toJS()) // { age: 20 }
複製代碼

看出問題了嗎?咱們想更改person的屬性,但person的屬性卻沒有更改,而set方法返回的結果personWithAge倒是咱們想獲得的。

也就是說,在 Immutabejs 的數據結構中,當你想更改某個對象屬性時,你獲得的永遠是一個新的對象,而原對象永遠也不會發生更改。這與咱們Object.assign的使用場景是契合的。那麼當咱們須要修改statestate是 Immutablejs 數據結構時,修改而且返回便可:

function myCounterReducer(state = { count: 0 }, action) {
  switch (action.type) {
    case "add":
      return state.set('count', state.get('count') + 1);
    default:
      return state;
  }
}
複製代碼

這只是 Immutablejs 的核心功能。基於它本身的封裝的數據結構,它還給咱們提供了其餘好用的功能,好比.getIn方法或者.setIn方法,又或者能夠約束數據結構的Record類型。Immutablejs 的使用技巧能夠另說

Immutablejs 實現內幕

提到 Immutablejs,不得不提用於實現它的數據結構,這經常是被認爲它性能高於原生對象的論據之一。這一小節的部分直接翻譯自Immutable.js, persistent data structures and structural sharing,作了簡化和刪減

假設你有這樣的一個 Javascript 結構對象:

const data = {
  to: 7,
  tea: 3,
  ted: 4,
  ten: 12,
  A: 15,
  i: 11,
  in: 5,
  inn: 9
}
複製代碼

能夠想象它在 Javscript 內存裏的存儲結構是這樣的:

但咱們還能夠根據 key 使用到的字母做爲索引,組織成字典查找樹的結構:

在這種數據結構中,不管你想訪問對象任意屬性的值,從根節點出發都可以訪問到

當你想修改值時,只須要建立一棵新的字典查找樹,而且最大限度的利用已有節點便可

假設此時你想修改 tea 屬性的值爲14,首先須要找到訪問到tea節點的關鍵路徑:

而後將這些節點複製出來,構建一棵一摸同樣結構的樹,只不過新樹的其餘的節點均是對原樹的引用:

最後將新構建的樹的根節點返回

這就是 Immutablejs 中 Map 的基本實現原理,這也固然只是 Immutablejs 的黑科技之一

實戰測試

這樣的數據結構可以帶來多大性能上的提高?咱們實際測試一下:

假設咱們有十萬個todos數據,用原生的 Javascript 對象進行存儲:

const todos = {
  '1': { title: `Task 1`, completed: false };
  '2': { title: `Task 2`, completed: false };
  '3': { title: `Task 3`, completed: false };
  //...
  '100000': { title: `Task 1`, completed: false };
}
複製代碼

或者使用函數生成十萬個todos:

function generateTodos() {
  let count = 100000;
  const todos = {};
  while (count) {
    todos[count.toString()] = { title: `Task ${count}`, completed: false };
    count--;
  }
  return todos;
}
複製代碼

接下來咱們準備一個 reducer 用於根據 id 切換單個 todo 的 completed 狀態:

function toggleTodo(todos, id) {
  return Object.assign({}, todos, {
    [id]: Object.assign({}, todos[id], {
      completed: !todos[id].completed
    })
  });
}
複製代碼

接下里咱們測試一下修改單個todo所耗費的時間是多少:

const startTime = performance.now();
const nextState = toggleTodo(todos, String(100000 / 2));
console.log(performance.now() - startTime);
複製代碼

在個人PC(配置 1700x ,32GB, Chrome 64.0.3282.186)上執行的時間是 33ms

接下來咱們把toggleTodo換成 Immutablejs 版本(固然數據也要是 Immutablejs 中的Map數據類型,Immutablejs 提供了方法fromJS可以很方便的將原生 Javacript 數據類型轉化爲 Immutablejs 數據類型)再試試看:

function toggleTodo(todos, id) {
  return todos.set(id, !todos.getIn([id, "completed"]));
}
const startTime = performance.now();
const nextState = toggleTodo(state, String(100000 / 2));
console.log(performance.now() - startTime);
複製代碼

執行時間不超過 1ms,快了 30 倍!

可是你有沒有看出這個測試的問題:

  • 雖然二者之間相差了30倍,可是最慢也就是 33ms 而已,用戶是感受不到的。若是這也算是瓶頸的話,這個瓶頸不會形成太大的問題
  • 1ms vs 33ms 的成績是在十萬個 todo 的狀況下測試出來的,但在實際的過程當中,不多的場景會用到這麼大的數據量。那若是在一千條數據下原生表現的狀況如何呢?原生方法一樣不會超過 1ms
  • 咱們只觀察到了 Immutablejs 在更改屬性時高效,卻忘了在原生數據轉化爲 Immutablejs 時(fromJS)或者從 Immutablejs 轉化爲原生對象時(toJS)也是須要代價的。若是你在fromJS的先後記錄時間,你會發現時間大約是 300ms。你沒法避免轉化,由於第三方組件或者老舊代碼頗有可能不支持 Immutablejs

因此綜上,使用 Immutablejs 會帶來性能上的提高,但性能並不會很是明顯,同時還會有兼容性問題

我還有其餘的一些關於性能的的測試放在 github 上,測試過程當中也有一些很好玩的發現,就不一一贅述了。有興趣的朋友能夠拿去跑一跑,由於是一次性的之後不會再維護了,因此代碼寫得比較爛,請見諒

說一說使用 Immutablejs 可能帶來的問題

  • 學習成本。不只僅是你我的的學習成本,整個團隊都須要學習如何使用它。最可怕的是在你們都不熟悉可是又不得不使用它的狀況下, 很容易的就會引入一些錯誤實踐。這會給代碼埋下隱患
  • 兼容性問題,絕大部分第三方代碼都不支持這種數據結構,你也沒法改造當前項目的每個組件去適應它,因此務必要進行數據格式間的兼容和轉化。若是隻是在單個組件中使用 Immutablejs 還好,若是你想貫穿於整個應用使用,從 reducer 的 initialState 就開始使用它,那麼可能會有更多的問題等着你處理,好比經常使用的react-router-redux就不支持 Immutablejs,你須要的不只僅是fromJStoJS,還須要額外的代碼去支持它。

最後

其實關於 Immutablejs 還有不少的話題能夠聊,好比最佳實踐注意事項什麼的。鑑於篇幅有限就先聊到這裏。有機會再繼續

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

參考文章

相關文章
相關標籤/搜索