前言
在上一篇文章 精读 Immer 源码(一),我详细分析了 core 部分的源码。其实 Immer 虽然是一个很简单的 JS 库,但是考虑到性能和体积,官方团队还是做了简单的插件机制。对于一些用户在特殊场景下才用到的功能,用户可以通过官方提供的 API 手动开启,也能让用户很清楚地知道使用了什么样的功能。
目前 Immer 源码中,主要封装了三个插件:mapset、es5、patches,它们放在 src plugins 目录下,本文会通过详细的源码分析介绍每个插件的使用场景。
通过看插件的源码,我们能知道 Immer 是怎么实现 Map、Set 这类数据的“Proxy”,如果要支持 es5 环境下的代理,也就是不能用 Proxy API,又要怎么 fallback。
下面我们先从 mapset 插件开始,揭开每个插件的神秘面纱。
mapset
在 精读 Immer 源码(一)一文中解读 core 源码的时候,在执行 createProxy 方法对数据进行 proxy 的时候,我们知道对于 Map、Set 等数据结构的代理走的是单独的 proxy 方法:
const draft: Drafted = isMap(value)
? getPlugin('MapSet').proxyMap_(value, parent)
: isSet(value)
? getPlugin('MapSet').proxySet_(value, parent)
: immer.useProxies_
? createProxyProxy(value, parent)
: getPlugin('ES5').createES5Proxy_(value, parent)
而这些方法,实际上就是由 mapset 插件提供。在我们使用 Immer 的时候,默认是不支持 Map、Set 等数据结构的代理,我们必须调用 API 手动开启插件:
import { enableMapSet, produce } from 'immer'
enableMapSet()
const nextState = produce(new Map(), (draft) => {
draft.set('immer', { title: 'Learn Immer', done: false })
})
那我们来看 mapset 具体是怎么实现的。
先看下 proxyMap_ 的方法:
const DraftMap = (function (_super) {
__extends(DraftMap, _super)
// Create class manually, cause #502
function DraftMap(this: any, target: AnyMap, parent?: ImmerState): any {
this[DRAFT_STATE] = {
type_: ProxyType.Map,
parent_: parent,
scope_: parent ? parent.scope_ : getCurrentScope()!,
modified_: false,
finalized_: false,
copy_: undefined,
assigned_: undefined,
base_: target,
draft_: this as any,
isManual_: false,
revoked_: false
} as MapState
return this
}
const p = DraftMap.prototype
Object.defineProperty(p, 'size', {
get: function () {
return latest(this[DRAFT_STATE]).size
}
// enumerable: false,
// configurable: true
})
p.has = function (key: any): boolean {
return latest(this[DRAFT_STATE]).has(key)
}
p.set = function (key: any, value: any) {
const state: MapState = this[DRAFT_STATE]
assertUnrevoked(state)
if (!latest(state).has(key) || latest(state).get(key) !== value) {
prepareMapCopy(state)
markChanged(state)
state.assigned_!.set(key, true)
state.copy_!.set(key, value)
state.assigned_!.set(key, true)
}
return this
}
p.delete = function (key: any): boolean {
if (!this.has(key)) {
return false
}
const state: MapState = this[DRAFT_STATE]
assertUnrevoked(state)
prepareMapCopy(state)
markChanged(state)
state.assigned_!.set(key, false)
state.copy_!.delete(key)
return true
}
p.clear = function () {
const state: MapState = this[DRAFT_STATE]
assertUnrevoked(state)
if (latest(state).size) {
prepareMapCopy(state)
markChanged(state)
state.assigned_ = new Map()
each(state.base_, (key) => {
state.assigned_!.set(key, false)
})
state.copy_!.clear()
}
}
p.forEach = function (
cb: (value: any, key: any, self: any) => void,
thisArg?: any
) {
const state: MapState = this[DRAFT_STATE]
latest(state).forEach((_value: any, key: any, _map: any) => {
cb.call(thisArg, this.get(key), key, this)
})
}
p.get = function (key: any): any {
const state: MapState = this[DRAFT_STATE]
assertUnrevoked(state)
const value = latest(state).get(key)
if (state.finalized_ || !isDraftable(value)) {
return value
}
if (value !== state.base_.get(key)) {
return value // either already drafted or reassigned
}
// despite what it looks, this creates a draft only once, see above condition
const draft = createProxy(state.scope_.immer_, value, state)
prepareMapCopy(state)
state.copy_!.set(key, draft)
return draft
}
p.keys = function (): IterableIterator<any> {
return latest(this[DRAFT_STATE]).keys()
}
p.values = function (): IterableIterator<any> {
const iterator = this.keys()
return {
[iteratorSymbol]: () => this.values(),
next: () => {
const r = iterator.next()
/* istanbul ignore next */
if (r.done) return r
const value = this.get(r.value)
return {
done: false,
value
}
}
} as any
}
p.entries = function (): IterableIterator<[any, any]> {
const iterator = this.keys()
return {
[iteratorSymbol]: () => this.entries(),
next: () => {
const r = iterator.next()
/* istanbul ignore next */
if (r.done) return r
const value = this.get(r.value)
return {
done: false,
value: [r.value, value]
}
}
} as any
}
p[iteratorSymbol] = function () {
return this.entries()
}
return DraftMap
})(Map)
function proxyMap_<T extends AnyMap>(target: T, parent?: ImmerState): T {
// @ts-ignore
return new DraftMap(target, parent)
}
初次看这里的代码,可能有些人有点蒙,为什么要自己构造一个 DraftMap,然后实现 Map 的所有增删改查的 API。
如果对 Proxy 有过了解的同学就会知道,原生的 Proxy 是不支持代理 Map 和 Set 的一些增删改查操作的。我们做个实验:
const map = new Map([['immer'], [{ title: 'Lerna Immer', done: false }]])
const target = new Proxy(map, {
get(obj, prop) {
return obj[prop]
},
set(obj, prop, value) {
obj.set(prop, value)
}
})
console.log(target.get('immer'))
target.set('typescript', { title: 'Learn TS', done: false })
当我们去 get 数据的时候,devtool 报错如下:
这跟 Map 的底层实现有关,Set 也是类似。Vue3 的响应式设计也使用了 Proxy,实际上也是有这个问题,所以解决方案也是构造一个新的对象去代理。
搞清楚了这一点,我们继续看代码。
其实 DraftMap 也会保存一个类似于 ProxyState 的数据,在这里叫 MapState,它存在一个叫 DRAFTSAFE 的属性上,跟 ProxyState 大多数属性类型,也会有 base 和 copy_ 属性,而所有的 get、set、has 方法都会映射到 copy__ 上,实际上这一块的设计 Immer 是做到了一致的,所以看懂了 core 中的对数组和 plain 对象的实现,看这里就比较轻松了。以 set 方法为例:
p.set = function (key: any, value: any) {
const state: MapState = this[DRAFT_STATE]
assertUnrevoked(state)
if (!latest(state).has(key) || latest(state).get(key) !== value) {
prepareMapCopy(state)
markChanged(state)
state.assigned_!.set(key, true)
state.copy_!.set(key, value)
state.assigned_!.set(key, true)
}
return this
}
当在外部调用 draft 对象的 set 方法,这里我们关注核心的逻辑。首先会判断最新的 state 有没有对应的 key,并且当前的 value 是不是跟更新的 value 一致,都不满足,才会走接下来的逻辑。
首先要是初始化 copy** 属性,如果没有,从 base 拷贝一份。然后进行 Map 的 set 操作,也会将当前更新的 key 存在 assign** 属性里面,这里的逻辑还是比较简单的。
其它的方法也是类似,Immer “Proxy” 的思路基本还是构造一个 state 对象,然后代理 state 对象的增删改查,然后再映射到 copy_ 属性中。对于 DraftSet 的实现思路跟 Map 是几乎一模一样的,只不过 Map 和 Set 的原型方法有些区别,这里就不重复解读了。
es5
看完 mapset 插件,我们继续看 es5 插件的实现思路。
我们知道 Proxy 是 es6 的 API,如果我们的功能需要支持 es5 的浏览器,那我们就要考虑使用别的方案就行数据代理。熟悉 Vue 的同学可能很容易就想到 Vue2 的响应式方案:Object.defineProperty,没错,Immer 也是使用该 API 实现对数据的劫持。
同样在创建 draft 对象的时候,会根据当前的配置,如果浏览器不支持 Proxy,并且开启 es5 的插件,则使用 createES5Proxy_ 方法创建 draft 对象:
const draft: Drafted = getPlugin('ES5').createES5Proxy_(value, parent)
createES5Proxy_ 封装在 es5 插件中,看下具体实现:
function createES5Proxy_<T>(
base: T,
parent?: ImmerState
): Drafted<T, ES5ObjectState | ES5ArrayState> {
const isArray = Array.isArray(base)
const draft = createES5Draft(isArray, base)
const state: ES5ObjectState | ES5ArrayState = {
type_: isArray ? ProxyType.ES5Array : (ProxyType.ES5Object as any),
scope_: parent ? parent.scope_ : getCurrentScope(),
modified_: false,
finalized_: false,
assigned_: {},
parent_: parent,
// base is the object we are drafting
base_: base,
// draft is the draft object itself, that traps all reads and reads from either the base (if unmodified) or copy (if modified)
draft_: draft,
copy_: null,
revoked_: false,
isManual_: false
}
Object.defineProperty(draft, DRAFT_STATE, {
value: state,
// enumerable: false <- the default
writable: true
})
return draft
}
对于构造 ProxyState 的处理,在这里对应的是 ES5ObjectState 和 ES5ArrayState, 前面已经提到了很多次,这里就不重复介绍。
这个方法的关键还是在 createES5Draft 的调用吗,它创建了 draft 对象,看他的实现:
function createES5Draft(isArray: boolean, base: any) {
if (isArray) {
const draft = new Array(base.length)
for (let i = 0; i < base.length; i++)
Object.defineProperty(draft, '' + i, proxyProperty(i, true))
return draft
} else {
const descriptors = getOwnPropertyDescriptors(base)
delete descriptors[DRAFT_STATE as any]
const keys = ownKeys(descriptors)
for (let i = 0; i < keys.length; i++) {
const key: any = keys[i]
descriptors[key] = proxyProperty(
key,
isArray || !!descriptors[key].enumerable
)
}
return Object.create(Object.getPrototypeOf(base), descriptors)
}
}
对于数组,进行编辑后,通过 defineProperty 代理数组的每一项,最后调用了 proxyProperty 方法,去重写了属性的 set 和 get 方法:
function proxyProperty(
prop: string | number,
enumerable: boolean
): PropertyDescriptor {
let desc = descriptors[prop]
if (desc) {
desc.enumerable = enumerable
} else {
descriptors[prop] = desc = {
configurable: true,
enumerable,
get(this: any) {
const state = this[DRAFT_STATE]
if (__DEV__) assertUnrevoked(state)
// @ts-ignore
return objectTraps.get(state, prop)
},
set(this: any, value) {
const state = this[DRAFT_STATE]
if (__DEV__) assertUnrevoked(state)
// @ts-ignore
objectTraps.set(state, prop, value)
}
}
}
return desc
}
对于 plain 对象,则代理了对象所有的 ownKeys :
const descriptors = getOwnPropertyDescriptors(base)
delete descriptors[DRAFT_STATE as any]
const keys = ownKeys(descriptors)
for (let i = 0; i < keys.length; i++) {
const key: any = keys[i]
descriptors[key] = proxyProperty(
key,
isArray || !!descriptors[key].enumerable
)
}
return Object.create(Object.getPrototypeOf(base), descriptors)
跟 Vue2.0 不一样的是,如果我们定义的数据对象和数组是嵌套的结构,Vue2.0 会在初始化的时候递归对属性进行劫持。Immer 在这里也是使用 lazy 的方式。
如果看过 Immer 的同学,就知道,使用 Proxy 代理的方式,以对象为例,是在 objectTraps 里面对嵌套的属性进行 proxy。而在 es5 插件中,这里的 proxyProperty 方法引用的 objectTraps 实际上就是来自 core 源码中 proxy.ts 中。我们以 get traps 为例回顾下代码:
export const objectTraps: ProxyHandler<ProxyState> = {
get(state, prop) {
if (prop === DRAFT_STATE) return state
const source = latest(state)
if (!has(source, prop)) {
// non-existing or non-own property...
return readPropFromProto(state, source, prop)
}
const value = source[prop]
if (state.finalized_ || !isDraftable(value)) {
return value
}
// Check for existing draft in modified state.
// Assigned values are never drafted. This catches any drafts we created, too.
if (value === peek(state.base_, prop)) {
prepareCopy(state)
return (state.copy_![prop as any] = createProxy(
state.scope_.immer_,
value,
state
))
}
return value
}
// 省略一些代码
}
在我们去访问嵌套属性的时候,就会再次调用 createProxy 方法,对嵌套的对象进行 proxy。
使用 es5 插件的时候,跟使用 Proxy 还有个不同之处,在于最后一步,对于 processResult 的处理:
if (!scope.immer_.useProxies_)
getPlugin('ES5').willFinalizeES5_(scope, result, isReplaced)
会多走一个特殊的 willFinalizeES5_ 逻辑,我们看下它的实现:
function willFinalizeES5_(scope: ImmerScope, result: any, isReplaced: boolean) {
if (!isReplaced) {
if (scope.patches_) {
markChangesRecursively(scope.drafts_![0])
}
// This is faster when we don't care about which attributes changed.
markChangesSweep(scope.drafts_)
}
// When a child draft is returned, look for changes.
else if (
isDraft(result) &&
(result[DRAFT_STATE] as ES5State).scope_ === scope
) {
markChangesSweep(scope.drafts_)
}
}
我们先忽略 patch 相关逻辑,实际上就是做一个 markChangesSweep 逻辑:
// This looks expensive, but only proxies are visited, and only objects without known changes are scanned.
function markChangesSweep(drafts: Drafted<any, ImmerState>[]) {
// The natural order of drafts in the `scope` array is based on when they
// were accessed. By processing drafts in reverse natural order, we have a
// better chance of processing leaf nodes first. When a leaf node is known to
// have changed, we can avoid any traversal of its ancestor nodes.
for (let i = drafts.length - 1; i >= 0; i--) {
const state: ES5State = drafts[i][DRAFT_STATE]
if (!state.modified_) {
switch (state.type_) {
case ProxyType.ES5Array:
if (hasArrayChanges(state)) markChanged(state)
break
case ProxyType.ES5Object:
if (hasObjectChanges(state)) markChanged(state)
break
}
}
}
}
其实就是对所有 draft 对象进行 markChanged 操作,markChanged 其实就是修改 state 的 modified_ 标识:
export function markChanged(state: ImmerState) {
if (!state.modified_) {
state.modified_ = true
if (state.parent_) {
markChanged(state.parent_)
}
}
}
为了优化性能, Immer 还做了数组或者对象是否有 change 的判断,这里不是特别核心的逻辑,就不再解读 hasArrayChanges 和 hasObjectChanges 方法了。
总体来看,es5 插件逻辑略微有点绕,还有一些隐藏的逻辑需要理解 core 源码中 es6 Proxy 的实现才能知道。但其实只要明白了整个设计就是为了解决 es5 不支持 Proxy API,所以 fallback 到 defineProperty 方法来实现,整体的核心实现也不是很难理解。
patches
要看懂这部分的源码,首先需要知道 patches 在 Immer 中是个什么功能,怎么用?打开官方文档,有个章节专门介绍 patches 相关的使用。
首选需要知道,从 Immer v6.0 开始,patches 功能需要通过 API 开启:
Since version 6 support for Patches has to be enabled explicitly by calling enablePatches() once when starting your application.
开启了 patches 功能后,在我们调用 produce 期间,就会通过 patches 插件各 API 进行 patches 相关的数据存储。
patches 功能主要有以下使用场景:
- 与其它功能模块交换增量更新数据,例如通过 websockets;
- 对于调试/追踪,可以准确地看到状态如何随时间更改,也就是追踪数据变化;
- 作为撤销重做的基础功能,用来实现变化状态树的一种方式。
看下官网的使用例子:
import produce, { applyPatches } from 'immer'
// version 6
import { enablePatches } from 'immer'
enablePatches()
let state = {
name: 'Micheal',
age: 32
}
// Let's assume the user is in a wizard, and we don't know whether
// his changes should end up in the base state ultimately or not...
let fork = state
// all the changes the user made in the wizard
let changes = []
// the inverse of all the changes made in the wizard
let inverseChanges = []
fork = produce(
fork,
(draft) => {
draft.age = 33
},
// The third argument to produce is a callback to which the patches will be fed
(patches, inversePatches) => {
changes.push(...patches)
inverseChanges.push(...inversePatches)
}
)
// In the meantime, our original state is replaced, as, for example,
// some changes were received from the server
state = produce(state, (draft) => {
draft.name = 'Michel'
})
// When the wizard finishes (successfully) we can replay the changes that were in the fork onto the *new* state!
state = applyPatches(state, changes)
// state now contains the changes from both code paths!
expect(state).toEqual({
name: 'Michel', // changed by the server
age: 33 // changed by the wizard
})
// Finally, even after finishing the wizard, the user might change his mind and undo his changes...
state = applyPatches(state, inverseChanges)
expect(state).toEqual({
name: 'Michel', // Not reverted
age: 32 // Reverted
})
当我们开启了 patches 功能后,就可以给 produce 传第三个 patchListenner 参数,用于获取到每次修改 draft 后的 patches 和 inversePatches 数据,然后通过调用 applyPatches 拿到最新的 state 和上一次的 state。
你可以直接调用 produceWithPatches 方法直接拿到每次修改 draft 后的 patches 和 inversePatches 数据,看一个例子:
import { produceWithPatches } from 'immer'
const [nextState, patches, inversePatches] = produceWithPatches(
{
age: 33
},
(draft) => {
draft.age++
}
)
说了这么多,那么 patch 是怎么来的了。在官网也提到了,其实 patch 的数据模型设计参考了:RFC 6902 JSON Patch 的规范。只是 Immer 中的 path 数据是使用数组表示:
;[
{
op: 'replace',
path: ['profile'],
value: { name: 'Veria', age: 5 }
},
{ op: 'remove', path: ['tags', 3] }
]
而规范里面则是使用字符串,然后使用 / 分割多个值。
在 patches 插件的实现中,主要暴露出来三个核心的 API:applyPatches*,generatePatches__, generateReplacementPatches*。_
我们先看下 _generatePatches 的实现:
function generatePatches_(
state: ImmerState,
basePath: PatchPath,
patches: Patch[],
inversePatches: Patch[]
): void {
switch (state.type_) {
case ProxyType.ProxyObject:
case ProxyType.ES5Object:
case ProxyType.Map:
return generatePatchesFromAssigned(
state,
basePath,
patches,
inversePatches
)
case ProxyType.ES5Array:
case ProxyType.ProxyArray:
return generateArrayPatches(state, basePath, patches, inversePatches)
case ProxyType.Set:
return generateSetPatches(
state as any as SetState,
basePath,
patches,
inversePatches
)
}
}
这个方法比较简单,就是根据不同的数据类型,返回对应的 generatePatchsXXX 方法。对于可以 Proxy 的对象、es5 对象、Map 等数据结构走的是 generatePatchesFromAssigned 方法,数组一律走 generateArrayPatches 逻辑,Set 走独立的 generateSetPatches 方法。
我们先看 generateArrayPatches 方法:
function generateArrayPatches(
state: ES5ArrayState | ProxyArrayState,
basePath: PatchPath,
patches: Patch[],
inversePatches: Patch[]
) {
let { base_, assigned_ } = state
let copy_ = state.copy_!
// Reduce complexity by ensuring `base` is never longer.
if (copy_.length < base_.length) {
// @ts-ignore
;[base_, copy_] = [copy_, base_]
;[patches, inversePatches] = [inversePatches, patches]
}
// Process replaced indices.
for (let i = 0; i < base_.length; i++) {
if (assigned_[i] && copy_[i] !== base_[i]) {
const path = basePath.concat([i])
patches.push({
op: REPLACE,
path,
// Need to maybe clone it, as it can in fact be the original value
// due to the base/copy inversion at the start of this function
value: clonePatchValueIfNeeded(copy_[i])
})
inversePatches.push({
op: REPLACE,
path,
value: clonePatchValueIfNeeded(base_[i])
})
}
}
// Process added indices.
for (let i = base_.length; i < copy_.length; i++) {
const path = basePath.concat([i])
patches.push({
op: ADD,
path,
// Need to maybe clone it, as it can in fact be the original value
// due to the base/copy inversion at the start of this function
value: clonePatchValueIfNeeded(copy_[i])
})
}
if (base_.length < copy_.length) {
inversePatches.push({
op: REPLACE,
path: basePath.concat(['length']),
value: base_.length
})
}
}
在 精读 Immer 源码(一)一文中分析 proxy 部分的源码时,每个 ProxyState 对象或者其它各代理的 MapState、ES5ObjectState 等对象,都有 base**、copy_、assigned** 属性,实际上在生成 patches 和 inversePatches 数据时,就是结合这三个属性存储的数据,经过一系列的算法计算,得到最后的 patches 数据。
在方法的开始,有个非常 trick 的处理,如果发现 copy_ 的长度小于 base_ 的长度,会交换 base_ 和 copy_ 的值,inversePatches 和 patches 的值,为了减少后面处理的复杂度。因为下面处理的时候总是从数据最少的*开始,然后再到新增的部分数据。在 patche 的模型中,数据的增删改查被抽象为 repace、add、remove 等操作。
看第一个 for 循环:
for (let i = 0; i < base_.length; i++) {
if (assigned_[i] && copy_[i] !== base_[i]) {
const path = basePath.concat([i])
patches.push({
op: REPLACE,
path,
// Need to maybe clone it, as it can in fact be the original value
// due to the base/copy inversion at the start of this function
value: clonePatchValueIfNeeded(copy_[i])
})
inversePatches.push({
op: REPLACE,
path,
value: clonePatchValueIfNeeded(base_[i])
})
}
}
replace 操作指当前的数据项的 assign_ 标识为 true 且新的 copy_ 里面的数据不等于 base_ 里面的数据项,前面我们在看 ProxyState 源码时,assign_ 的属性注释是:
const state: ProxyState = {
// 省略其它属性
// Track which properties have been assigned (true) or deleted (false).
assigned_: {}
// 省略其它属性
}
所以看到这里的实现,我们就明白了这句注释的含义了。
对于第二个 for 循环:
// Process added indices.
for (let i = base_.length; i < copy_.length; i++) {
const path = basePath.concat([i])
patches.push({
op: ADD,
path,
// Need to maybe clone it, as it can in fact be the original value
// due to the base/copy inversion at the start of this function
value: clonePatchValueIfNeeded(copy_[i])
})
}
这个就比较好理解了,对于多出来的数据项,那肯定就属于 add 操作了。
最后还需要根据新老数据的长度变化,存一个 replace 操作:
if (base_.length < copy_.length) {
inversePatches.push({
op: REPLACE,
path: basePath.concat(['length']),
value: base_.length
})
}
其它两个方法的实现,思路类似,这里就不重复介绍了。关键是要理解 patch 的设计思路,其实具体的算法实现就是要看使用场景了。
最后我们还要看下 generatePatches_ 的调用时机:
function finalize(rootScope: ImmerScope, value: any, path?: PatchPath) {
// 省略一些代码
// first time finalizing, let's create those patches
if (path && rootScope.patches_) {
getPlugin("Patches").generatePatches_(
state,
path,
rootScope.patches_,
rootScope.inversePatches_!
)
}
}
return state.copy_
}
这个方法,相信大家看过前面的分析应该很熟悉了,在 finalize 章节详细分析过,只是当时跳过了 patch 相关的代码。也就是说生成 patchs 数据的阶段是 produce 方法最后的调用 processResult 方法的时候,这时候执行完了 recipe 函数,copy_ 数据也已经是最新的,所以这时候去计算 patches 数据是合理的。
看完了 generatePatches_ 方法,我们继续看 applyPatches__ 方法:
function applyPatches_<T>(draft: T, patches: Patch[]): T {
patches.forEach((patch) => {
const { path, op } = patch
let base: any = draft
for (let i = 0; i < path.length - 1; i++) {
const parentType = getArchtype(base)
const p = '' + path[i]
// See #738, avoid prototype pollution
if (
(parentType === Archtype.Object || parentType === Archtype.Array) &&
(p === '__proto__' || p === 'constructor')
)
die(24)
if (typeof base === 'function' && p === 'prototype') die(24)
base = get(base, p)
if (typeof base !== 'object') die(15, path.join('/'))
}
const type = getArchtype(base)
const value = deepClonePatchValue(patch.value) // used to clone patch to ensure original patch is not modified, see #411
const key = path[path.length - 1]
switch (op) {
case REPLACE:
switch (type) {
case Archtype.Map:
return base.set(key, value)
/* istanbul ignore next */
case Archtype.Set:
die(16)
default:
// if value is an object, then it's assigned by reference
// in the following add or remove ops, the value field inside the patch will also be modifyed
// so we use value from the cloned patch
// @ts-ignore
return (base[key] = value)
}
case ADD:
switch (type) {
case Archtype.Array:
return key === '-'
? base.push(value)
: base.splice(key as any, 0, value)
case Archtype.Map:
return base.set(key, value)
case Archtype.Set:
return base.add(value)
default:
return (base[key] = value)
}
case REMOVE:
switch (type) {
case Archtype.Array:
return base.splice(key as any, 1)
case Archtype.Map:
return base.delete(key)
case Archtype.Set:
return base.delete(patch.value)
default:
return delete base[key]
}
default:
die(17, op)
}
})
return draft
}
这个方法的作用就是,通过 draft 和 patches 数据,将 draft 进行一系列的 patch 操作,能回到最初的数据 draft 或者算出最新的 draft。
前面我们在分析 generatePatches 的时候,发现对于任何对象的增删改查,最后被抽象成一个个 pacth 对象,然后 applyPatches_ 就能将一个个 patch 对象作用到 draft 对象上。所以在刚开始的例子中,我们通过 applyPatches 方法和 patches 数据拿到最新的 state,也能通过最新的 draft 对象和 inversePatches 数据得到最初的 state。
暴露给外面使用的 applyPatches 方法内部就是调用 applyPatches_ 方法:
applyPatches<T extends Objectish>(base: T, patches: Patch[]): T {
// If a patch replaces the entire state, take that replacement as base
// before applying patches
let i: number
for (i = patches.length - 1; i >= 0; i--) {
const patch = patches[i]
if (patch.path.length === 0 && patch.op === "replace") {
base = patch.value
break
}
}
// If there was a patch that replaced the entire state, start from the
// patch after that.
if (i > -1) {
patches = patches.slice(i + 1)
}
const applyPatchesImpl = getPlugin("Patches").applyPatches_
if (isDraft(base)) {
// N.B: never hits if some patch a replacement, patches are never drafts
return applyPatchesImpl(base, patches)
}
// Otherwise, produce a copy of the base state.
return this.produce(base, (draft: Drafted) =>
applyPatchesImpl(draft, patches)
)
}
总结
看完了三个插件,总体来说,Immer 的插件化设计还是很值得学习的。虽然 Immer 本身功能不复杂,引入插件化机制,也使得扩展功能相对来说比较容易;同时通过按需加载,也能减少整个包的体积。通过阅读插件源码,我们还知道了:
- 如果要支持 Map 和 Set 等数据结构,需要通过 enableMapSet 开启插件,而且因为 Map 和 Set 等数据的特殊性,不能直接使用 Proxy 进行数据劫持,所以通过构造 DraftMap 和 DraftSet 等数据结构,实现两种数据的增删改查 API ,然后在代理到原始数据上;
- 如果要支持 es5 的环境,则需要手动开启 es5 的插件,会将 Proxy 方案 fallback 到使用 Object.defineProperty API;
- 通过 patches 插件,使得我们可以实现一些特定的业务功能,比如撤销和重做、记录每一次数据操作,方便追踪。