150 行代碼,手搓一個 Immer
- 2019 年 11 月 29 日
- 筆記
寫在前面
Immer結合 Copy-on-write 機制與 ES6 Proxy 特性,提供了一種異常簡潔的不可變數據操作方式:
const myStructure = { a: [1, 2, 3], b: 0 }; const copy = produce(myStructure, () => { // nothings to do }); const modified = produce(myStructure, myStructure => { myStructure.a.push(4); myStructure.b++; }); copy === myStructure // true modified !== myStructure // true JSON.stringify(modified) === JSON.stringify({ a: [1, 2, 3, 4], b: 1 }) // true JSON.stringify(myStructure) === JSON.stringify({ a: [1, 2, 3], b: 0 }) // true
這究竟是怎麼做到的呢?
一.目標
Immer 只有一個核心 API:
produce(currentState, producer: (draftState) => void): nextState
所以,只要手動實現一個等價的produce
函數,就能弄清楚 Immer 的秘密了
二.思路
仔細觀察produce
的用法,不難發現 5 個特點(見注釋):
const myStructure = { a: [1, 2, 3], b: 0 }; const copy = produce(myStructure, () => {}); const modified = produce(myStructure, myStructure => { // 1.在producer函數中訪問draftState,就像訪問原值currentState一樣 myStructure.a.push(4); myStructure.b++; }); // 2.producer中不修改draftState的話,引用不變,都指向原值 copy === myStructure // true // 3.改過draftState的話,引用發生變化,produce()返回新值 modified !== myStructure // true // 4.producer函數中對draftState的操作都會應用到新值上 JSON.stringify(modified) === JSON.stringify({ a: [1, 2, 3, 4], b: 1 }) // true // 5.producer函數中對draftState的操作不影響原值 JSON.stringify(myStructure) === JSON.stringify({ a: [1, 2, 3], b: 0 }) // true
即:
- 僅在寫時拷貝(見注釋 2、注釋 3)
- 讀操作被代理到了原值上(見注釋 1)
- 寫操作被代理到了拷貝值上(見注釋 4、注釋 5)
那麼,簡單的骨架已經浮出水面了:
function produce(currentState, producer) { const copy = null; const draftState = new Proxy(currentState, { get(target, key, receiver) { // todo 把讀操作代理到原值上 }, set() { if (!mutated) { mutated = true; // todo 創建拷貝值 } // todo 把寫操作代理到拷貝值上 } }); producer(draftState); return copy || currentState; }
此外,由於 Proxy 只能監聽到當前層的屬性訪問,所以代理關係也要按需創建:

根節點預先創建一個 Proxy,對象樹上被訪問到的所有中間節點(或新增子樹的根節點)都要創建對應的 Proxy
而每個 Proxy 都只在監聽到寫操作(直接賦值、原生數據操作 API 等)時才創建拷貝值(所謂Copy-on-write),並將之後的寫操作全都代理到拷貝值上
最後,將這些拷貝值與原值整合起來,得到數據操作結果
因此,Immer = Copy-on-write + Proxy
三.具體實現
按照上面的分析,實現上主要分為 3 部分:
- 代理:按需創建、代理讀寫操作
- 拷貝:按需拷貝(Copy-on-write)
- 整合:建立拷貝值與原值的關聯、深度 merge 原值與拷貝值
代理
拿到原值之後,先給根節點創建 Proxy,得到供producer
操作的draftState
:
function produce(original, producer) { const draft = proxy(original); //... }
最關鍵的當然是對原值的get
、set
操作的代理:
function proxy(original, onWrite) { // 存放代理關係及拷貝值 let draftState = { originalValue: original, draftValue: Array.isArray(original) ? [] : Object.create(Object.getPrototypeOf(original)), mutated: false, onWrite }; // 創建根節點代理 const draft = new Proxy(original, { // 讀操作(代理屬性訪問) get(target, key, receiver) { if (typeof original[key] === 'object' && original[key] !== null) { // 不為基本值類型的現有屬性,創建下一層代理 return proxyProp(original[key], key, draftState, onWrite); } else { // 改過直接從draft取最新狀態 if (draftState.mutated) { return draftValue[key]; } // 不存在的,或者值為基本值的現有屬性,代理到原值 return Reflect.get(target, key, receiver); } }, // 寫操作(代理數據修改) set(target, key, value) { // 如果新值不為基本值類型,創建下一層代理 if (typeof value === 'object') { proxyProp(value, key, draftState, onWrite); } // 第一次寫時複製 copyOnWrite(draftState); // 複製過了,直接寫 draftValue[key] = value; return true; } }); return draft; }
P.S.此外,其餘許多讀寫方法也需要代理,例如has
、ownKeys
、deleteProperty
等等,處理方式類似,這裡不再贅述
拷貝
即上面出現過的copyOnWrite
函數:
function copyOnWrite(draftState) { const { originalValue, draftValue, mutated, onWrite } = draftState; if (!mutated) { draftState.mutated = true; // 下一層有修改時才往父級 draftValue 上掛 if (onWrite) { onWrite(draftValue); } // 第一次寫時複製 copyProps(draftValue, originalValue); } }
僅在第一次寫時(!mutated
)才將原值上的其餘屬性拷貝到draftValue
上
特殊的,淺拷貝時需要注意屬性描述符、Symbol屬性等細節:
// 跳過target身上已有的屬性 function copyProps(target, source) { if (Array.isArray(target)) { for (let i = 0; i < source.length; i++) { // 跳過在更深層已經被改過的屬性 if (!(i in target)) { target[i] = source[i]; } } } else { Reflect.ownKeys(source).forEach(key => { const desc = Object.getOwnPropertyDescriptor(source, key); // 跳過已有屬性 if (!(key in target)) { Object.defineProperty(target, key, desc); } }); } }
P.S.Reflect.ownKeys
能夠返回對象的所有屬性名(包括 Symbol 屬性名和字符串屬性名)
整合
要想把拷貝值與原值整合起來,先要建立兩種關係:
- 代理與原值、拷貝值的關聯:根節點的代理需要將結果帶出來
- 下層拷貝值與祖先拷貝值的關聯:拷貝值要能輕鬆對應到結果樹上
對於第一個問題,只需要將代理對象對應的draftState
暴露出來即可:
const INTERNAL_STATE_KEY = Symbol('state'); function proxy(original, onWrite) { let draftState = { originalValue: original, draftValue, mutated: false, onWrite }; const draft = new Proxy(original, { get(target, key, receiver) { // 建立proxy到draft值的關聯 if (key === INTERNAL_STATE_KEY) { return draftState; } //... } } }
至於第二個問題,可以通過onWrite
鉤子來建立下層拷貝值與祖先拷貝值的關聯:
// 創建下一層代理 function proxyProp(propValue, propKey, hostDraftState) { const { originalValue, draftValue, onWrite } = hostDraftState; // 下一層屬性發生寫操作時 const onPropWrite = (value) => { // 按需創建父級拷貝值 if (!draftValue.mutated) { hostDraftState.mutated = true; // 拷貝host所有屬性 copyProps(draftValue, originalValue); } // 將子級拷貝值掛上去(建立拷貝值的父子關係) draftValue[propKey] = value; // 通知祖先,向上建立完整的拷貝值樹 if (onWrite) { onWrite(draftValue); } }; return proxy(propValue, onPropWrite); }
也就是說,深層屬性第一次發生寫操作時,向上按需拷貝,構造拷貝值樹
至此,大功告成:
function produce(original, producer) { const draft = proxy(original); // 修改draft producer(draft); // 取出draft內部狀態 const { originalValue, draftValue, mutated } = draft[INTERNAL_STATE_KEY]; // 將改過的新值patch上去 const next = mutated ? draftValue : originalValue; return next; }
四.在線 Demo
鑒於手搓的版本要比原版更精簡一些,索性少個 m,就叫 imer:
- Git repo:ayqy/imer
- npm package:imer
五.對比 Immer
與正版相比,實現方案上有兩點差異:
- 創建代理的方式不同:imer 使用
new Proxy
,immer 採用Proxy.revocable()
- 整合方案不同:imer 反向構建拷貝值樹,immer 正向遍歷代理對象樹
通過Proxy.revocable()
創建的 Proxy 能夠解除代理關係,更安全些
而 Immer 正向遍歷代理對象樹也是一種相當聰明的做法:
When the producer finally ends, it will just walk through the proxy tree, and, if a proxy is modified, take the copy; or, if not modified, simply return the original node. This process results in a tree that is structurally shared with the previous state. And that is basically all there is to it.
比onWrite
反向構建拷貝值樹直觀很多,值得借鑒
P.S.另外,Immer 不支持Object.defineProperty()
、Object.setPrototypeOf()
操作,而手搓的 imer 支持所有的代理操作
參考資料
- immerjs/immer v4.0.1
- Introducing Immer: Immutability the easy way