建議在閱讀完上一篇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
,即對於唯一的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 函式),對於每一次遍歷:
- 它會建立一個對當前碎片資料的引用
- 呼叫 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,它為我們實現了幾類“不可更改”的資料結構,比如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 實現內幕
提到 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,你需要的不僅僅是fromJS
和toJS
,還需要額外的程式碼去支援它。
最後
其實關於 Immutablejs 還有很多的話題可以聊,比如最佳實踐注意事項什麼的。鑑於篇幅有限就先聊到這裡。有機會再繼續
這篇文章同時也發表在我的知乎前端專欄,歡迎大家關注