Immutable.js與React,Redux及reselect的實踐
歡迎訪問我的個人博客-Immutable.js與React,Redux及reselect的實踐
本篇文章將聚焦Immutable與Redux,reselect的項目實踐,將從多方面闡述Immutable及Redux:包括什麼是Immutable,為什麼需要使用Immutable,Immutable.js與React,Redux及reselect的組合實踐及優化,最後總結使用Immutable可能遇到的一些問題及解決方式。
Immutable
Immutable來自於函數式編程的世界,我們可以稱它為不可變,試想如下代碼:
var object = { x:1, y: 2 };var object2 = { x: 1, y: 2 };object == object2// falseobject === object2 // false
相等性檢查將包括兩個部分:
- 值檢查
- 引用檢查
引用檢查
JavaScript的對象是一個非常複雜的數據結構,它的鍵可以指向任意值,包括object。JavaScript創建的對象將存儲在計算機內存中(對應一個物理地址),然後它返回一個引用,JavaScript引擎通過該引用可以訪問該對象,該引用賦值給某個變數後,我們便可以通過該變數以引用的方式操作該對象。引用檢查即檢查兩個對象的引用地址是否相同。
值檢查
層層循環檢查對象各屬性值是否相同。
React重新渲染
React通過對組件屬性(props)和狀態(state)進行變更檢查以決定是否更新並重新渲染該組件,若組件狀態太過龐大,組件性能就會下降,因為對象越複雜,其相等性檢查就會越慢。
- 對於嵌套對象,必須迭代層層進行檢查判斷,耗費時間過長;
- 若僅修改對象的屬性,其引用保持不變,相等性檢查中的引用檢查結果不變;
Immutable提供一直簡單快捷的方式以判斷對象是否變更,對於React組件更新和重新渲染性能可以有較大幫助。
Immutable數據
Never mutated, instead copy it and then make change.
絕對不要突然修改對象,首先複製然後修改複製對象,再返回這個新對象,保持原對象不變。
Immutable對象和原生JavaScript對象的主要差異可以概括為以下兩點:
- 持久化數據結構(Persistent data structures)
- 結構共享(Structures sharing Trie)
持久化數據結構
持久數據結構主張所有操作都返回該數據結構的更新副本,並保持原有結構不變,而不是改變原來的結構。通常利用Trie構建它不可變的持久性數據結構,它的整體結構可以看作一棵樹,一個樹節點可以對應代表對象某一個屬性,節點值即屬性值。
結構共享
一旦創建一個Immutable Trie型對象,我們可以把該Trie型對象想像成如下一棵樹,在之後的對象變更儘可能的重用樹節點:
當我們要更新一個Immutable對象的屬性值時,就是對應著需要重構該Trie樹中的某一個節點,對於Trie樹,我們修改某一節點只需要重構該節點及受其影響的節點,即其祖先節點,如上圖中的四個綠色節點,而其他節點可以完全重用。
參考
- Immutable Persistent Data Structures
- Trie
為什麼需要Immutable
上一節簡單介紹了什麼是Immutable,本節介紹為什麼需要使用Immutable。
不可變,副作用及突變
我們不鼓勵突然變更對象,因為那通常會打斷時間旅行及bug相關調試,並且在react-redux的connect方法中狀態突變將導致組件性能低下:
- 時間旅行:Redux DevTools開發工具期望應用在重新發起某個歷史action時將僅僅返回一個狀態值,而不改變任何東西,即無副作用。突變和非同步操作將導致時間旅行混亂,行為不可預測。
- react-redux:connect方法將檢查mapStateToProps方法返回的props對象是否變更以決定是否需要更新組件。為了提高這個檢查變更的性能,connect方法基於Immutabe狀態對象進行改進,使用淺引用相等性檢查來探測變更。這意味著對對象或數組的直接變更將無法被探測,導致組件無法更新。
在reducer函數中的諸如生成唯一ID或時間戳的其他副作用也會導致應用狀態不可預測,難以調試和測試。
若Redux的某一reducer函數返回一個可以突變的狀態對象,意味著我們不能追蹤,預測狀態,這可能導致組件發生多餘的更新,重新渲染或者在需要更新時沒有響應,也會導致難以跟蹤調試bug。Immutable.js能提供一種Immutable方案解決如上提到的問題,同時其豐富的API也足夠支撐我們複雜的開發。
參考
- Why and When to use Immutable
- Why do we need Immutable class
如何使用Immutable
Immutable能給我們的應用提供較大的性能提升,但是我們必須正確的使用它,否則得不償失。目前關於Immutable已經有一些類庫,對於React應用,首選的是Immutable.js。
Immutable.js和React
首先需要明白的是React組件狀態必須是一個原生JavaScript對象,而不能是一個Immutable對象,因為React的setState方法期望接受一個對象然後使用Object.assign方法將其與之前的狀態對象合併。
class Component extends React.Component { Constructor (props) { super(props) this.state = { data: Immutable.Map({ count:0, todos: List() }) } this.handleAddItemClick = this.handleAddItemClick.bind(this) } handleAddItemClick () { this.setState(({data}) => { data: data.update(todos, todos => todos.push(data.get(count))) }) } render () { const data = this.state.data; Return ( <div> <button onclick={this.handleAddItemClick}></button> <ul> {data.get(todos).map(item => <li>Saved: {item}</li> )} </ul> </div> ) }}
- 使用Immutable.js的訪問API訪問state,如get(),getIn();
- 使用Immutable.js的集合操作生成組件子元素:
使用高階函數如map(),reduce()等創建React元素的子元素:
{data.get(todos).map(item =>
<li>Saved:{item}</li>)}- 使用Immutable.js的更新操作API更新state;
this.setState(({data}) => ({
data: data.update(count, v => v + 1)}))或者
this.setState(({data}) => ({
data: data.set(count, data.get(count) + 1)}));參考:
- Immutable as React state
Immutable.js和Redux
React本身是專註於視圖層的一個JavaScript類庫,所以其單獨使用時狀態一般不會過於複雜,所以其和Immutable.js的協作比較簡單,更重要也是我們需要更多關注的地方是其與React應用狀態管理容器的協作,下文就Immutable.js如何高效的與Redux協作進行闡述。
我們在Redux中講狀態(state)主要是指應用狀態,而不是組件狀態。
REDUX-IMMUTABLE
原始Redux的combineReducers方法期望接受原生JavaScript對象並且它把state作為原生對象處理,所以當我們使用createStore方法並且接受一個Immutable對象作應用初始狀態時,reducer將會返回一個錯誤,源代碼如下:
if (!isPlainObject(inputState)) { return ( `The ${argumentName} has unexpected type of "` + ({}).toString.call(inputState).match(/s([a-z|A-Z]+)/)[1] + ".Expected argument to be an object with the following + `keys:"${reducerKeys.join(", ")}"` ) }
如上表明,原始類型reducer接受的state參數應該是一個原生JavaScript對象,我們需要對combineReducers其進行增強,以使其能處理Immutable對象,redux-immutable 即是用來創建一個可以和Immutable.js協作的Redux combineReducers。
const StateRecord = Immutable.Record({ foo: bar });const rootReducer = combineReducers({ first: firstReducer}, StateRecord);
react-router-redux
如果在項目中使用了react-router-redux類庫,那麼我們需要知道routeReducer不能處理Immutable,我們需要自定義一個新的reducer:
import Immutable from immutable;import { LOCATION_CHANGE } from react-router-redux;const initialState = Immutable.fromJS({ locationBeforeTransitions: null});export default (state = initialState, action) => { if (action.type === LOCATION_CHANGE) { return state.set(locationBeforeTransitions, action.payload); } return state; };
當我們使用syncHistoryWithStore方法連接history對象和store時,需要將routing負載轉換成一個JavaScript對象,如下傳遞一個selectLocationState參數給syncHistoryWithStore方法:
import { browserHistory } from react-router;import { syncHistoryWithStore } from react-router-redux;const history = syncHistoryWithStore(browserHistory, store, { selectLocationState (state) { return state.get(routing).toJS(); }});
IMMUTABLE.JS與REDUX實踐
當使用Immutable.js和Redux協作開發時,可以從如下幾方面思考我們的實踐。
JavaScript對象轉換為Immutable對象
- 不要在Immutable對象中混用原生JavaScript對象;
- 當在Immutable對象內添加JavaScript對象時,首先使用fromJS()方法將JavaScript對象轉換為Immutable對象,然後使用update(),merge(),set()等更新API對Immutable對象進行更新操作;
// avoid const newObj = { key: value }
const newState = state.setIn([prop1], newObj)// newObj has been added as a plain JavaScript object, NOT as an Immutable.JS Map // recommended const newObj = { key: value }const newState = state.setIn([prop1], fromJS(newObj))Immutable與Redux state tree
- 使用Immutable對象表示完整的Redux狀態樹;
對於一個Redux應用,完整的狀態樹應該由一個Immutable對象表示,而沒有原生JavaScript對象。
- 使用fromJS()方法創建狀態樹
狀態樹對象可以是一個Immutable.Record或者任何其他的實現了get,set,withMutations方法的Immutable集合的實例。
- 使用redux-immutable庫調整combineReducers方法使其能處理Immutable。
Immutable與Redux組件
當使用Redux作React應用狀態管理容器時,我們通常將組件分為容器組件和展示型組件,Immutable與Redux組件的實踐也主要圍繞這兩者。
- 除了在展示型組件內,其他地方一律使用Immutable方式操作狀態對象;
為了保證應用性能,在容器組件,選擇器(selectors),reducer函數,action創建函數,sagas和thunks函數內等所有地方均使用Immutable,但是不在展示型組件內使用。
- 在容器組件內使用Immutable
容器組件可以使用react-redux提供的connect方法訪問redux的store,所以我們需要保證選擇器(selectors)總是返回Immutable對象,否則,將會導致不必要的重新渲染。另外,我們可以使用諸如reselect的第三方庫緩存選擇器(selectors)以提高部分情景下的性能。
Immutable對象轉換為JavaScript對象
toJS()方法功能就是把一個Immutable對象轉換為一個JavaScript對象,而我們通常儘可能將Immutable對象轉換為JavaScript對象這一操作放在容器組件中,這也與容器組件的宗旨吻合。另外toJS方法性能極低,應該盡量限制該方法的使用,如在mapStateToProps方法和展示型組件內。
- 絕對不要在mapStateToProps方法內使用toJS()方法
toJS()方法每次會調用時都是返回一個原生JavaScript對象,如果在mapStateToProps方法內使用toJS()方法,則每次狀態樹(Immutable對象)變更時,無論該toJS()方法返回的JavaScript對象是否實際發生改變,組件都會認為該對象發生變更,從而導致不必要的重新渲染。
- 絕對不要在展示型組件內使用toJS()方法
如果傳遞給某組件一個Immuatble對象類型的prop,則該組件的渲染取決於該Immutable對象,這將給組件的重用,測試和重構帶來更多困難。
- 當容器組件將Immutable類型的屬性(props)傳入展示型組件時,需使用高階組件(HOC)將其轉換為原生JavaScript對象。
該高階組件定義如下:
import React from react import { Iterable } from immutable export const toJS = WrappedComponent => wrappedComponentProps => {
const KEY = 0 const VALUE = 1 const propsJS = Object.entries(wrappedComponentProps) .reduce((newProps, wrappedComponentProp) => {newProps[wrappedComponentProp[KEY]] = Iterable.isIterable(wrappedComponentProp[VALUE]) ? wrappedComponentProp[VALUE].toJS() : wrappedComponentProp[VALUE]
return newProps}, {})return <WrappedComponent {...propsJS} />}該高階組件內,首先使用Object.entries方法遍歷傳入組件的props,然後使用toJS()方法將該組件內Immutable類型的prop轉換為JavaScript對象,該高階組件通常可以在容器組件內使用,使用方式如下:
import { connect } from react-redux import { toJS } from ./to-js import DumbComponent from ./dumb.component const mapStateToProps = state => {
return { // obj is an Immutable object in Smart Component, but it』s converted to a plain // JavaScript object by toJS, and so passed to DumbComponent as a pure JavaScript // object. Because it』s still an Immutable.JS object here in mapStateToProps, though, // there is no issue with errant re-renderings. obj:getImmutableObjectFromStateTree(state) }}
export default connect(mapStateToProps)(toJS(DumbComponent))這類高階組件不會造成過多的性能下降,因為高階組件只在被連接組件(通常即展示型組件)屬性變更時才會被再次調用。你也許會問既然在高階組件內使用toJS()方法必然會造成一定的性能下降,為什麼不在展示型組件內也保持使用Immutable對象呢?事實上,相對於高階組件內使用toJS()方法的這一點性能損失而言,避免Immutable滲透入展示型組件帶來的可維護性,可重用性及可測試性是我們更應該看重的。
參考
- Immutable.js Best practices
Immutable.js與reselect
RESELECT
使用Redux管理React應用狀態時,mapStateToProps方法作為從Redux Store上獲取數據過程中的重要一環,它一定不能有性能缺陷,它本身是一個函數,通過計算返回一個對象,這個計算過程通常是基於Redux Store狀態樹進行的,而很明顯的Redux狀態樹越複雜,這個計算過程可能就越耗時,我們應該要能夠儘可能減少這個計算過程,比如重複在相同狀態下渲染組件,多次的計算過程顯然是多餘的,我們是否可以緩存該結果呢?這個問題的解決者就是reselect,它可以提高應用獲取數據的性能。
reselect的原理是,只要相關狀態不變,即直接使用上一次的緩存結果。
選擇器
reselect通過創建選擇器(selectors),該函數接受一個state參數,然後返回我們需要在mapStateToProps方法內返回對象的某一個數據項,一個選擇器的處理可以分為兩個步驟:
- 接受state參數,根據我們提供的映射函數數組分別進行計算,如果返回結果和上次第一步的計算結果一致,說明命中緩存,則不進行第二步計算,直接返回上次第二步的計算結果,否則繼續第二步計算。第一步的結果比較,通常僅僅是===相等性檢查,性能是足夠的。
- 根據第一步返回的結果,計算,返回最終結果。
以TODO為例,有如下選擇器函數:
import { createSelector } from reselect import { FilterTypes } from ../constants export const selectFilterTodos = createSelector(
[getTodos, getFilters],(todos, filters) => { switch(filters) { case FilterTypes.ALL:return todos;
case FilterTypes.COMPLETED: return todos.filter((todo) => todo.completed) default: return todos }})如上,createSelector方法,接受兩個參數:
- 第一個參數是一個映射函數數組,選擇器處理流程的第一步所處理的數據即為該數組內各函數的返回值,這些返回值也依次作為參數傳入第二步處理函數;
- 第二個參數則是,第二步的具體計算函數,也即緩存結果處理函數,其返回結果也即mapStateToProps方法所需的數據;
然後在mapStateToProps內使用該選擇器函數,接受state參數:
const mapStateToProps = (state) => {
return {
todos: selectFilterTodos(state) }}上文中的映射函數,內容如:
const getTodos = (state) => {state.todos}
const getFilter = (state) => {state.filter}Immutable概念數據
另外需要注意的是,傳入createSelector的映射函數返回的狀態應該是不可變的,因為默認緩存命中檢測函數使用引用檢查,如果使用JavaScript對象,僅改變該對象的某一屬性,引用檢測是無法檢測到屬性變更的,這將導致組件無法響應更新。在緩存結果處理函數內執行如下代碼,是不行的:
todos.map(todo => { todo.completed = !areAllMarked return todo})
這種突然性的改變某一狀態對象後,其差異檢測無法通過,將命中緩存,無法更新,在未使用Immutable.js庫時,應該採用如下這種方式:
todos.map(todo => Object.assign({}, todo, { completed: !areAllMarked}))
總是返回一個新對象,而不影響原對象。
自定義選擇器
前面使用createSelector方法創建的選擇器函數默認緩存間隔是1,只緩存上一次的計算結果,即選擇器處理流程的第一步,僅會將當前計算結果與緊鄰的上一次計算結果對比。
有時候也許我們會想是否可以加大緩存程度呢?比如當前狀態a,變化到狀態b,此時緩存的僅僅是狀態b下的選擇器計算結果,如果狀態再次變為a,比對結果自然是false,依然會執行複雜的計算過程,那我們是否能緩存第一次狀態a下的選擇器計算結果呢?答案就在createSelectorCreator。
defaultMemoize
defaultMemoize(func, equalityCheck = defaultEqualityCheck)
defaultMemoize將緩存傳遞的第一個函數參數func的返回結果,該函數是使用createSelector創建選擇器時傳入的緩存結果處理函數,其默認緩存度為1。
equalityCheck是創建的選擇器使用的緩存命中檢測函數,默認函數代碼如:
function defaultEqualityCheck(currentVal, previousVal) { return currentVal === previousVal}
只是簡單的進行引用檢查。
createSelectorCreator
createSelectorCreator方法支持我們創建一個自定義的createSelector函數,並且支持我們傳入自定義的緩存計算函數,覆蓋默認的defaultMemoize函數,定義格式如下:
createSelectorCreator(memoize, ...memoizeOptions)
- memoize參數是一個緩存函數,用以替代defaultMemoize,該函數接受的第一個參數就是創建選擇器時傳入的緩存結果處理函數;
- …memoizeOptions是0或多個配置對象,將傳遞給memoize緩存函數作為後續參數,如可以傳遞一個自定義緩存檢測函數覆蓋defaultEqualityCheck;
// 使用lodash.isEqual覆蓋默認的『===』引用等值檢測import isEqual from lodash.isEqualimport { createSelectorCreator, defaultMemoize } from reselect// 自定義選擇器創建函數const customSelectorCreator = createSelectorCreator( customMemoize, // 自定義緩存函數,也可以直接使用defaultMemoize isEqual, // 配置項 option2 // 配置項)// 自定義選擇器const customSelector = customSelectorCreator( input1, // 映射函數 input2, // 映射函數 resultFunc // 緩存結果處理函數)// 調用選擇器const mapStateToProps = (state) => { todos: customSelector(state) }
在自定義選擇器函數內部,會執行緩存函數:
customMemoize(resultFunc, isEqual, option2)
結合IMMUTABLE.JS
如上文為例,reselect是內在需要使用Immutable概念數據的,當我們把整個Redux狀態樹Immutable化以後,需要進行一些修改。
修改映射函數:
const getTodos = (state) => {state.get(todos)}const getFilter = (state) => {state.get(filter)}
特別需要注意的是在選擇器第二步處理函數內,如果涉及Immutable操作,也需要額外修改成Immutable對應方式。
Immutable實踐中的問題
無論什麼情況,都不存在絕對完美的事物或者技術,使用Immutable.js也必然會帶來一些問題,我們能做的則是盡量避免或者盡最大可能的分化這些問題,而可以更多的去發揚該技術帶來的優勢,使用Immutable.js最常見的問題如下。
- 很難進行內部協作
Immutable對象和JavaScript對象之間存在的巨大差異,使得兩者之間的協作通常較麻煩,而這也正是許多問題的源頭。
- 使用Immutable.js後我們不再能使用點號和中括弧的方式訪問對象屬性,而只能使用其提供的get,getIn等API方式;
- 不再能使用ES6提供的解構和展開操作符;
- 和第三方庫協作困難,如lodash和JQuery等。
- 滲透整個代碼庫
Immutable代碼將滲透入整個項目,這種對於外部類庫的強依賴會給項目的後期帶來很大約束,之後如果想移除或者替換Immutable是很困難的。
- 不適合經常變更的簡單狀態對象
Immutable和複雜的數據使用時有很大的性能提升,但是對於簡單的經常變更的數據,它的表現並不好。
- 切斷對象引用將導致性能低下
Immutable最大的優勢是它的淺比較可以極大提高性能,當我們多次使用toJS方法時,儘管對象實際沒有變更,但是它們之間的等值檢查不能通過,將導致重新渲染。更重要的是如果我們在mapStateToProps方法內使用toJS將極大破壞組件性能,如果真的需要,我們應該使用前面介紹的高階組件方式轉換。
- 難以調試
當我們審查一個Immutable對象時,瀏覽器會列印出Immutable.js的整個嵌套結構,而我們實際需要的只是其中小一部分,這導致我們調試較困難,可以使用Immutable.js Object Formatter瀏覽器插件解決。
推薦閱讀:
※redux到mobx到dob……前端發展好快?
※React-Redux源碼分析
※如何在非 React 項目中使用 Redux
※重用 Redux 中的 reducer
※集成 React 和 Datatables - 並沒有宣傳的那麼難