immer.js 簡介及源碼簡析 -- 更簡單,更快速的建立不可變數據類型

更簡單,更快速的建立不可變數據類型前端

做者:張釗git


在 JS 中對象的使用須要格外注意引用問題,斷絕引用的方式常見有深拷貝。可是深拷貝相比較而言比較消耗性能。本文主要簡介 immutable-js 和 immer 兩個處理「不可變數據」的庫,同時簡單分析了 immer 的實現方式,最後經過測試數據,對比總結了 immutable-js 和 immer 的優劣。

JS 裏面的變量類型能夠分爲 基本類型引用類型github

在使用過程當中,引用類型常常會產生一些沒法意識到的反作用,因此在現代 JS 開發過程當中,有經驗的開發者都會在特定位置有意識的寫下斷開引用的不可變數據類型。編程

// 由於引用所帶來的反作用:

var a = [{ val: 1 }]

var b = a.map(item => item.val = 2

// 指望:b 的每個元素的 val 值變爲 2,但最終 a 裏面每一個元素的 val 也變爲了 2

console.log(a[0].val) // 2

從上述例子咱們能夠發現,本意是隻想讓 ​b​ 中的每個元素的值變爲 2 ,但卻無心中改掉了 ​a​ 中每個元素的結果,這固然是不符合預期的。接下來若是某個地方使用到了 ​a​ ,很容易發生一些咱們難以預料而且難以 debug 的 bug (由於它的值在不經意間被改變了)。數組

在發現這樣的問題以後,解決方案也很簡單。通常來講當須要傳遞一個引用類型的變量(例如對象)進一個函數時,咱們可使用 ​Object.assign​ 或者 ​...​ 對對象進行解構,成功斷掉一層的引用。安全

例如上面的問題咱們能夠改用下面的這種寫法:服務器

var a = [{ val: 1 }]

var b = a.map(item => ({ ...item, val: 2 }))

console.log(a[0].val) // 1

console.log(b[0].val) // 2

可是這樣作會有另一個問題,不管是 ​Object.assign​ 仍是 ​...​ 的解構操做,斷掉的引用也只是一層,若是對象嵌套超過一層,這樣作仍是有必定的風險。網絡

// 深層次的對象嵌套,這裏 a 裏面的元素對象下又嵌套了一個 desc 對象`

var a = [{

  val: 1,

  desc: { text: 'a' }

}]

var b = a.map(item => ({ ...item, val: 2 }))

console.log(a === b) // false

console.log(a[0].desc === b[0].desc) // true

b[0].desc.text = 'b';          // 改變 b 中對象元素對象下的內容

console.log(a[0].desc.text); // b (a 中元素的值無心中被改變了)

​a[0].desc === b[0].desc​ 表達式的結果仍爲 true,這說明在程序內部 ​a[0].desc​ 和 ​b[0].desc​ 仍然指向相同的引用。若是後面的代碼一不當心在一個函數內部直接經過 ​b[0].desc​ 進行賦值,就必定會改變具備相同引用的 ​a[0].desc​ 部分的結果。例如上面的例子中,經過「點」直接操做 ​b​ 中的嵌套對象,最終也改變了 ​a​ 裏面的結果。數據結構

因此在這以後,大多數狀況下咱們會考慮 深拷貝 這樣的操做來徹底避免上面遇到的全部問題。深拷貝,顧名思義就是在遍歷過程當中,若是遇到了可能出現引用的數據類型(例如前文中舉例的對象 Object),就會遞歸的徹底建立一個新的類型。ide

// 一個簡單的深拷貝函數,只作了簡單的判斷

// 用戶態這裏輸入的 obj 必定是一個 Plain Object,而且全部 value 也是 Plain Object

function  deepClone(obj) {

  const keys = Object.keys(obj)

  return keys.reduce((memo, current) => {

    const value = obj[current]

    if (typeof value === 'object') {

      // 若是當前結果是一個對象,那咱們就繼續遞歸這個結果

      return {

        ...memo,

        [current]: deepClone(value),

      }
    }
    return {

      ...memo,

      [current]: value,

    }

  }, {})
}

用上面的 deepClone 函數進行簡單測試

var a = {

  val: 1,

  desc: {

    text: 'a'

  },
};

var b = deepClone(a)

b.val = 2

console.log(a.val) // 1

console.log(b.val) // 2

b.desc.text = 'b'

console.log(a.desc.text) // 'a'

console.log(b.desc.text) // 'b'

上面的這個 ​deepClone​ 能夠知足簡單的需求,可是真正在生產工做中,咱們須要考慮很是多的因素。

舉例來講:

  • key 裏面 getter,setter 以及原型鏈上的內容如何處理?
  • value 是一個 Symbol 如何處理?
  • value 是其餘非 Plain Object 如何處理?
  • value 內部出現了一些循環引用如何處理?

由於有太多不肯定因素,因此在真正的工程實踐中,仍是推薦你們使用大型開源項目裏面的工具函數。比較經常使用的爲你們所熟知的就是 ​lodash.cloneDeep​,不管是安全性仍是效果都有所保障。

其實,這種去除引用數據類型反作用的數據的概念咱們稱做 immutable,意爲不可變的數據,其實理解爲不可變關係更爲恰當。每當咱們建立一個被 ​deepClone​ 過的數據,新的數據進行有反作用 (side effect) 的操做都不會影響到以前的數據,這也就是 immutable 的精髓和本質。

這裏的反作用不僅侷限於經過「點」操做對屬性賦值。例如 array 裏面的 ​push​, ​pop​ , ​splice​ 等方法操做都是會改變原來的數組結果,這些操做都算是非 immutable。相比較而言,​slice​ , ​map​ 這類返回操做結果爲一個新數組的形式,就是 immutable 的操做。

然而 deepClone 這種函數雖然斷絕了引用關係實現了 immutable,可是相對來講開銷太大(由於他至關於徹底建立了一個新的對象出來,有時候有些 value 咱們不會進行賦值操做,因此即便保持引用也不要緊)。

因此在 2014 年,facebook 的 immutable-js 橫空出世,即保證了數據間的 immutable ,在運行時判斷數據間的引用狀況,又兼顧了性能。

immutable-js 簡介

immutable-js 使用了另外一套數據結構的 API ,與咱們的常見操做有些許不一樣,它將全部的原生數據類型(Object, Array等)都會轉化成 immutable-js 的內部對象(Map,List 等),而且任何操做最終都會返回一個新的 immutable 的值。

上面的例子使用 immutable-js 就須要這樣改造一下:

const { fromJS } = require('immutable')

const data = {

  val: 1,

  desc: {

    text: 'a',

  },

}

// 這裏使用 fromJS 將 data 轉變爲 immutable 內部對象

const a = fromJS(data)

// 以後咱們就能夠調用內部對象上的方法如 get getIn set setIn 等,來操做原對象上的值

const b = a.set('val', 2)

console.log(a.get('val')) // 1

console.log(b.get('val')) // 2

const pathToText = ['desc', 'text']

const c = a.setIn([...pathToText], 'c')

console.log(a.getIn([...pathToText])) // 'a'

console.log(c.getIn([...pathToText])) // 'c'

對於性能方面,immutable-js 也有它的優點,舉個簡單的例子:

const { fromJS } = require('immutable')

const data = {

  content: {

    time: '2018-02-01',

    val: 'Hello World',

  },

  desc: {

    text: 'a',

  },

}

// 把 data 轉化爲 immutable-js 中的內置對象

const a = fromJS(data)

const b = a.setIn(['desc', 'text'], 'b')

console.log(b.get('desc') === a.get('desc')) // false

// content 的值沒有改動過,因此 a 和 b 的 content 還保持着引用

console.log(b.get('content') === a.get('content')) // true

// 將 immutable-js 的內置對象又轉化爲 JS 原生的內容

const c = a.toJS()

const d = b.toJS()

// 這時咱們發現全部的引用都斷開了

console.log(c.desc === d.desc) // false

console.log(c.content === d.content) // false

從上面的例子能夠看出來,咱們操做 immutable-js 的內置對象過程當中,改變了 ​desc​ 對象下的內容。但實際上 ​content​ 的結果咱們並無改動。咱們經過 ​===​ 進行比較的過程當中,就能發現 ​desc​ 的引用已經斷開了,可是 ​content​ 的引用還保持着鏈接。

在 immutable-js 的數據結構中,深層次的對象 在沒有修改的狀況下仍然可以保證嚴格相等,這也是 immutable-js 的另外一個特色 「深層嵌套對象的結構共享」。即嵌套對象在沒有改動前仍然在內部保持着以前的引用,修改後斷開引用,可是卻不會影響以前的結果。

常用 React 的同窗確定也對 immutable-js 不陌生,這也就是爲何 immutable-js 會極大提升 React 頁面性能的緣由之一了。

固然可以達到 immutable 效果的固然不僅這幾個個例,這篇文章我主要想介紹實現 immutable 的庫實際上是 immer。

immer 簡介

immer 的做者同時也是 mobx 的做者。mobx 又像是把 Vue 的一套東西融合進了 React,已經在社區取得了不錯的反響。immer 則是他在 immutable 方面所作的另外一個實踐。

與 immutable-js 最大的不一樣,immer 是使用原生數據結構的 API 而不是像 immutable-js 那樣轉化爲內置對象以後使用內置的 API,舉個簡單例子:

const produce = require('immer')

const state = {

  done: false,

  val: 'string',

}

// 全部具備反作用的操做,均可以放入 produce 函數的第二個參數內進行

// 最終返回的結果並不影響原來的數據

const newState = produce(state, (draft) => {

  draft.done = true

})

console.log(state.done)    // false

console.log(newState.done) // true

經過上面的例子咱們能發現,全部具備反作用的邏輯均可以放進 produce 的第二個參數的函數內部進行處理。在這個函數內部對原來的數據進行任何操做,都不會對原對象產生任何影響。

這裏咱們能夠在函數中進行任何操做,例如 ​push​ ​splice ​ 等非 immutable 的 API,最終結果與原來的數據互不影響。

Immer 最大的好處就在這裏,咱們的學習沒有太多成本,由於它的 API 不多,無非就是把咱們以前的操做放置到 ​produce​ 函數的第二參數函數中去執行。

immer 原理解析

Immer 源碼中,使用了一個 ES6 的新特性 Proxy 對象。Proxy 對象容許攔截某些操做並實現自定義行爲,但大多數 JS 同窗在平常業務中可能並不常用這種元編程模式,因此這裏簡單且快速的介紹一下它的使用。

Proxy 對象接受兩個參數,第一個參數是須要操做的對象,第二個參數是設置對應攔截的屬性,這裏的屬性一樣也支持 get,set 等等,也就是劫持了對應元素的讀和寫,可以在其中進行一些操做,最終返回一個 Proxy 對象實例。

const proxy = new  Proxy({}, {

  get(target, key) {

    // 這裏的 target 就是 Proxy 的第一個參數對象

    console.log('proxy get key', key)

  },

  set(target, key, value) {

    console.log('value', value)

  }

})

// 全部讀取操做都被轉發到了 get 方法內部

proxy.info  // 'proxy get key info'

// 全部設置操做都被轉發到了 set 方法內部

proxy.info = 1  // 'value 1'

上面這個例子中傳入的第一個參數是一個空對象,固然咱們能夠用其餘已有內容的對象代替它,也就是函數參數中的 target。

immer 的作法就是維護一份 state 在內部,劫持全部操做,內部來判斷是否有變化從而最終決定如何返回。下面這個例子就是一個構造函數,若是將它的實例傳入 Proxy 對象做爲第一個參數,就可以後面的處理對象中使用其中的方法:

class  Store  {

  constructor(state) {

    this.modified = false

    this.source = state

    this.copy = null

  }

  get(key) {

    if (!this.modified) return  this.source[key]

    return  this.copy[key]

  }

  set(key, value) {

    if (!this.modified) this.modifing()

    return  this.copy[key] = value

  }

  modifing() {

    if (this.modified) return

    this.modified = true

    // 這裏使用原生的 API 實現一層 immutable,

    // 數組使用 slice 則會建立一個新數組。對象則使用解構

    this.copy = Array.isArray(this.source)

      ? this.source.slice()

      : { ...this.source }

  }

}

上面這個 Store 構造函數相比源代碼省略了不少判斷的部分。實例上面有 ​modified​,​source​,​copy​ 三個屬性,有 ​get​,​set​,​modifing​ 三個方法。​modified​ 做爲內置的 flag,判斷如何進行設置和返回。

裏面最關鍵的就應該是 ​modifing​ 這個函數,若是觸發了 setter 而且以前沒有改動過的話,就會手動將 ​modified​ 這個 flag 設置爲 ​true​,而且手動經過原生的 API 實現一層 immutable。

對於 Proxy 的第二個參數,在簡版的實現中,咱們只是簡單作一層轉發,任何對元素的讀取和寫入都轉發到 store 實例內部方法去處理。

const PROXY_FLAG = '@@SYMBOL_PROXY_FLAG'

const handler = {

  get(target, key) {

    // 若是遇到了這個 flag 咱們直接返回咱們操做的 target

    if (key === PROXY_FLAG) return target

    return target.get(key)

  },

 set(target, key, value) {

    return target.set(key, value)

  },

}

這裏在 getter 裏面加一個 flag 的目的就在於未來從 proxy 對象中獲取 store 實例更加方便。

最終咱們可以完成這個 ​produce​ 函數,建立 ​store​ 實例後建立 ​proxy​ 實例。而後將建立的 ​proxy​ 實例傳入第二個函數中去。這樣不管在內部作怎樣有反作用的事情,最終都會在 ​store​ 實例內部將它解決。最終獲得了修改以後的 ​proxy​ 對象,而 ​proxy​ 對象內部已經維護了兩份 ​state​ ,經過判斷 ​modified​ 的值來肯定究竟返回哪一份。

function  produce(state, producer) {

  const store = new Store(state)

  const proxy = new  Proxy(store, handler)

  // 執行咱們傳入的 producer 函數,咱們實際操做的都是 proxy 實例,全部有反作用的操做都會在 proxy 內部進行判斷,是否最終要對 store 進行改動

  producer(proxy)

  // 處理完成以後,經過 flag 拿到 store 實例

  const newState = proxy[PROXY_FLAG]
 
  if (newState.modified) return newState.copy

  return newState.source

}

這樣,一個分割成 Store 構造函數,handler 處理對象和 produce 處理 state 這三個模塊的最簡版就完成了,將它們組合起來就是一個最最最 tiny 版的 immer ,裏面去除了不少沒必要要的校驗和冗餘的變量。但真正的 immer 內部也有其餘的功能,例如上面提到的深層嵌套對象的結構化共享等等。

固然,Proxy 做爲一個新的 API,並非全部環境都支持,Proxy 也沒法 polyfill,因此 immer 在不支持 Proxy 的環境中,使用 ​Object.defineProperty​ 來進行一個兼容。

性能

咱們用一個簡單測試來測試一下 immer 在實際中的性能。這個測試使用一個具備 100k 狀態的狀態樹,咱們記錄了一下操做 10k 個數據的時間來進行對比。

freeze 表示狀態樹在生成以後就被凍結不可繼續操做。對於普通 JS 對象,咱們可使用 ​Object.freeze​ 來凍結咱們生成的狀態樹對象,固然像 immer / immutable-js 內部本身有凍結的方法和邏輯。

具體測試文件能夠點擊查看:https://github.com/immerjs/immer/blob/master/__performance_tests__/add-data.js

這裏分別舉例一下每一個橫座標所表明的含義:

  • just mutate:直接經過原生操做進行操做,freeze 就直接調用 Object.freeze 凍結整個對象。
  • deepclone:經過深拷貝來複制原來的數據,freeze 後的時間指凍結這個深拷貝對象的時間。
  • reducer:指咱們手動經過 ​...​ 或者 ​Object.assign​ 這類原生 immutable API 來處理咱們的數據,freeze 後的時間表明咱們凍結這個咱們建立出來的新內容的時間。
  • Immutable js: 指咱們經過 immutable-js 來操做數據。toJS 指將內置的 immutable-js 對象轉化爲原生 js 內容。
  • Immer: 分別測試了在支持 Proxy 的環境和在不支持 Proxy 使用 defineProperty 環境下的數據。

經過上圖的觀察,基本能夠得出如下對比結果:

  • 從 mutate 和 deepclone 來看,mutate 基準肯定了數據更改費用的基線,deepclone 深拷貝由於沒有結構共享,因此效率會差不少。
  • 使用 Proxy 的 immer 大概是手寫 reducer 的兩倍,固然這在實踐中能夠忽略不計。
  • immer 大體和 immutable-js 同樣快。可是,immutable-js 最後常常須要 toJS 操做,這裏的性能的開銷是很大的。例如將不可變的 JS 對象轉換回普通的對象,將它們傳遞給組件中,或着經過網絡傳輸等等(還有將從例如服務器接收到的數據轉換爲 immutable-js 內置對象的前期成本)。
  • immer 的 ES5 版本中使用 ​defineProperty​ 來實現,它的測試速度明顯較慢。因此儘可能在支持 ​Proxy​ 的環境中使用 immer。
  • 在 freeze 的版本中,只有 mutate,deepclone 和原生 reducer 纔可以遞歸地凍結全狀態樹,而其餘測試用例只凍結樹的修改部分。

總結

從上面的例子中咱們也能夠總結一下對比 immutable-js 和 immer 之間的優勢和不足:

  • Immer 的 API 很是簡單,上手幾乎沒有難度,同時項目遷移改造也比較容易。immutable-js 上手就複雜的多,使用 immutable-js 的項目遷移或者改造起來會稍微複雜一些。
  • Immer 須要環境支持 ​Proxy​ 和 ​defineProperty​,不然沒法使用。但 immutable-js 支持編譯爲 ES3 代碼,適合全部 JS 環境。
  • Immer 的運行效率受到環境因素影響較大。immutable-js 的效率總體來講比較平穩,可是在轉化過程當中要先執行 ​fromJS​ 和 ​toJS​,因此須要一部分前期效率的成本。

字節跳動商業化前端團隊招人啦!能夠每日與技術網紅暢談理想、參加技術大牛交流分享、享受一日四餐、頂級極客裝備、免費健身房,與優秀的人作有挑戰的事情,這種地方哪裏找?快來加入咱們吧!

簡歷投遞郵箱: sunyunchao@bytedance.com

相關文章
相關標籤/搜索