写在前面
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:
五.对比 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 支持所有的代理操作
proxyProp 方法里面的 if (!draftValue.mutated), 这块判断有问题,应该是hostDraftState.mutated
我看源码里面也是这样子判断的,应该会有问题吧这块