下一代狀態管理工具 immer 簡介及源碼解析

博客鏈接:下一代狀態管理工具 immer 簡介及源碼解析

JS 裡面的變數類型可以大致分為基本類型和引用類型。在使用過程中,引用類型經常會產生一些無法意識到的副作用,所以在現代 JS 開發過程中,大家都有意識的寫下斷開引用的不可變數據類型。

// 引用帶來的副作用var a = [{ val: 1 }]var b = a.map(item => item.val = 2)// 期望:b 的每一個元素的 val 值變為 2console.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) // 1console.log(b[0].val) // 2

這樣做其實還會有一個問題,無論是 Object.assign 還是 ... 的解構操作,斷掉的引用也只是一層,如果對象嵌套超過一層,這樣做還是有一定的風險。

var a = [ { val: 1, desc: { text: a } }]var b = a.map(item => ({ ...item, val: 2 }))console.log(a === b) // falseconsole.log(a.desc === b.desc) // true

這樣一來,後面的代碼如果一不小心在一個函數內部給 b.desc 對象裡面的內容通過「點」進行賦值,就一定會改變具有相同引用的 a.desc 部分的值,這當然是不符合我們的預期的。

所以在這之後,大多數情況下我們會考慮 深拷貝 這樣的操作來完全避免上面遇到的所有問題。深拷貝,顧名思義就是在遍歷過程中,如果遇到了可能出現引用的數據類型,就會遞歸的完全創建一個新的類型。

// 一個簡單的深拷貝函數,去掉了一些膠水部分// 用戶態輸入一定是一個 Plain Object,並且所有 value 也是 Plain Objectfunction deepClone(a) { const keys = Object.keys(a) return keys.reduce((memo, current) => { const value = a[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 = 2console.log(a.val) // 1console.log(b.val) // 2b.desc.text = bconsole.log(a.desc.text) // aconsole.log(b.desc.text) // b

上面的這個 deepClone 可以滿足簡單的需求,但是真正在生產工作中,我們需要考慮非常多的因素。舉例來說:

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

因為有太多不確定因素,所以我還是推薦使用大型開源項目裡面的工具函數,比較常用的為大家所熟知的就是 lodash.cloneDeep,無論是安全性還是效果都有所保障。

其實,這樣的概念我們常稱作 immutable ,意為不可變的數據,其實理解為不可變關係更為恰當。每當我們創建一個被 deepClone 過的數據,新的數據進行有副作用 (side effect) 的操作都不會影響到之前的數據,這也就是 immutable 的精髓和本質。

然而 deepClone 這種函數雖然斷絕了引用關係實現了 immutable,但是開銷實在太大。所以在 2014 年,facebook 的 immutable-js 橫空出世,即保證了 immutable ,又兼顧了性能。

immutable-js 簡介

immutable-js 使用了另一套數據結構的 API ,與我們的常見操作有些許不同,它將所有的原生對象都會轉化成 immutable-js 的內部對象,並且任何操作最終都會返回一個新的 immutable 的值。

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

const { fromJS } = require(immutable)const data = { val: 1, desc: { text: a, },}const a = fromJS(data)const b = a.set(val, 2)console.log(a.get(val)) // 1console.log(b.get(val)) // 2const pathToText = [desc, text]const c = a.setIn([...pathToText], c)console.log(a.getIn([...pathToText])) // aconsole.log(c.getIn([...pathToText])) // c

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

const { fromJS } = require(immutable)const data = { content: { time: 2018-02-01, val: Hello World, }, desc: { text: a, },}const a = fromJS(data)const b = a.setIn([desc, text], b)console.log(b.get(desc) === a.get(desc)) // falseconsole.log(b.get(content) === a.get(content)) // trueconst c = a.toJS()const d = b.toJS()console.log(c.desc === d.desc) // falseconsole.log(c.content === d.content) // false

從上面的例子可以看出來,在 immutable-js 的數據結構中,深層次的對象在沒有修改的情況下仍然能夠保證嚴格相等。這裡的嚴格相等就可以認為是沒有新建這個對象,仍然在內部保持著之前的引用,但是修改卻不會同步的修改。

經常使用 React 的同學肯定也對 immutable-js 不陌生,這也就是為什麼 immutable-js 會極大提高 React 頁面性能的原因之一了。

當然能夠達到 immutable 效果的當然不只這幾個個例,這篇文章我主要想介紹實現 immutable 的庫其實是 immer。

immer 簡介

immer 的作者同時也是 mobx 的作者,一個看起來非常感性的中年大叔。mobx 又像是把 Vue 的一套東西融合進了 React,已經在社區取得了不錯的反響。immer 則是他在 immutable 方面所做的另一個實踐,在 2018-02-01,immer 成功發布了 1.0.0 版本,我差不多在一個月前開始關注這個項目,所以大清早看到作者在 twitter 上發的通告,有感而發今天寫下這篇文章,算是簡單介紹一下 immer 這個 immutable 框架的使用以及內部簡單的實現原理。

與 immutable-js 最大的不同,immer 是使用原生數據結構的 API 而不是內置的 API,舉個簡單例子:

const produce = require(immer)const state = { done: false, val: string,}const newState = produce(state, (draft) => { draft.done = true})console.log(state.done) // falseconsole.log(newState.done) // true

所有需要更改的邏輯都可以放進 produce 的第二個參數的函數內部,即使給對象內的元素直接賦值,也不會對原對象產生任何影響。

簡單介紹完使用之後,下面就開始簡單介紹它的內部實現。不過在這之前,想先通過上面的例子簡單的發散思考一下。

