Immutable.js 以及在 react+redux 專案中的實踐

美團點評點餐發表於2017-06-22

作者介紹:鄒弓一,美團點評前端工程師,5年 Web 前端開發經驗,現在是美團點評點餐團隊的一員。

前言

  本文主要介紹facebook推出的一個類庫immutable.js,以及如何將immutable.js整合到我們團隊現有的react+redux架構的移動端專案中。

本文較長(5000字左右),建議閱讀時間: 20 min

通過閱讀本文,你可以學習到:

  • 什麼是immutable.js,它的出現能解決什麼問題
  • immutable.js的特性以及使用api
  • 在一個redux+react的專案中,引入immutable.js能帶來什麼提升
  • 如何整合immutable.js到react+redux中
  • 整合前後的資料對比
  • immutabe.js使用過程中的一些注意點

目錄

  • 一. immutable.js
    • 1.1 原生js引用型別的坑
    • 1.2 immutable.js介紹
      • 1.2.1 Persistent data structure (持久化資料結構)
      • 1.2.2 structural sharing (結構共享)
      • 1.2.3 support lazy operation (惰性操作)
    • 1.3 常用api介紹
    • 1.4 immutable.js的優缺點
  • 二. 在react+redux中整合immutable.js實踐
    • 2.1 點餐H5專案引入immutable.js前的現狀
    • 2.2 如何將immutableJS整合到一個react+redux專案中
      • 2.2.1 明確整合方案,邊界界定
      • 2.2.2 具體整合程式碼實現方法
    • 2.3 點餐H5專案優化前後對比
  • 三. immutable.js使用過程中的一些注意點
  • 四. 總結

一. immutable.js

1.1 原生js引用型別的坑

先考慮如下兩個場景:

// 場景一
var obj = {a:1, b:{c:2}};
func(obj);
console.log(obj)  //輸出什麼??

// 場景二
var obj = ={a:1};
var obj2 = obj;
obj2.a = 2;
console.log(obj.a);  // 2
console.log(obj2.a);  // 2複製程式碼

  上面兩個場景相信大家平日裡開發過程中非常常見,具體原因相信大家也都知道了,這邊不展開細說了,通常這類問題的解決方案是通過淺拷貝或者深拷貝複製一個新物件,從而使得新物件與舊物件引用地址不同。
  在js中,引用型別的資料,優點在於頻繁的運算元據都是在原物件的基礎上修改,不會建立新物件,從而可以有效的利用記憶體,不會浪費記憶體,這種特性稱為mutable(可變),但恰恰它的優點也是它的缺點,太過於靈活多變在複雜資料的場景下也造成了它的不可控性,假設一個物件在多處用到,在某一處不小心修改了資料,其他地方很難預見到資料是如何改變的,針對這種問題的解決方法,一般就像剛才的例子,會想複製一個新物件,再在新物件上做修改,這無疑會造成更多的效能問題以及記憶體浪費。
  為了解決這種問題,出現了immutable物件,每次修改immutable物件都會建立一個新的不可變物件,而老的物件不會改變。

1.2 immutable.js介紹

  現今,實現了immutable資料結構的js類庫有好多,immutable.js就是其中比較主流的類庫之一。

Immutable.js出自Facebook,是最流行的不可變資料結構的實現之一。它從頭開始實現了完全的持久化資料結構,通過使用像tries這樣的先進技術來實現結構共享。所有的更新操作都會返回新的值,但是在內部結構是共享的,來減少記憶體佔用(和垃圾回收的失效)。

immutable.js主要有三大特性:

  • Persistent data structure (持久化資料結構)
  • structural sharing (結構共享)
  • support lazy operation (惰性操作)

下面我們來一一具體介紹下這三個特性:

1.2.1 Persistent data structure (持久化資料結構)

  一般聽到持久化,在程式設計中第一反應應該是,資料存在某個地方,需要用到的時候就能從這個地方拿出來直接使用
  但這裡說的持久化是另一個意思,用來描述一種資料結構,一般函數語言程式設計中非常常見,指一個資料,在被修改時,仍然能夠保持修改前的狀態,從本質來說,這種資料型別就是不可變型別,也就是immutable
  immutable.js提供了十餘種不可變的型別(List,Map,Set,Seq,Collection,Range等)
  到這,有些同學可能會覺得,這和之前講的拷貝有什麼區別,也是每次都建立一個新物件,開銷一樣很大。ok,那接下來第二個特性會為你揭開疑惑。

