Immutable.js與React,Redux及reselect的實踐

前端魔法師發表於2017-09-15

歡迎訪問我的部落格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// false
object === object2 // false複製程式碼

相等性檢查將包括兩個部分:

  1. 值檢查
  2. 引用檢查

引用檢查

JavaScript的物件是一個非常複雜的資料結構,它的鍵可以指向任意值,包括object。JavaScript建立的物件將儲存在計算機記憶體中(對應一個實體地址),然後它返回一個引用,JavaScript引擎通過該引用可以訪問該物件,該引用賦值給某個變數後,我們便可以通過該變數以引用的方式操作該物件。引用檢查即檢查兩個物件的引用地址是否相同。

值檢查

層層迴圈檢查物件各屬性值是否相同。

React重新渲染

React通過對元件屬性(props)和狀態(state)進行變更檢查以決定是否更新並重新渲染該元件,若元件狀態太過龐大,元件效能就會下降,因為物件越複雜,其相等性檢查就會越慢。

  1. 對於巢狀物件,必須迭代層層進行檢查判斷,耗費時間過長;
  2. 若僅修改物件的屬性,其引用保持不變,相等性檢查中的引用檢查結果不變;

Immutable提供一直簡單快捷的方式以判斷物件是否變更,對於React元件更新和重新渲染效能可以有較大幫助。

Immutable資料

Never mutated, instead copy it and then make change.

絕對不要突然修改物件,首先複製然後修改複製物件,再返回這個新物件,保持原物件不變。

Immutable物件和原生JavaScript物件的主要差異可以概括為以下兩點:

  1. 持久化資料結構(Persistent data structures)
  2. 結構共享(Structures sharing Trie

持久化資料結構

持久資料結構主張所有操作都返回該資料結構的更新副本,並保持原有結構不變,而不是改變原來的結構。通常利用Trie構建它不可變的永續性資料結構,它的整體結構可以看作一棵樹,一個樹節點可以對應代表物件某一個屬性,節點值即屬性值。

結構共享

一旦建立一個Immutable Trie型物件,我們可以把該Trie型物件想象成如下一棵樹,在之後的物件變更儘可能的重用樹節點:

Structures sharing
Structures sharing

當我們要更新一個Immutable物件的屬性值時,就是對應著需要重構該Trie樹中的某一個節點,對於Trie樹,我們修改某一節點只需要重構該節點及受其影響的節點,即其祖先節點,如上圖中的四個綠色節點,而其他節點可以完全重用。

參考

  1. Immutable Persistent Data Structures
  2. Trie

為什麼需要Immutable

上一節簡單介紹了什麼是Immutable,本節介紹為什麼需要使用Immutable。

不可變,副作用及突變

我們不鼓勵突然變更物件,因為那通常會打斷時間旅行及bug相關除錯,並且在react-redux的connect方法中狀態突變將導致元件效能低下:

  1. 時間旅行:Redux DevTools開發工具期望應用在重新發起某個歷史action時將僅僅返回一個狀態值,而不改變任何東西,即無副作用。突變和非同步操作將導致時間旅行混亂,行為不可預測。
  2. react-redux:connect方法將檢查mapStateToProps方法返回的props物件是否變更以決定是否需要更新元件。為了提高這個檢查變更的效能,connect方法基於Immutabe狀態物件進行改進,使用淺引用相等性檢查來探測變更。這意味著對物件或陣列的直接變更將無法被探測,導致元件無法更新。

在reducer函式中的諸如生成唯一ID或時間戳的其他副作用也會導致應用狀態不可預測,難以除錯和測試。

若Redux的某一reducer函式返回一個可以突變的狀態物件,意味著我們不能追蹤,預測狀態,這可能導致元件發生多餘的更新,重新渲染或者在需要更新時沒有響應,也會導致難以跟蹤除錯bug。Immutable.js能提供一種Immutable方案解決如上提到的問題,同時其豐富的API也足夠支撐我們複雜的開發。

參考

  1. Why and When to use Immutable
  2. 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>
        )
    }
}複製程式碼
  1. 使用Immutable.js的訪問API訪問state,如get(),getIn();

  2. 使用Immutable.js的集合操作生成元件子元素:

    使用高階函式如map()reduce()等建立React元素的子元素:

    {data.get('todos').map(item =>
        <li>Saved:
        {item}</li>
    )}複製程式碼
  3. 使用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)
    }));複製程式碼

參考:

  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物件
  1. 不要在Immutable物件中混用原生JavaScript物件;

  2. 當在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
  1. 使用Immutable物件表示完整的Redux狀態樹;

    對於一個Redux應用,完整的狀態樹應該由一個Immutable物件表示,而沒有原生JavaScript物件。

  2. 使用fromJS()方法建立狀態樹

    狀態樹物件可以是一個Immutable.Record或者任何其他的實現了get,set,withMutations方法的Immutable集合的例項。

  3. 使用redux-immutable庫調整combineReducers方法使其能處理Immutable。

Immutable與Redux元件

