寫在前面
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
有所得、有所惑,真好
關注「前端向後」微信公眾號,你將收穫一系列「用心原創」的高質量技術文章,主題包括但不限於前端、Node.js以及服務端技術
本文首發於 ayqy.net ,原文鏈接:http://www.ayqy.net/blog/new%...