1.2.2 structural sharing (結構共享)

Immutable.js 以及在 react+redux 專案中的實踐
(圖片來自網路)

immutable使用先進的tries(字典樹)技術實現結構共享來解決效能問題,當我們對一個Immutable物件進行操作的時候,ImmutableJS會只clone該節點以及它的祖先節點,其他保持不變,這樣可以共享相同的部分,大大提高效能。

這邊岔開介紹一下tries(字典樹),我們來看一個例子

Immutable.js 以及在 react+redux 專案中的實踐

Immutable.js 以及在 react+redux 專案中的實踐

Immutable.js 以及在 react+redux 專案中的實踐

(圖片來自網路)
  圖1就是一個字典樹結構object物件,頂端是root節點,每個子節點都有一個唯一標示(在immutable.js中就是hashcode)
  假設我們現在取data.in的值,根據標記i和n的路徑.可以找到包含5的節點.,可知data.in=5, 完全不需要遍歷整個物件
  那麼,現在我們要把data.tea從3修改成14,怎麼做呢?
  可以看到圖2綠色部分,不需要去遍歷整棵樹,只要從root開始找就行
  實際使用時,可以建立一個新的引用,如圖3,data.tea建一個新的節點,其他節點和老的物件共享,而老的物件還是保持不變
  由於這個特性,比較兩個物件時,只要他們的hashcode是相同的,他們的值就是一樣的,這樣可以避免深度遍歷

1.2.3 support lazy operation (惰性操作)

  • 惰性操作 Seq
  • 特徵1:Immutable (不可變)
  • 特徵2:lazy(惰性,延遲)

這個特性非常的有趣,這裡的lazy指的是什麼?很難用語言來描述,我們看一個demo,看完你就明白了

Immutable.js 以及在 react+redux 專案中的實踐

  這段程式碼的意思就是,陣列先取奇數,然後再對基數進行平方操作,然後在console.log第2個數,同樣的程式碼,用immutable的seq物件來實現,filter只執行了3次,但原生執行了8次。
  其實原理就是,用seq建立的物件,其實程式碼塊沒有被執行,只是被宣告瞭,程式碼在get(1)的時候才會實際被執行,取到index=1的數之後,後面的就不會再執行了,所以在filter時,第三次就取到了要的數,從4-8都不會再執行
  想想,如果在實際業務中,資料量非常大,如在我們點餐業務中,商戶的選單列表可能有幾百道菜,一個array的長度是幾百,要操作這樣一個array,如果應用惰性操作的特性,會節省非常多的效能

1.3 常用api介紹

//Map()  原生object轉Map物件 (只會轉換第一層,注意和fromJS區別)
immutable.Map({name:'danny', age:18})

//List()  原生array轉List物件 (只會轉換第一層,注意和fromJS區別)
immutable.List([1,2,3,4,5])

//fromJS()   原生js轉immutable物件  (深度轉換,會將內部巢狀的物件和陣列全部轉成immutable)
immutable.fromJS([1,2,3,4,5])    //將原生array  --> List
immutable.fromJS({name:'danny', age:18})   //將原生object  --> Map

//toJS()  immutable物件轉原生js  (深度轉換,會將內部巢狀的Map和List全部轉換成原生js)
immutableData.toJS();

//檢視List或者map大小  
immutableData.size  或者 immutableData.count()

// is()   判斷兩個immutable物件是否相等
immutable.is(imA, imB);

//merge()  物件合併
var imA = immutable.fromJS({a:1,b:2});
var imA = immutable.fromJS({c:3});
var imC = imA.merge(imB);
console.log(imC.toJS())  //{a:1,b:2,c:3}

//增刪改查(所有操作都會返回新的值,不會修改原來值)
var immutableData = immutable.fromJS({
    a:1,
    b:2,
    c:{
        d:3
    }
});
var data1 = immutableData.get('a') //  data1 = 1  
var data2 = immutableData.getIn(['c', 'd']) // data2 = 3   getIn用於深層結構訪問
var data3 = immutableData.set('a' , 2);   // data3中的 a = 2
var data4 = immutableData.setIn(['c', 'd'], 4);   //data4中的 d = 4
var data5 = immutableData.update('a',function(x){return x+4})   //data5中的 a = 5
var data6 = immutableData.updateIn(['c', 'd'],function(x){return x+4})   //data6中的 d = 7
var data7 = immutableData.delete('a')   //data7中的 a 不存在
var data8 = immutableData.deleteIn(['c', 'd'])   //data8中的 d 不存在複製程式碼