通過文章最開始的例子我們就能明白,給函數傳入一個對象,直接通過「點」操作符對裡面的一個屬性進行更改是一定會改變外面的結果的。而上面的這個例子中,draft 參數穿入進去,與 state 一樣也有 done 這個屬性,但是在通過 draft.done 改變值之後,原來的 state.done 並沒有發生改變。其實到這裡,結合之前研究 vue 源碼的經驗,我當時就篤定,這裡一定用了 Object.defineProperty,draft 通過「點」操作的之後,一些數據的結果被劫持了,然後做了一些新的操作。

immer 原理解析

真正翻開源碼,誠然裡面確實有 defineProperty 的身影,不過在另一個標準的文件中,用了一種新的方式,那就是 ES6 中新增的 Proxy 對象。而在日常的業務過程中,應該很少有前端工程師會用到 Proxy 對象,因為它的應用場景確實有些狹隘,所以這裡簡單介紹一下 Proxy 對象的使用。

Proxy 對象接受兩個參數,第一個參數是需要操作的對象,第二個參數是設置對應攔截的屬性,這裡的屬性同樣也支持 get,set 等等,也就是劫持了對應元素的讀和寫,能夠在其中進行一些操作,最終返回一個 Proxy 對象。

const proxy = new Proxy({}, { get(target, key) { console.log(proxy get key, key) }, set(target, key, value) { console.log(value, value) }})proxy.info // proxy get key infoproxy.info = 1 // value 1

上面這個例子中傳入的第一個參數是一個空對象,當然我們可以用其他對象有內容的對象代替它。例如維護一份 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 this.copy = Array.isArray(this.source) ? this.source.slice() : { ...this.source } }}

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

裡面最關鍵的就應該是 modifing 這個函數,如果觸發了 setter 並且之前沒有改動過的話,就會手動將 modified 這個 flag 設置為 true,並且手動通過原生的 API 實現一層 immutable。

對於 Proxy 的第二個參數,就更加簡單了。在這個例子中,只是簡單做一層轉發,任何對元素的讀取和寫入都轉發到前面的實例內部方法去。

const PROXY_FLAG = @@SYMBOL_PROXY_FLAGconst handler = { get(target, key) { 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)const newState = proxy[PROXY_FLAG] if (newState.modified) return newState.copy return newState.source}

這樣,一個分割成 Store 構造函數,handler 處理對象和 produce 處理 state 這三個模塊的最簡版就完成了,將它們組合起來就是一個最最最 tiny 版的 immer ,裡面去除了很多不必要的校驗和冗餘的變數。但真正的 immer 內部也有其他的功能,例如深度克隆情況下的結構共享等等。

如果真的對這部分邏輯感興趣的話,可以直接讀作者的源碼 immer 或者看我自己寫的繼續深入了一層的源碼 tiny-immer 。寫這篇文章的目的並不是一開始就讓讀者深入進去,而是在以後小型項目有了另一個 immutable 的選擇。

性能

性能方面,就用 immer 官方 README 裡面的介紹來說明情況。

這是一個關於 immer 性能的簡單測試。這個測試使用了 100000 個組件元素,並且更新其中的 10000 個。freeze 表示狀態樹在生成之後已被凍結。這是一個最佳的開發實踐,因為它可以防止開發人員意外修改狀態樹。

通過上圖的觀察,基本可以得出:

  • 從 immer 的角度來看,這個性能環境比其他框架和庫要惡劣的多,因為它必須代理的根節點相對於其餘的數據集來說大得多
  • 從 mutate 和 deepclone 來看,mutate 基準確定了數據更改費用的基線,沒有不可變性(或深度克隆情況下的結構共享)
  • 使用 Proxy 的 immer 大概是手寫 reducer 的兩倍,當然這在實踐中可以忽略不計
  • immer 大致和 immutable-js 一樣快。但是,immutable-js 最後經常需要 toJS 操作,這裡的性能的開銷是很大的。例如將不可變的 JS 對象轉換回普通的對象,將它們傳遞給組件中,或著通過網路傳輸等等(還有將從例如伺服器接收到的數據轉換為 immutable-js 內置對象的前期成本)
  • immer 的 ES5 實現速度明顯較慢。對於大多數的 reducer 來說,這並不重要,因為處理大量數據的 reducer 可以完全不(或者僅部分)使用 immer 的 produce 函數。幸運的是,immer 完全支持這種選擇性加入的情況
  • 在 freeze 的版本中,只有 mutate,deepclone 和原生 reducer 才能夠遞歸地凍結全狀態樹,而其他測試用例只凍結樹的修改部分

寫在後面

其實縱觀 immer 的實現,核心的原理就是放在了對對象讀寫的劫持,從表現形式上立刻就能讓人想到 vue ,mobx 從核心原理上來說也是對對象的讀寫劫持,最近有另一篇非常火的文章 -- 如何讓 (a == 1 && a == 2 && a == 3) 為 true,也相信不少的小夥伴讀過,除了那個肉眼不可見字元的答案,其他答案也算是對對象的讀寫劫持從而達到目標。

所以說在 JS 中,很多知識相輔相成,有多少種方式能讓 (a == 1 && a == 2 && a == 3) 為 true,理論上有多少種答案就會有多少種 MVVM 的組成方式,甚至就有多少種方法能夠實現這樣的 immutable。所以任何一點點小的知識點的聚合,未來都可能影響前端的發展。

最近我個人也在整理所有開源框架的最小實現,項目代碼現在都放在 tiny-all-the-world 。它的目的就在於把前端常用框架類庫實現一個沒有第三方依賴的簡易版,希望大家也能夠從其中有所學習。


推薦閱讀:

如何理解虛擬DOM?
巧用React Fiber中的渲染字元串新功能
The Redux Journey 翻譯及分析(上)
用 ReactJs 創建Mac版的 keep
怎樣看待民工叔去螞蟻金服?

TAG:前端开发 | React | Redux |