精讀《Immer.js》原始碼

黃子毅發表於2018-03-19

本週精讀的倉庫是 immer

1 引言

Immer 是最近火起來的一個專案,由 Mobx 作者 Mweststrate 研發。

瞭解 mobx 的同學可能會發現,Immer 就是更底層的 Mobx,它將 Mobx 特性發揚光大,得以結合到任何資料流框架,使用起來非常優雅。

2 概述

麻煩的 Immutable

Immer 想解決的問題,是利用超程式設計簡化 Immutable 使用的複雜度。舉個例子,我們寫一個純函式:

const addProducts = products => {
  const cloneProducts = products.slice()
  cloneProducts.push({ text: "shoes" })
  return cloneProducts
}
複製程式碼

雖然程式碼並不複雜,但寫起來內心仍隱隱作痛。我們必須將 products 拷貝一份,再呼叫 push 函式修改新的 cloneProducts,再返回它。

如果 js 原生支援 Immutable,就可以直接使用 push 了!對,Immer 讓 js 現在就支援:

const addProducts = produce(products => {
  products.push({ text: "shoes" })
})
複製程式碼

很有趣吧,這兩個 addProducts 函式功能一摸一樣,而且都是純函式。

彆扭的 setState

我們都知道,react 框架中,setState 支援函式式寫法:

this.setState(state => ({
  ...state,
  isShow: true
}))
複製程式碼

配合解構語法,寫起來仍是如此優雅。那資料稍微複雜些呢?我們就要默默忍受 “糟糕的 Immutable” 了:

this.setState(state => {
  const cloneProducts = state.products.slice()
  cloneProducts.push({ text: "shoes" })
  return {
    ...state,
    cloneProducts
  }
})
複製程式碼

然而有了 Immer,一切都不一樣了:

this.setState(produce(state => (state.isShow = true)))

this.setState(produce(state => state.products.push({ text: "shoes" })))
複製程式碼

方便的柯里化

上面講述了 Immer 支援柯里化帶來的好處。所以我們也可以直接把兩個引數一次性消費:

const oldObj = { value: 1 }
const newObj = produce(oldObj, draft => (draft.value = 2))
複製程式碼

這就是 Immer:Create the next immutable state by mutating the current one.

3 精讀

雖然筆者之前在這方面已經有所研究,比如做出了 Mutable 轉 Immutable 的庫:dob-redux,但 Immer 實在是太驚豔了,Immer 是更底層的拼圖,它可以插入到任何資料流框架作為功能增強,不得不讚嘆 Mweststrate 真的是非常高瞻遠矚。

所以筆者認真閱讀了它的原始碼,帶大家從原理角度認識 Immer。

Immer 是一個支援柯里化,僅支援同步計算的工具,所以非常適合作為 redux 的 reducer 使用。

Immer 也支援直接 return value,這個功能比較簡單,所以本篇會跳過所有對 return value 的處理。PS: mutable 與 return 不能同時返回不同物件,否則弄不清楚到哪種修改是有效的。

柯里化這裡不做擴充介紹,詳情檢視 curry。我們看 produce 函式 callback 部分:

produce(obj, draft => {
  draft.count++
})
複製程式碼

obj 是個普通物件,那黑魔法一定出現在 draft 物件上,Immer 給 draft 物件的所有屬性做了監聽。

所以整體思路就有了:draftobj 的代理,對 draft mutable 的修改都會流入到自定義 setter 函式,它並不修改原始物件的值,而是遞迴父級不斷淺拷貝,最終返回新的頂層物件,作為 produce 函式的返回值。

生成代理

第一步,也就是將 obj 轉為 draft 這一步,為了提高 Immutable 執行效率,我們需要一些額外資訊,因此將 obj 封裝成一個包含額外資訊的代理物件:

{
  modified, // 是否被修改過
  finalized, // 是否已經完成(所有 setter 執行完,並且已經生成了 copy)
  parent, // 父級物件
  base, // 原始物件(也就是 obj)
  copy, // base(也就是 obj)的淺拷貝,使用 Object.assign(Object.create(null), obj) 實現
  proxies, // 儲存每個 propertyKey 的代理物件,採用懶初始化策略
}
複製程式碼