上面只列舉了部分常用方法,具體查閱官網api:facebook.github.io/immutable-j…
immutablejs還有很多類似underscore語法糖,使用immutable.js之後完全可以在專案中去除lodash或者underscore之類的工具庫。

1.4 immutable.js的優缺點

優點:

  • 降低mutable帶來的複雜度
  • 節省記憶體
  • 歷史追溯性(時間旅行):時間旅行指的是,每時每刻的值都被保留了,想回退到哪一步只要簡單的將資料取出就行,想一下如果現在頁面有個撤銷的操作,撤銷前的資料被保留了,只需要取出就行,這個特性在redux或者flux中特別有用
  • 擁抱函數語言程式設計:immutable本來就是函數語言程式設計的概念,純函數語言程式設計的特點就是,只要輸入一致,輸出必然一致,相比於物件導向,這樣開發元件和除錯更方便

缺點:

  • 需要重新學習api
  • 資源包大小增加(原始碼5000行左右)
  • 容易與原生物件混淆:由於api與原生不同,混用的話容易出錯。

二. 在react+redux中整合immutable.js實踐

  前面介紹了這麼多,其實是想引出這塊重點,這章節會結合點評點餐團隊在實際專案中的實踐,給出使用immutable.js前後對react+redux專案的效能提升

2.1 點餐H5專案引入immutable.js前的現狀

  目前專案使用react+redux,由於專案的不斷迭代以及需求複雜度的提高,redux中維護的state結構日漸龐大,已經不是一個簡單的平鋪資料了,如選單頁state已經會出現三四層的object以及array巢狀,我們知道,JS中的object與array是引用型別,在不斷的操作過程中,state經過多次的action改變之後, 原本複雜state已經變得不可控,結果就是導致了一次state變化牽動了許多自身狀態沒有發生改動的component去re-render。如下圖

Immutable.js 以及在 react+redux 專案中的實踐

  這裡推薦一下react的效能指標工具react-addons-perf
  如果你沒有使用這個工具看之前,別人問你,圖中這個簡單的堂食/外帶的button的變化會引起哪些component去re-render,你可能會回答只有就餐方式這個component。
  但當你真正使用react-addons-perf去檢視之後你會發現,WTF??!一次操作竟然導致了這麼多沒任何關係的component重新渲染了??
  什麼原因??

shouldComponentUpdate

shouldComponentUpdate (nextProps, nextState) {
   return nextProps.id !== this.props.id;
};複製程式碼

  相信接觸過react開發的同學都知道,react有個重要的效能優化的點就是shouldComponentUpdate,shouldComponentUpdate返回true程式碼該元件要re-render,false則不重新渲染
  那簡單的場景可以直接使用==去判斷this.props和nextProps是否相等,但當props是一個複雜的結構時,==肯定是沒用的
  網上隨便查一下就會發現shallowCompare這個東西,我們來試一下
使用shallowCompare的例子:

Immutable.js 以及在 react+redux 專案中的實踐

可以看到,其實2個物件的count是不相等的,但shallowCompare返回的還是true
原因:
  shallowCompare只是進行了物件的頂層節點比較,也就是淺比較,上圖中的props由於結構比較複雜,在深層的物件中有count不一樣,所以這種情況無法通過shallowCompare處理。
shallowEqual原始碼:

function shallowEqual(objA, objB) {
  if (is(objA, objB)) {
    return true;
  }

  if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) {
    return false;
  }

  var keysA = Object.keys(objA);
  var keysB = Object.keys(objB);

  if (keysA.length !== keysB.length) {
    return false;
  }
//這裡只比較了物件A和B第一層是否相等,當物件過深時,無法返回正確結果
  // Test for A's keys different from B.
  for (var i = 0; i < keysA.length; i++) {
    if (!hasOwnProperty.call(objB, keysA[i]) || !is(objA[keysA[i]], objB[keysA[i]])) {
      return false;
    }
  }

  return true;
}複製程式碼

  這裡,我們肯定不可能每次比較都是用深比較,去遍歷所有的結構,這樣帶來的效能代價是巨大的,剛才我們說到immutable.js有個特性是引用比較(hashcode),這個特性就完美契合這邊的場景