當使用Redux作React應用狀態管理容器時,我們通常將元件分為容器元件和展示型元件,Immutable與Redux元件的實踐也主要圍繞這兩者。

  1. 除了在展示型元件內,其他地方一律使用Immutable方式操作狀態物件;

    為了保證應用效能,在容器元件,選擇器(selectors),reducer函式,action建立函式,sagas和thunks函式內等所有地方均使用Immutable,但是不在展示型元件內使用。

  2. 在容器元件內使用Immutable

    容器元件可以使用react-redux提供的connect方法訪問redux的store,所以我們需要保證選擇器(selectors)總是返回Immutable物件,否則,將會導致不必要的重新渲染。另外,我們可以使用諸如reselect的第三方庫快取選擇器(selectors)以提高部分情景下的效能。

Immutable物件轉換為JavaScript物件

toJS()方法功能就是把一個Immutable物件轉換為一個JavaScript物件,而我們通常儘可能將Immutable物件轉換為JavaScript物件這一操作放在容器元件中,這也與容器元件的宗旨吻合。另外toJS方法效能極低,應該儘量限制該方法的使用,如在mapStateToProps方法和展示型元件內。

  1. 絕對不要在mapStateToProps方法內使用toJS()方法

    toJS()方法每次會呼叫時都是返回一個原生JavaScript物件,如果在mapStateToProps方法內使用toJS()方法,則每次狀態樹(Immutable物件)變更時,無論該toJS()方法返回的JavaScript物件是否實際發生改變,元件都會認為該物件發生變更,從而導致不必要的重新渲染。

  2. 絕對不要在展示型元件內使用toJS()方法

    如果傳遞給某元件一個Immuatble物件型別的prop,則該元件的渲染取決於該Immutable物件,這將給元件的重用,測試和重構帶來更多困難。

  3. 當容器元件將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滲透入展示型元件帶來的可維護性,可重用性及可測試性是我們更應該看重的。

參考
  1. Immutable.js Best practices

Immutable.js與reselect

reselect

使用Redux管理React應用狀態時,mapStateToProps方法作為從Redux Store上獲取資料過程中的重要一環,它一定不能有效能缺陷,它本身是一個函式,通過計算返回一個物件,這個計算過程通常是基於Redux Store狀態樹進行的,而很明顯的Redux狀態樹越複雜,這個計算過程可能就越耗時,我們應該要能夠儘可能減少這個計算過程,比如重複在相同狀態下渲染元件,多次的計算過程顯然是多餘的,我們是否可以快取該結果呢?這個問題的解決者就是reselect,它可以提高應用獲取資料的效能。

reselect的原理是,只要相關狀態不變,即直接使用上一次的快取結果。

選擇器

reselect通過建立選擇器(selectors),該函式接受一個state引數,然後返回我們需要在mapStateToProps方法內返回物件的某一個資料項,一個選擇器的處理可以分為兩個步驟:

  1. 接受state引數,根據我們提供的對映函式陣列分別進行計算,如果返回結果和上次第一步的計算結果一致,說明命中快取,則不進行第二步計算,直接返回上次第二步的計算結果,否則繼續第二步計算。第一步的結果比較,通常僅僅是===相等性檢查,效能是足夠的。

  2. 根據第一步返回的結果,計算,返回最終結果。

    以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方法,接受兩個引數:

    1. 第一個引數是一個對映函式陣列,選擇器處理流程的第一步所處理的資料即為該陣列內各函式的返回值,這些返回值也依次作為引數傳入第二步處理函式;
    2. 第二個引數則是,第二步的具體計算函式,也即快取結果處理函式,其返回結果也即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)複製程式碼
  1. memoize引數是一個快取函式,用以替代defaultMemoize,該函式接受的第一個引數就是建立選擇器時傳入的快取結果處理函式;
  2. …memoizeOptions是0或多個配置物件,將傳遞給memoize快取函式作為後續引數,如可以傳遞一個自定義快取檢測函式覆蓋defaultEqualityCheck;
// 使用lodash.isEqual覆蓋預設的‘===’引用等值檢測
import isEqual from 'lodash.isEqual'
import { 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最常見的問題如下。

  1. 很難進行內部協作

    Immutable物件和JavaScript物件之間存在的巨大差異,使得兩者之間的協作通常較麻煩,而這也正是許多問題的源頭。

    1. 使用Immutable.js後我們不再能使用點號和中括號的方式訪問物件屬性,而只能使用其提供的get,getIn等API方式;
    2. 不再能使用ES6提供的解構和展開操作符;
    3. 和第三方庫協作困難,如lodash和JQuery等。
  2. 滲透整個程式碼庫

    Immutable程式碼將滲透入整個專案,這種對於外部類庫的強依賴會給專案的後期帶來很大約束,之後如果想移除或者替換Immutable是很困難的。

  3. 不適合經常變更的簡單狀態物件

    Immutable和複雜的資料使用時有很大的效能提升,但是對於簡單的經常變更的資料,它的表現並不好。

  4. 切斷物件引用將導致效能低下

    Immutable最大的優勢是它的淺比較可以極大提高效能,當我們多次使用toJS方法時,儘管物件實際沒有變更,但是它們之間的等值檢查不能通過,將導致重新渲染。更重要的是如果我們在mapStateToProps方法內使用toJS將極大破壞元件效能,如果真的需要,我們應該使用前面介紹的高階元件方式轉換。

  5. 難以除錯

    當我們審查一個Immutable物件時,瀏覽器會列印出Immutable.js的整個巢狀結構,而我們實際需要的只是其中小一部分,這導致我們除錯較困難,可以使用Immutable.js Object Formatter瀏覽器外掛解決。

相關文章