在這個代理物件上,繫結了自定義的 getter setter,然後直接將其扔給 produce 執行。

getter

produce 回撥函式中包含了使用者的 mutable 程式碼。所以現在入口變成了 gettersetter

getter 主要用來懶初始化代理物件,也就是當代理物件子屬性被訪問的時候,才會生成其代理物件。

這麼說比較抽象,舉個例子,下面是原始 obj:

{
  a: {},
  b: {},
  c: {}
}
複製程式碼

那麼初始情況下,draftobj 的代理,所以訪問 draft.a draft.b draft.c 時,都能觸發 getter setter,進入自定義處理邏輯。可是對 draft.a.x 就無法監聽了,因為代理只能監聽一層。

代理懶初始化就是要解決這個問題,當訪問到 draft.a 時,自定義 getter 已經悄悄生成了新的針對 draft.a 物件的代理 draftA,因此 draft.a.x 相當於訪問了 draftA.x,所以能遞迴監聽一個物件的所有屬性。

同時,如果程式碼中只訪問了 draft.a,那麼只會在記憶體生成 draftA 代理,b c 屬性因為沒有訪問,因此不需要浪費資源生成代理 draftB draftC

當然 Immer 做了一些效能優化,以及在物件被修改過(modified)獲取其 copy 物件,為了保證 base 是不可變的,這裡不做展開。

setter

當對 draft 修改時,會對 base 也就是原始值進行淺拷貝,儲存到 copy 屬性,同時將 modified 屬性設定為 true。這樣就完成了最重要的 Immutable 過程,而且淺拷貝並不是很消耗效能,加上是按需淺拷貝,因此 Immer 的效能還可以。

同時為了保證整條鏈路的物件都是新物件,會根據 parent 屬性遞迴父級,不斷淺拷貝,直到這個葉子結點到根結點整條鏈路物件都換新為止。

完成了 modified 物件再有屬性被修改時,會將這個新值儲存在 copy 物件上。

生成 Immutable 物件

當執行完 produce 後,使用者的所有修改已經完成(所以 Immer 沒有支援非同步),如果 modified 屬性為 false,說明使用者根本沒有改這個物件,那直接返回原始 base 屬性即可。

如果 modified 屬性為 true,說明物件發生了修改,返回 copy 屬性即可。但是 setter 過程是遞迴的,draft 的子物件也是 draft(包含了 base copy modified 等額外屬性的代理),我們必須一層層遞迴,拿到真正的值。

所以在這個階段,所有 draftfinalized 都是 falsecopy 內部可能還存在大量 draft 屬性,因此遞迴 basecopy 的子屬性,如果相同,就直接返回;如果不同,遞迴一次整個過程(從這小節第一行開始)。

最後返回的物件是由 base 的一些屬性(沒有修改的部分)和 copy 的一些屬性(修改的部分)最終拼接而成的。最後使用 freeze 凍結 copy 屬性,將 finalized 屬性設定為 true

至此,返回值生成完畢,我們將最終值儲存在 copy 屬性上,並將其凍結,返回了 Immutable 的值。

Immer 因此完成了不可思議的操作:Create the next immutable state by mutating the current one。

原始碼讀到這裡,發現 Immer 其實可以支援非同步,只要支援 produce 函式返回 Promise 即可。最大的問題是,最後對代理的 revoke 清洗,需要藉助全域性變數,這一點阻礙了 Immer 對非同步的支援。

4 總結

讀到這,如果覺得不過癮,可以看看 redux-box 這個庫,利用 immer + redux 解決了 reducer 冗餘 return 的問題。

同樣我們也開始思考並設計新的資料流框架,筆者在 2018.3.24 的攜程技術沙龍將會分享 《mvvm 前端資料流框架精講》,分享這幾年湧現的各套資料流技術方案研究心得,感興趣的同學歡迎報名參加。

5 更多討論

討論地址是:精讀《Immer.js》原始碼》 · Issue #68 · dt-fe/weekly

如果你想參與討論,請點選這裡,每週都有新的主題,每週五發布。

相關文章