2.2 如何將immutableJS整合到一個react+redux專案中

2.2.1 明確整合方案,邊界界定

  首先,我們有必要來劃分一下邊界,哪些資料需要使用不可變資料,哪些資料要使用原生js資料結構,哪些地方需要做互相轉換

  • 在redux中,全域性state必須是immutable的,這點毋庸置疑是我們使用immutable來優化redux的核心
  • 元件props是通過redux的connect從state中獲得的,並且引入immutableJS的另一個目的是減少元件shouldComponentUpdate中不必要渲染,shouldComponentUpdate中比對的是props,如果props是原生JS就失去了優化的意義
  • 元件內部state如果需要提交到store的,必須是immutable,否則不強制
  • view提交到action中的資料必須是immutable
  • Action提交到reducer中的資料必須是immutable
  • reducer中最終處理state必須是以immutable的形式處理並返回
  • 與服務端ajax互動中返回的callback統一封裝,第一時間轉換成immutable資料

  從上面這些點可以看出,幾乎整個專案都是必須使用immutable的,只有在少數與外部依賴有互動的地方使用了原生js。
  這麼做的目的其實就是為了防止在大型專案中,原生js與immutable混用,導致coder自己都不清楚一個變數中儲存的到底是什麼型別的資料。
  那有人可能會覺得說,在一個全新專案中這樣是可行的,但在一個已有的成熟專案中,要將所有的變數全部改成immutablejs,程式碼的改動量與侵入性非常大,風險也高。那他們會想到,將reducer中的state用fromJS()改成immutable進行state操作,然後再通過toJS()轉成原生js返回出來,這樣不就可以即讓state變得可追溯,又不用去修改reducer以外的程式碼,代價非常的小。

export default function indexReducer(state, action) {
    switch (action.type) {
    case RECEIVE_MENU:
        state = immutable.fromJS(state);   //轉成immutable
        state = state.merge({a:1});
        return state.toJS()    //轉回原生js
    }  
}複製程式碼

兩點問題:

  1. fromJS() 和 toJS() 是深層的互轉immutable物件和原生物件,效能開銷大,儘量不要使用(見下一章節做了具體的對比)
  2. 元件中props和state還是原生js,shouldComponentUpdate仍然無法做利用immutablejs的優勢做深度比較

2.2.2 具體整合程式碼實現方法

redux-immutable

  redux中,第一步肯定利用combineReducers來合併reducer並初始化state,redux自帶的combineReducers只支援state是原生js形式的,所以這裡我們需要使用redux-immutable提供的combineReducers來替換原來的方法

import {combineReducers} from 'redux-immutable';
import dish from './dish';
import menu from './menu';
import cart from './cart';

const rootReducer = combineReducers({
    dish,
    menu,
    cart,
});

export default rootReducer;複製程式碼

  reducer中的initialState肯定也需要初始化成immutable型別

const initialState = Immutable.Map({});
export default function menu(state = initialState, action) {
    switch (action.type) {
    case SET_ERROR:
        return state.set('isError', true);
    }
}複製程式碼

  state成為了immutable型別,那相應的頁面其他檔案都需要做相應的寫法改變

//connect
function mapStateToProps(state) {
    return {
        menuList: state.getIn(['dish', 'list']),  //使用get或者getIn來獲取state中的變數
        CartList: state.getIn(['dish', 'cartList'])
    }
}複製程式碼

  頁面中原來的原生js變數需要改造成immutable型別,不一一列舉了

服務端互動ajax封裝

  前端程式碼使用了immutable,但服務端下發的資料還是json,所以需要統一在ajax處做封裝並且將服務端返回資料轉成immutable

//虛擬碼
$.ajax({
    type: 'get',
    url: 'XXX',
    dataType: 'json',
    success(res){
        res = immutable.fromJS(res || {});
        callback && callback(res);
    },
    error(e) {
        e = immutable.fromJS(e || {});
        callback && callback(e);
    },
});複製程式碼

這樣的話,頁面中統一將ajax返回當做immutable型別來處理,不用擔心混淆

shouldComponentUpdate

  重中之重!之前已經介紹了很多為什麼要用immutable來改造shouldComponentUpdate,這裡就不多說了,直接看怎麼改造
