建議在閱讀完上一篇React + Redux 性能優化(一):理論篇以後再開始本文的旅程,本文的不少概念和結論,都在上篇作了詳細的講解javascript
這會是一篇長文,咱們首先會討論使用 Immutable Data 的正當性;而後從功能上和性能上研究使用 Immutablejs 的技術的必要性html
我猜你更關心的是是否值得使用 Immutablejs,這裏先放上結論:推薦使用;但不必定必須使用。若是推薦指數最低一分最高十分的話,那麼打六分。前端
不管是在 react 仍是 redux 中,pure 都是很是重要的概念。理解什麼是 pure 有助於咱們理解咱們爲何須要 Immutablejsjava
首先咱們要介紹什麼是Pure function (純函數), 來自維基百科::react
在程序設計中,若一個函數符合如下要求,則它可能被認爲是純函數:git
- 此函數在相同的輸入值時,需產生相同的輸出。函數的輸出和輸入值之外的其餘隱藏信息或狀態無關,也和由I/O設備產生的外部輸出無關。
- 該函數不能有語義上可觀察的函數反作用,諸如「觸發事件」,使輸出設備輸出,或更改輸出值之外物件的內容等。
簡單來講純函數的兩個特徵:1) 對於相同的輸入總有相同的輸出;2) 函數不依賴外部變量,也不會對外部產生影響(這種影響稱之爲「反作用(side effects)」)github
redux 中規定 reducer 就是純函數。它接收前一個 state 狀態和 action 做爲參數,返回下一個狀態:redux
(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,準確說那是狹義上的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
,即對於惟一的props
和state
有惟一的渲染結果。 因此react-redux
首先會對根狀態(即上述代碼中mapStateToProps
的第一個形參state
)建立索引,進行淺對比,若是對比結果一致則不對組件進行從新渲染,不然繼續調用mapStateToProps
函數;同時繼續對mapStateToProps
返回的props
對象裏的每個屬性的值(即上述代碼中的state.todos
值和getVisibleTodos(state)
值,而不是返回的props
整個對象)建立索引。和shouldComponentUpdate
相似,只有當淺對比失敗,即索引起生更改時纔會從新對封裝的組件進行渲染
就上面的代碼例子來講,只要state.todos
和getVisibleTodos(state)
的值不發生更改,那麼App
組件就永遠不會再一次進行渲染。可是請注意下面的陷阱模式:
function mapStateToProps(state) {
return {
data: {
todos: state.todos,
visibleTodos: getVisibleTodos(state),
}
}
}
複製代碼
即便state.todos
和getVisibleTodos(state)
一樣再也不發生變化,可是由於每次mapStateToProps
返回結果{ data: {...} }
中的data
都建立新的(字面量)對象,致使淺對比老是失敗,App
依然會再次渲染
其次是在 combineReducers
中。
咱們都知道 Redux Store 鼓勵咱們把狀態對象劃分爲不一樣的碎片(slice)或者領域(domain,也能夠理解爲業務),而且爲這些不一樣的領域分別編寫 reducer 函數用於管理它們的狀態,最後使用官方提供的combineReducers
函數將這些領域以及它們的 reducer 函數關聯起來,拼裝成一個總體的state
舉個例子
combineReducers({ todos: myTodosReducer, counter: myCounterReducer })
複製代碼
上述代碼中,程序的狀態是由{ todos, counter }
兩個領域模型組成,同時myTodosReducer
與myCounterReducer
分別爲各自領域的 reducer 函數
combineReducers
會遍歷每一「對」領域(key是領域名稱、value是領域 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
以後都有自增,可是組件卻始終不會渲染出來
結合以上兩個知識點,不管是從 reducer 的定義上,仍是從 redux 的工做機制上,咱們都走上了同一條Object.assign
的模式,即不修改原狀態,只返回新狀態。可見 state 天生就是不可被更改的(Immutable)
可是使用Object.assign
的方法卻不能算優雅,甚至有 hack 的嫌疑,畢竟Object.assign
的本意是用來複制一個對象的屬性到另外一個對象的。因而咱們在這裏引入 Immutablejs,它爲咱們實現了幾類「不可更改」的數據結構,好比Map
,List
,咱們舉幾個使用的例子。
好比咱們須要建立一個空對象,這裏使用 Immutablejs 中的 Map
數據結構:
import {Map} from 'immutable'
const person = Map()
複製代碼
好像沒有什麼特別的。接下來咱們想給這個person
實例添加age
屬性,這裏須要使用Map
自帶的set
方法:
const personWithAge = person.set('age', 20)
複製代碼
接下來咱們把person
和personWithAge
打印出來:
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
的使用場景是契合的。那麼當咱們須要修改state
而state
是 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,不得不提用於實現它的數據結構,這經常是被認爲它性能高於原生對象的論據之一。這一小節的部分直接翻譯自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 倍!
可是你有沒有看出這個測試的問題:
fromJS
)或者從 Immutablejs 轉化爲原生對象時(toJS
)也是須要代價的。若是你在fromJS
的先後記錄時間,你會發現時間大約是 300ms。你沒法避免轉化,由於第三方組件或者老舊代碼頗有可能不支持 Immutablejs因此綜上,使用 Immutablejs 會帶來性能上的提高,但性能並不會很是明顯,同時還會有兼容性問題
我還有其餘的一些關於性能的的測試放在 github 上,測試過程當中也有一些很好玩的發現,就不一一贅述了。有興趣的朋友能夠拿去跑一跑,由於是一次性的之後不會再維護了,因此代碼寫得比較爛,請見諒
react-router-redux
就不支持 Immutablejs,你須要的不只僅是fromJS
和toJS
,還須要額外的代碼去支持它。其實關於 Immutablejs 還有不少的話題能夠聊,好比最佳實踐注意事項什麼的。鑑於篇幅有限就先聊到這裏。有機會再繼續
這篇文章同時也發表在個人知乎前端專欄,歡迎你們關注