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

李熠發表於2018-03-21

建議在閱讀完上一篇React + Redux 效能優化(一):理論篇之後再開始本文的旅程,本文的很多概念和結論,都在上篇做了詳細的講解

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

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

關於 Pure

無論是在 react 還是 redux 中,pure 都是非常重要的概念。理解什麼是 pure 有助於我們理解我們為什麼需要 Immutablejs

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

在程式設計中,若一個函式符合以下要求,則它可能被認為是純函式:

  • 此函式在相同的輸入值時,需產生相同的輸出。函式的輸出和輸入值以外的其他隱藏資訊或狀態無關,也和由I/O裝置產生的外部輸出無關。
  • 該函式不能有語義上可觀察的函式副作用,諸如“觸發事件”,使輸出裝置輸出,或更改輸出值以外物件的內容等。

簡單來說純函式的兩個特徵:1) 對於相同的輸入總有相同的輸出;2) 函式不依賴外部變數,也不會對外部產生影響(這種影響稱之為“副作用(side effects)”)

Reducer

redux 中規定 reducer 就是純函式。它接收前一個 state 狀態和 action 作為引數,返回下一個狀態:

(previousState, action) => newState
複製程式碼

保證 reducer 的“純粹(pure)”非常重要,你永遠不能在 reducer 中做以下三件事:

  • 修改引數
  • 執行任何具有副作用的操作,比如呼叫 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 記憶體裡的儲存結構是這樣的:

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

但我們還可以根據 key 使用到的字母作為索引,組織成字典查詢樹的結構:

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

在這種資料結構中,無論你想訪問物件任意屬性的值,從根節點出發都能夠訪問到

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

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

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

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

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

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

這就是 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 還有很多的話題可以聊,比如最佳實踐注意事項什麼的。鑑於篇幅有限就先聊到這裡。有機會再繼續

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

參考文章

相關文章