shouldComponentUpdate具體怎麼封裝有很多種辦法,我們這裡選擇了封裝一層component的基類,在基類中去統一處理shouldComponentUpdate,元件中直接繼承基類的方式

//baseComponent.js   component的基類方法

import React from 'react';
import {is} from 'immutable';

class BaseComponent extends React.Component {
    constructor(props, context, updater) {
        super(props, context, updater);
    }

    shouldComponentUpdate(nextProps, nextState) {
        const thisProps = this.props || {};
        const thisState = this.state || {};
        nextState = nextState || {};
        nextProps = nextProps || {};

        if (Object.keys(thisProps).length !== Object.keys(nextProps).length ||
            Object.keys(thisState).length !== Object.keys(nextState).length) {
            return true;
        }

        for (const key in nextProps) {
            if (!is(thisProps[key], nextProps[key])) {
                return true;
            }
        }

        for (const key in nextState) {
            if (!is(thisState[key], nextState[key])) {
                return true;
            }
        }
        return false;
    }
}

export default BaseComponent;複製程式碼

  元件中如果需要使用統一封裝的shouldComponentUpdate,則直接繼承基類

import BaseComponent from './BaseComponent';
class Menu extends BaseComponent {
    constructor() {
        super();
    }
    …………
}複製程式碼

  當然如果元件不想使用封裝的方法,那直接在該元件中重寫shouldComponentUpdate就行了

2.3 點餐H5專案優化前後對比

這邊只是截了幾張圖舉例
優化前搜尋頁:

Immutable.js 以及在 react+redux 專案中的實踐

優化後:
Immutable.js 以及在 react+redux 專案中的實踐

優化前購物車頁:
Immutable.js 以及在 react+redux 專案中的實踐

優化後:
Immutable.js 以及在 react+redux 專案中的實踐

三. immutable.js使用過程中的一些注意點

1.fromJS和toJS會深度轉換資料,隨之帶來的開銷較大,儘可能避免使用,單層資料轉換使用Map()和List()

(做了個簡單的fromJS和Map效能對比,同等條件下,分別用兩種方法處理1000000條資料,可以看到fromJS開銷是Map的4倍)

Immutable.js 以及在 react+redux 專案中的實踐

2.js是弱型別,但Map型別的key必須是string!(看下圖官網說明)

Immutable.js 以及在 react+redux 專案中的實踐

3.所有針對immutable變數的增刪改必須左邊有賦值,因為所有操作都不會改變原來的值,只是生成一個新的變數

//javascript
var arr = [1,2,3,4];
arr.push(5);
console.log(arr) //[1,2,3,4,5]

//immutable
var arr = immutable.fromJS([1,2,3,4])
//錯誤用法
arr.push(5);
console.log(arr) //[1,2,3,4]
//正確用法
arr = arr.push(5);
console.log(arr) //[1,2,3,4,5]複製程式碼

4.引入immutablejs後,不應該再出現物件陣列拷貝的程式碼(如下舉例)

//es6物件複製
var state = Object.assign({}, state, {
    key: value
});

//array複製
var newArr = [].concat([1,2,3])複製程式碼

5. 獲取深層深套物件的值時不需要做每一層級的判空

//javascript
var obj = {a:1}
var res = obj.a.b.c   //error

//immutable
var immutableData=immutable.fromJS({a:1})
var res = immutableData.getIn(['a', 'b', 'c'])  //undefined複製程式碼

6.immutable物件直接可以轉JSON.stringify(),不需要顯式手動呼叫toJS()轉原生

7. 判斷物件是否是空可以直接用size

8.除錯過程中要看一個immutable變數中真實的值,可以chrome中加斷點,在console中使用.toJS()方法來檢視

四. 總結

  總的來說immutable.js的出現解決了許多原生js的痛點,並且自身對效能方面做了許多的優化處理,而且immuable.js作為和react同期推出的一個產品,完美的契合了react+redux的state流處理,redux的宗旨就是單一資料流,可追溯,這兩點恰恰是immutable.js的優勢,自然水到渠成,何樂而不為。
  當然也不是所有使用react+redux的場景都需要使用immutable.js,建議滿足專案足夠大,state結構足夠複雜的原則,小專案可以手動處理shouldComponentUpdate,不建議使用,得不償失。

相關文章