简介
Immer 是一个优秀的开源项目,在2019年荣获了”最具影响力的 JS 开源项目之一“的称号。它能让你非常方便使用 JS 直观的方式操作”不可变“的数据,而依赖这一数据原则的 React 成为了最大的受益框架。
举一个例子,当没有用 Immer 去操作一个数据的时候,一般是这样做的:
const nextState = baseState.slice() // shallow clone the array
nextState[1] = {
// replace element 1...
...nextState[1], // with a shallow clone of element 1
done: true // ...combined with the desired update
}
// since nextState was freshly cloned, using push is safe here,
// but doing the same thing at any arbitrary time in the future would
// violate the immutability principles and introduce a bug!
nextState.push({title: "Tweet about it"})
而使用了 Immer 之后,代码就变得简单了起来:
import produce from "immer"
const baseState = [
{
title: 'learn immmer',
done: false
},
{
title: 'learn typescript',
done: false
}
]
const nextState = produce(baseState, draft => {
draft[1].done = true
draft.push({title: "Tweet about it", done: false })
})
不需要使用各种拷贝、扩展运算符,随着数据结构的复杂,需要处理数据的心智成本就越高,Immer 让我们可以使用 immutable 的方式修改数据,更加符合 JS 修改数据的方式。
Immer 的源码大概只有在 1000 行左右,所以它的包使用 Gzip 压缩后,大概只有 3kb 左右。支持常见的数组、对象、Map、Set 等数据结构的操作,也通过手动开启插件支持 fallback 到 ES5,功能非常强大。
本篇是 Immer 源码解析的第一篇,主要分析 core 部分相关源码。
架构图
Immer 虽然源码不多,但是其自身也设计了简单的插件化机制,下面我们通过一张图来了解其背后的架构。
整个 Immer 源码,主要有两块核心的源码:
- Core,主要是 Immer 核心功能的实现,然后拆分成 proxy、scope、finalize 等三个模块辅助核心的 immerClass 实现,current 实现的是一些独立的暴露给用户的 API;
- Plugins,下面主要包含三个插件: es5、mapset、patches,可以通过 Immer 提供的 API 主动开启插件,所有插件默认是不开启的。
下面开始分析核心模块的具体源码。
core 源码
其实整个 core 核心的实现都在 immerClass.ts 这个文件中,它主要的作用是实现了 Immer 这个类:
interface ProducersFns {
produce: IProduce
produceWithPatches: IProduceWithPatches
}
export class Immer implements ProducersFns {
useProxies_: boolean = hasProxies
autoFreeze_: boolean = true
constructor(config?: {useProxies?: boolean; autoFreeze?: boolean}) {
if (typeof config?.useProxies === "boolean")
this.setUseProxies(config!.useProxies)
if (typeof config?.autoFreeze === "boolean")
this.setAutoFreeze(config!.autoFreeze)
}
/**
* The `produce` function takes a value and a "recipe function" (whose
* return value often depends on the base state). The recipe function is
* free to mutate its first argument however it wants. All mutations are
* only ever applied to a __copy__ of the base state.
*
* Pass only a function to create a "curried producer" which relieves you
* from passing the recipe function every time.
*
* Only plain objects and arrays are made mutable. All other objects are
* considered uncopyable.
*
* Note: This function is __bound__ to its `Immer` instance.
*
* @param {any} base - the initial state
* @param {Function} producer - function that receives a proxy of the base state as first argument and which can be freely modified
* @param {Function} patchListener - optional function that will be called with all the patches produced here
* @returns {any} a new state, or the initial state if nothing was modified
*/
produce: IProduce = (base: any, recipe?: any, patchListener?: any) => {
// 省略一些代码
}
produceWithPatches: IProduceWithPatches = (
arg1: any,
arg2?: any,
arg3?: any
): any => {
// 省略一些代码
}
createDraft<T extends Objectish>(base: T): Draft<T> {
// 省略实现代码
}
finishDraft<D extends Draft<any>>(
draft: D,
patchListener?: PatchListener
): D extends Draft<infer T> ? T : never {
// 省略实现代码
}
/**
* Pass true to automatically freeze all copies created by Immer.
*
* By default, auto-freezing is enabled.
*/
setAutoFreeze(value: boolean) {
this.autoFreeze_ = value
}
/**
* Pass true to use the ES2015 `Proxy` class when creating drafts, which is
* always faster than using ES5 proxies.
*
* By default, feature detection is used, so calling this is rarely necessary.
*/
setUseProxies(value: boolean) {
if (value && !hasProxies) {
die(20)
}
this.useProxies_ = value
}
applyPatches<T extends Objectish>(base: T, patches: Patch[]): T {
// 省略实现代码
}
}
Immer 类实现了 ProducersFns 接口,这个接口有两个核心的方法:produce 和 produceWithPatches,而 produce 实际上就是我们使用 immer 时最核心的 API,它就是从 Immer 类导出的,所以一个 Immer 类的实例方法。而 produceWithPatches API 是一个带有 patch 功能的 produce 加强版,后续我们分析插件部分的源码时,就会提到这一块的内容,所以在分析这部分源码会跳过 patches 相关的部分。
而其它的 API:createDraft、finishDraft、setAutoFreeze、setUseProxies、applyPatches 等都是一些扩展的 API,在一些特定的场景下才会使用到,也在后续的源码解析中会介绍。
produce
看源码之前我们需要知道 produce 的两种使用方式,最基本的使用方式:
import produce from "immer"
const baseState = [
{
title: "Learn TypeScript",
done: true
},
{
title: "Try Immer",
done: false
}
]
const nextState = produce(baseState, draftState => {
draftState.push({title: "Tweet about it"})
draftState[1].done = true
})
还有另外一种,第一个参数直接出传入一个方法:
import produce from "immer"
// curried producer:
const toggleTodo = produce((draft, id) => {
const todo = draft.find(todo => todo.id === id)
todo.done = !todo.done
})
const baseState = [
/* as is */
]
const nextState = toggleTodo(baseState, "Immer")
第二种方式如果在 React 中使用过 Immer 就很熟悉了,其实就是我们使用 hook setState 的时候,就可以使用这种 curry 的方式:
const [todos, setTodos] = useState([
{
id: "React",
title: "Learn React",
done: true
},
{
id: "Immer",
title: "Try Immer",
done: false
}
]);
const handleToggle = useCallback((id) => {
setTodos(
produce((draft) => {
const todo = draft.find((todo) => todo.id === id);
todo.done = !todo.done;
})
);
}, []);
看完了使用的方式,我们再回到源码中,先看下源码:
import produce from "immer"
// curried producer:
const toggleTodo = produce((draft, id) => {
const todo = draft.find(todo => todo.id === id)
todo.done = !todo.done
})
const baseState = [
/* as is */
]
const nextState = toggleTodo(baseState, "Immer")
第二种方式如果在 React 中使用过 Immer 就很熟悉了,其实就是我们使用 hook setState 的时候,就可以使用这种 curry 的方式:
const [todos, setTodos] = useState([
{
id: "React",
title: "Learn React",
done: true
},
{
id: "Immer",
title: "Try Immer",
done: false
}
]);
const handleToggle = useCallback((id) => {
setTodos(
produce((draft) => {
const todo = draft.find((todo) => todo.id === id);
todo.done = !todo.done;
})
);
}, []);
看完了使用的方式,我们再回到源码中,先看下源码:
produce: IProduce = (base: any, recipe?: any, patchListener?: any) => {
// debugger
// curried invocation
if (typeof base === "function" && typeof recipe !== "function") {
const defaultBase = recipe
recipe = base
const self = this
return function curriedProduce(
this: any,
base = defaultBase,
...args: any[]
) {
return self.produce(base, (draft: Drafted) => recipe.call(this, draft, ...args)) // prettier-ignore
}
}
if (typeof recipe !== "function") die(6)
if (patchListener !== undefined && typeof patchListener !== "function") die(7)
let result
// Only plain objects, arrays, and "immerable classes" are drafted.
if (isDraftable(base)) {
const scope = enterScope(this)
const proxy = createProxy(this, base, undefined)
let hasError = true
try {
result = recipe(proxy)
hasError = false
} finally {
// finally instead of catch + rethrow better preserves original stack
if (hasError) revokeScope(scope)
else leaveScope(scope)
}
if (typeof Promise !== "undefined" && result instanceof Promise) {
return result.then(
result => {
usePatchesInScope(scope, patchListener)
return processResult(result, scope)
},
error => {
revokeScope(scope)
throw error
}
)}
usePatchesInScope(scope, patchListener)
return processResult(result, scope)
} else if (!base || typeof base !== "object") {
result = recipe(base)
if (result === NOTHING) return undefined
if (result === undefined) result = base
if (this.autoFreeze_) freeze(result, true)
return result
} else die(21, base)
}
初看源码可能有点懵逼,我们先通过一个流程图,有个大概的认识:
首先,如果我们使用第二种方式只传入一个 recipe 函数,那么 produce 返回的就是一个新的 curriedProduce 函数,这个函数只做了一件事,就是会执行 produce 自身,以 curriedProduce 的 base 参数作为 state,recipe 函数作为第二个参数执行 produce。所以本质,还是要看后面的逻辑。
其它的一些就是对于不合法参数的判断,然后进行一些错误处理,也就是代码里面出现了很多次的 die 函数的调用,每个数字代表一个错误类型,它定义在 utils 下的 errors.ts 中。
接下来走到 isDraftable(base)
逻辑,Immer 默认是只能代理 plain 对象和数组,还有一种特殊的就是加了 immerable 属性的自定义 class。看官网的一个例子:
import {immerable, produce} from "immer"
class Clock {
[immerable] = true
constructor(hour, minute) {
this.hour = hour
this.minute = minute
}
get time() {
return `${this.hour}:${this.minute}`
}
tick() {
return produce(this, draft => {
draft.minute++
})
}
}
const clock1 = new Clock(12, 10)
const clock2 = clock1.tick()
console.log(clock1.time) // 12:10
console.log(clock2.time) // 12:11
Immer 本身导出了一个 immerable 的 symbol 变量,它定义在 utils 下的 env.ts 中:
export const DRAFTABLE: unique symbol = hasSymbol
? Symbol.for("immer-draftable")
: ("__$immer_draftable" as any)
如果我们给一个 class 加了这个 symbol 属性,那么这个 class 对象的实例就是可以被 produce 进行 proxy。
而对于基本类型 string、number、boolean,走的时候最后一个 else,虽然 produce 函数能正常地执行 recipe 函数,但是只做简单的处理,并且返回结果,并不会走核心的 proxy 逻辑。
我们重点关注当一个对象可以被 Immer proxy 的时候,就会走核心的 proxy 逻辑。重点看这部分代码:
// Only plain objects, arrays, and "immerable classes" are drafted.
f (isDraftable(base)) {
const scope = enterScope(this)
const proxy = createProxy(this, base, undefined)
let hasError = true
try {
result = recipe(proxy)
hasError = false
} finally {
// finally instead of catch + rethrow better preserves original stack
if (hasError) revokeScope(scope)
else leaveScope(scope)
}
if (typeof Promise !== "undefined" && result instanceof Promise) {
return result.then(
result => {
usePatchesInScope(scope, patchListener)
return processResult(result, scope)
},
error => {
revokeScope(scope)
throw error
})
}
usePatchesInScope(scope, patchListener)
return processResult(result, scope)
}
scope
首先 Immer 会通过 enterScope 方法创建一个 scope,那 scope 在 Immer 中是个什么概念了?在 core 目录下有个 scope.ts 文件,用来处理对 scope 的系列操作,文件开头就有注释:
/** Each scope represents a
produce
call. */
意思就是每次调用 produce 都会产生一个 scope,类似于一个调用上下文的概念,用来存储本次 produce 方法调用过程中的一些必要的上下文信息,而 enterScope 其实本质就是调用 createScope 方法:
export interface ImmerScope {
patches_?: Patch[]
inversePatches_?: Patch[] // 如果启用了 patches 功能才会有,存的是用来实现撤销和重做等功能的信息
canAutoFreeze_: boolean // 用来存 state 是否可以冻结的标识
drafts_: any[] // 存的是 draft 对象
parent_?: ImmerScope // 关联的 parent scope
patchListener_?: PatchListener // 如果启用了 patches 功能才会有,存的是 patch 监听函数
immer_: Immer // immer class 实例
unfinalizedDrafts_: number // 没有被 finalize 的 draft 对象个数
}
export function enterScope(immer: Immer) {
return (currentScope = createScope(currentScope, immer))
}
function createScope(
parent_: ImmerScope | undefined,
immer_: Immer
): ImmerScope {
return {
drafts_: [],
parent_,
immer_,
// Whenever the modified draft contains a draft from another scope, we
// need to prevent auto-freezing so the unowned draft can be finalized.
canAutoFreeze_: true,
unfinalizedDrafts_: 0
}
}
一个基本的 scope,如果没有开启 patch 功能,主要包含了 canAutoFreeze_、drafts_、parent_、unfinalizedDrafts_、immer_ 等属性。
创建完 scope 后,接下来到了 proxy 的流程,调用 createProxy,先看下 createProxy 方法的实现:
export function createProxy<T extends Objectish>(
immer: Immer,
value: T,
parent?: ImmerState
): Drafted<T, ImmerState> {
// precondition: createProxy should be guarded by isDraftable, so we know we can safely draft
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)
const scope = parent ? parent.scope_ : getCurrentScope()
scope.drafts_.push(draft)
return draft
}
createProxy 首先要根据不同的数据类型调用不同的方法创建类型为 Drafted 的 draft 对象,这里的 draft 对象其实就是我们传给 produce 方法第二个参数中的函数的 draft 参数,我们一般就是通过 draft 参数来修改数据,我们先来看下 Drafted 类型定义:
export type Drafted<Base = any, T extends ImmerState = ImmerState> = {
[DRAFT_STATE]: T
} & Base
export const DRAFTABLE: unique symbol = hasSymbol
? Symbol.for("immer-draftable")
: ("__$immer_draftable" as any)
实际上 Drafted 的定义是比较宽泛的,由两个泛型来决定它的具体类型。一般分为几类,从创建 draft 对象的代码我们可以看到,在被代理的数据是 Map、Set、数组和对象、或者启用了 es5 的插件,它们对应创建的 draft 对象都是略有不同,以最常见的数组和对象为例,它创建出来的 draft 对象大概是这样:
如果在支持 Proxy 的环境下创建,本质就是一个 Proxy 实例。
而对于 Map 的数据类型,创建的 draft 就如下图:
Map 和 Set 创建的 draft 不是一个 Proxy 实例,这里面有些猫腻,后面讲到 mapset 插件的时候会重点分析。
因为创建 draft 对象的时候会根据不同的数据类型,是否支持 Proxy,是否开启了 es5 插件,来区分不同的创建 draft 的方法,所以创建出来的 draft 对象会有些区别。
proxy
我们先来看最基本的对象和数组的 proxy 函数,当传入的数据是一个 plain 对象或者数组时,最后调用的是 createProxyProxy 方法,这个方法代码如下:
/**
* Returns a new draft of the `base` object.
*
* The second argument is the parent draft-state (used internally).
*/
export function createProxyProxy<T extends Objectish>(
base: T,
parent?: ImmerState
): Drafted<T, ProxyState> {
const isArray = Array.isArray(base)
const state: ProxyState = {
type_: isArray ? ProxyType.ProxyArray : (ProxyType.ProxyObject as any),
// Track which produce call this is associated with.
scope_: parent ? parent.scope_ : getCurrentScope()!,
// True for both shallow and deep changes.
modified_: false,
// Used during finalization.
finalized_: false,
// Track which properties have been assigned (true) or deleted (false).
assigned_: {},
// The parent draft state.
parent_: parent,
// The base state.
base_: base,
// The base proxy.
draft_: null as any, // set below
// The base copy with any updated values.
copy_: null,
// Called by the `produce` function.
revoke_: null as any,
isManual_: false
}
// the traps must target something, a bit like the 'real' base.
// but also, we need to be able to determine from the target what the relevant state is
// (to avoid creating traps per instance to capture the state in closure,
// and to avoid creating weird hidden properties as well)
// So the trick is to use 'state' as the actual 'target'! (and make sure we intercept everything)
// Note that in the case of an array, we put the state in an array to have better Reflect defaults ootb
let target: T = state as any
let traps: ProxyHandler<object | Array<any>> = objectTraps
if (isArray) {
target = [state] as any
traps = arrayTraps
}
const {revoke, proxy} = Proxy.revocable(target, traps)
state.draft_ = proxy as any
state.revoke_ = revoke
return proxy as any
}
这个方法最后会构造一个 state 对象,然后使用 Proxy.revocable 方法去代理这个对象。
看到这里我们可能会有点懵,我有两个疑问:
- 为什么不直接代理我们传入的数据对象,而是构造了一个 state 对象作为 target 去代理?
- 这里代理数据的时候,为什么没有使用 new Proxy 这种方式去代理,而是通过 Proxy.revocable 这个方法?
第一个问题,看完了大部分源码,我的理解是,自己构造一个新的对象去代理的优点是:
- 因为它可以去存很多的数据上下文,通过放在这个新的对象属性里面,这样就不用通过创建很多的全局对象传来传去,可维护性不好;
- 还有就是不同的数据结构:数组、对象、Map、Set 等数据结构不一致,如果不构造一个新的 state 对象,那么每种数据结构的代理实现就要写很多不同的逻辑,如果是统一的 state 对象,那么后续做各种逻辑处理的时候只需要关注类型基本一直的 state 对象即可。
第二个问题,那我们首先要了解,Proxy.revocable 的作用是什么?看了下 MDN 的文档:
Proxy.revocable() 方法可以用来创建一个可撤销的代理对象。
所以从上面的源码中,我们看到,被代理的 ProxyState 对象,会存储 Proxy.revocable 返回的 revoke 方法,方便后面使用。
在 Immer 的设计中,如果一个 draft 对象最终完成了 ”finalize“ 后,实际上,draft 对象是会被 revoke 的,这样能防止一些意外的对 draft 的修改,从而造成数据状态错乱的问题,所以 Immer 使用了 Proxy.revocable 的方式去代理对象。
对于最后的 finalize 之后对 draft 的 revoke,后面的内容会提到。
既然我们知道 Immer 构造了一个新的 ProxyState 对象去代理,我们了解一下 ProxyState 有哪些属性,先看下它的定义:
export interface ImmerBaseState {
parent_?: ImmerState // 父 state 对象
scope_: ImmerScope // 前面提到过的 scope 对象
modified_: boolean // 是否已经修改过了,也就是执行过传进来的 recipe 函数
finalized_: boolean // 是否完成了最后的 finalize 流程
isManual_: boolean // 是否手动?
}
interface ProxyBaseState extends ImmerBaseState {
assigned_: {
[property: string]: boolean // 用来记住删除属性和修改属性的操作,属性被修改就会设置为 true,被删除设置为 false
}
parent_?: ImmerState
revoke_(): void // 调用 Proxy.revocable 返回的 revoke 方法
}
export interface ProxyObjectState extends ProxyBaseState {
type_: ProxyType.ProxyObject // state 类型
base_: any // 原始的数据
copy_: any // 存修改后的数据,所有对 draft 的增删改查都会被代理到这个对象上
draft_: Drafted<AnyObject, ProxyObjectState> // draft 对象
}
export interface ProxyArrayState extends ProxyBaseState {
type_: ProxyType.ProxyArray
base_: AnyArray
copy_: AnyArray | null
draft_: Drafted<AnyArray, ProxyArrayState>
}
type ProxyState = ProxyObjectState | ProxyArrayState
ProxyState 最后是由 ProxyObjectState 和 ProxyArrayState 的联合类型组成,而这两个类型都是继承自ProxyBaseState ,ProxyBaseState 继承自 ImmerBaseState。
这里有几个比较重要的属性单独提一下,一个是 base_,一个是 copy_。使用 base 存储原始的数据,方便后面使用。使用 copy_ 存储执行 recipe 函数后修改后的数据,方便在 proxy 的时候使用,也能在下一次修改之前拿到上一次修改后的数据。
在执行 proxy traps 函数的时候,通过 lastest 函数获取数据实际上就会优先从 copy_ 中读取最新的数据:
export function latest(state: ImmerState): any {
return state.copy_ || state.base_
}
那如果 Proxy 代理的是 ProxyState,那么对于 target 的修改又是怎么映射到我们传入的数据上的了?
其实很简单,前面我们刚说到,ProxyState 对象有 base__ 和 copy_ 两个属性,对 ProxyState 对象的任何操作只要在 traps 里面映射到 copy_ 上就行了,以对象的 objectTraps 为例:_
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
},
has(state, prop) {
return prop in latest(state)
},
ownKeys(state) {
return Reflect.ownKeys(latest(state))
},
set(
state: ProxyObjectState,
prop: string /* strictly not, but helps TS */,
value
) {
const desc = getDescriptorFromProto(latest(state), prop)
if (desc?.set) {
// special case: if this write is captured by a setter, we have
// to trigger it with the correct context
desc.set.call(state.draft_, value)
return true
}
if (!state.modified_) {
// the last check is because we need to be able to distinguish setting a non-existing to undefined (which is a change)
// from setting an existing property with value undefined to undefined (which is not a change)
const current = peek(latest(state), prop)
// special case, if we assigning the original value to a draft, we can ignore the assignment
const currentState: ProxyObjectState = current?.[DRAFT_STATE]
if (currentState && currentState.base_ === value) {
state.copy_![prop] = value
state.assigned_[prop] = false
return true
}
if (is(value, current) && (value !== undefined || has(state.base_, prop)))
return true
prepareCopy(state)
markChanged(state)
}
if (
state.copy_![prop] === value &&
// special case: NaN
typeof value !== "number" &&
// special case: handle new props with value 'undefined'
(value !== undefined || prop in state.copy_)
)
return true
// @ts-ignore
state.copy_![prop] = value
state.assigned_[prop] = true
return true
},
deleteProperty(state, prop: string) {
// The `undefined` check is a fast path for pre-existing keys.
if (peek(state.base_, prop) !== undefined || prop in state.base_) {
state.assigned_[prop] = false
prepareCopy(state)
markChanged(state)
} else {
// if an originally not assigned property was deleted
delete state.assigned_[prop]
}
// @ts-ignore
if (state.copy_) delete state.copy_[prop]
return true
},
// Note: We never coerce `desc.value` into an Immer draft, because we can't make
// the same guarantee in ES5 mode.
getOwnPropertyDescriptor(state, prop) {
const owner = latest(state)
const desc = Reflect.getOwnPropertyDescriptor(owner, prop)
if (!desc) return desc
return {
writable: true,
configurable: state.type_ !== ProxyType.ProxyArray || prop !== "length",
enumerable: desc.enumerable,
value: owner[prop]
}
},
defineProperty() {
die(11)
},
getPrototypeOf(state) {
return Object.getPrototypeOf(state.base_)
},
setPrototypeOf() {
die(12)
}
}
我们看下 get traps,它首先会通过 lastest 方法获取到最新的数据 source,如果需要访问的属性不存在,则通过 readPropFromProto 方法去读原型上的属性。
如果存在,则从 source 中取出 value,如果本次 get 操作,ProxyState 已经之前走完一轮 finalize 的过程或者 value 不是能被 proxy 的值,例如是 primitive 的数据类型,则直接返回。
否则,这里还会做一个 lazy proxy 的处理。就是当我们需要 get 的属性是一个对象或者数组,或者说任何可以 draftable 的数据类型,就会再走一次 createProxy 流程。这个处理跟 Vue3 的 Proxy 处理有点类似,对于嵌套的对象数据,在真正访问的嵌套对象的时候才会走一次新的 Proxy 过程,这样就使得嵌套的对象属性也能被代理,也不会因为对象嵌套如果刚开始去递归代理而带来一定的性能问题。
对于其它的 traps ,都是类似的思路,真正被处理的是通过 lastest 方法返回的 source 数据,也就是存在 copy_属性中的数据。如果刚开始 copy_ 为 null,则会通过 prepareCopy(state) 方法,给 copy_做初始化:
export function prepareCopy(state: {base_: any; copy_: any}) {
if (!state.copy_) {
state.copy_ = shallowCopy(state.base_)
}
}
而对于数组的 arraryTraps 的处理基本是一样的,这里就不重复解读了。
而对于 Map 和 Set 等数据结构,”proxy“ 方式是不同的,这个逻辑单独封装在 plugins/mapset.ts 下,后面我们讲到插件的时候再专门解读。
创建完 proxy 对象后,接下来就会执行我们传给 produce 方法的第二个参数的 recipe 方法:
try {
result = recipe(proxy)
hasError = false
} finally {
// finally instead of catch + rethrow better preserves original stack
if (hasError) revokeScope(scope)
else leaveScope(scope)
}
执行完得到一个 result,实际上 reecipe 方法是支持返回 draft 对象的,但是大多数场景一般不返回任何值。而且 result 也支持返回 Promise 对象,也就是下面的一个判断:
if (typeof Promise !== "undefined" && result instanceof Promise) {
return result.then(
result => {
usePatchesInScope(scope, patchListener)
return processResult(result, scope)
},
error => {
revokeScope(scope)
throw error
})
}
跟正常不返回 result 没有什么太大的区别,如果返回了一个 Promise,则先要执行 Promise.then 方法,然后拿到最后的 result,再执行最后的 processResult(result, scope) 方法。这里的 usePatchesInScope(scope, patchListener) 可以先忽略,在没有开启 patches 插件的场景下,这个函数是不会做过多关于 patch 的逻辑处理的。
所以 produce 最后一步是执行 processResult 方法,我们来看这个方法做了什么。
finalize
前面我们反复提到了 finalize 的过程,实际上 processResult 背后的逻辑就是处理对 draft 修改后的 finalize 流程。它的代码封装在 core/finalize.ts 文件中:
export function processResult(result: any, scope: ImmerScope) {
scope.unfinalizedDrafts_ = scope.drafts_.length
const baseDraft = scope.drafts_![0]
const isReplaced = result !== undefined && result !== baseDraft // recipe 是否返回了 result
if (!scope.immer_.useProxies_)
getPlugin("ES5").willFinalizeES5_(scope, result, isReplaced)
if (isReplaced) {
if (baseDraft[DRAFT_STATE].modified_) {
revokeScope(scope)
die(4)
}
if (isDraftable(result)) {
// Finalize the result in case it contains (or is) a subset of the draft.
result = finalize(scope, result)
if (!scope.parent_) maybeFreeze(scope, result)
}
if (scope.patches_) {
getPlugin("Patches").generateReplacementPatches_(
baseDraft[DRAFT_STATE],
result,
scope.patches_,
scope.inversePatches_!
)
}
} else {
// Finalize the base draft.
result = finalize(scope, baseDraft, [])
}
revokeScope(scope)
if (scope.patches_) {
scope.patchListener_!(scope.patches_, scope.inversePatches_!)
}
return result !== NOTHING ? result : undefined
}
这个函数就是对 draft 修改后的 finalize 处理,首先我们先忽略不使用 Proxy 的处理。
在我们一开始给的例子中,我们的 recipe 方法也没有返回值,所以最后是走到了 isReplaced 判断的 else 逻辑,即执行了 finalize 方法:
function finalize(rootScope: ImmerScope, value: any, path?: PatchPath) {
// Don't recurse in tho recursive data structures
if (isFrozen(value)) return value
const state: ImmerState = value[DRAFT_STATE]
// A plain object, might need freezing, might contain drafts
if (!state) {
each(
value,
(key, childValue) =>
finalizeProperty(rootScope, state, value, key, childValue, path),
true // See #590, don't recurse into non-enumerable of non drafted objects
)
return value
}
// Never finalize drafts owned by another scope.
if (state.scope_ !== rootScope) return value
// Unmodified draft, return the (frozen) original
if (!state.modified_) {
maybeFreeze(rootScope, state.base_, true)
return state.base_
}
// Not finalized yet, let's do that now
if (!state.finalized_) {
state.finalized_ = true
state.scope_.unfinalizedDrafts_--
const result =
// For ES5, create a good copy from the draft first, with added keys and without deleted keys.
state.type_ === ProxyType.ES5Object || state.type_ === ProxyType.ES5Array
? (state.copy_ = shallowCopy(state.draft_))
: state.copy_
// Finalize all children of the copy
// For sets we clone before iterating, otherwise we can get in endless loop due to modifying during iteration, see #628
// Although the original test case doesn't seem valid anyway, so if this in the way we can turn the next line
// back to each(result, ....)
each(
state.type_ === ProxyType.Set ? new Set(result) : result,
(key, childValue) =>
finalizeProperty(rootScope, state, result, key, childValue, path)
)
// everything inside is frozen, we can freeze here
maybeFreeze(rootScope, result, false)
// first time finalizing, let's create those patches
if (path && rootScope.patches_) {
getPlugin("Patches").generatePatches_(
state,
path,
rootScope.patches_,
rootScope.inversePatches_!
)
}
}
return state.copy_
}
这个方法的核心逻辑就是当 ProxyState 对象还没有被 finalized 的时候,先对 state 对象的属性进行 finalize 处理,然后根据不同的数据类型先从 copy_ 克隆出最后返回的数据。如果是对象或者数组类型,还要对数据的每个属性或者每一项进行 finalizeProperty 处理:
function finalizeProperty(
rootScope: ImmerScope,
parentState: undefined | ImmerState,
targetObject: any,
prop: string | number,
childValue: any,
rootPath?: PatchPath
) {
if (global.__DEV__ && childValue === targetObject) die(5)
if (isDraft(childValue)) {
const path =
rootPath &&
parentState &&
parentState!.type_ !== ProxyType.Set && // Set objects are atomic since they have no keys.
!has((parentState as Exclude<ImmerState, SetState>).assigned_!, prop) // Skip deep patches for assigned keys.
? rootPath!.concat(prop)
: undefined
// Drafts owned by `scope` are finalized here.
const res = finalize(rootScope, childValue, path)
set(targetObject, prop, res)
// Drafts from another scope must prevented to be frozen
// if we got a draft back from finalize, we're in a nested produce and shouldn't freeze
if (isDraft(res)) {
rootScope.canAutoFreeze_ = false
} else return
}
// Search new objects for unfinalized drafts. Frozen objects should never contain drafts.
if (isDraftable(childValue) && !isFrozen(childValue)) {
if (!rootScope.immer_.autoFreeze_ && rootScope.unfinalizedDrafts_ < 1) {
// optimization: if an object is not a draft, and we don't have to
// deepfreeze everything, and we are sure that no drafts are left in the remaining object
// cause we saw and finalized all drafts already; we can stop visiting the rest of the tree.
// This benefits especially adding large data tree's without further processing.
// See add-data.js perf test
return
}
finalize(rootScope, childValue)
// immer deep freezes plain objects, so if there is no parent state, we freeze as well
if (!parentState || !parentState.scope_.parent_)
maybeFreeze(rootScope, childValue)
}
}
也就是当对象或者数组的某一项也是对象或者数组,则递归进行 finalize 处理。
处理完之后会执行 maybeFreeze(rootScope, result, false) 方法在,这个方法的作用是调用 Object.freeze 方法对 result 进行冻结,防止用户直接修改 result。也就是说,如果一个对象经过 produce 方法调用进行处理后,新的 nextState 默认是不允许直接修改的。我们看一个例子:
const baseState = [{title: "Learn TypeScript", done: true}]
const nextState = produce(baseState, draftState => {
console.log("original state", original(draftState))
baseState.push({title: "Immer", done: false})
baseState[0].done = false
console.log("current state", current(draftState))
})
nextState[0].title = 'show freeze error'
console.log(nextState)
我们就会在控制台看到这个报错: 当然我们可以通过 Immer 提供的 API setAutoFreeze 选择手动关掉冻结的操作:
import produce, {setAutoFreeze} from "./immer"
setAutoFreeze(false)
const baseState = [{title: "Learn TypeScript", done: true}]
const nextState = produce(baseState, draftState => {
baseState.push({title: "Immer", done: false})
baseState[0].done = false
})
nextState[0].title = "show freeze error" // 不会报错
console.log(nextState)
执行完冻结方法后,最后 finalize 就会返回最终的 result 给到外面,也就是我们通过 produce 方法得到最后的 nextState。
总结
看完 core 的代码,总体来说,没有很复杂的逻辑。就是需要理解 Immer 的一些设计,比如 scope、freeze 的功能,produce 灵活的使用方式,除了基本的使用场景,也能结合 React setState 使用。最后对 core 源码做个总结:
- 调用 produce 支持灵活的传参,对于一般的 plain 对象和数组,Immer 会使用 Proxy 进行代理,对于 Primitive 类型:字符串、布尔、数字,immer 不会走 proxy 逻辑,只是执行 recipe 函数,返回结果;
- 每次 produce 执行,Immer 都会产生一个 scope,用来存储当前的一些上下文信息,比如当前 Immer 类的实例、draft 对象等;
- Immer 默认使用 Proxy 进行代理,但是它不会直接代理我们传入的数据,而是在内部构造了 ProxyState ,Proxy 代理的是这个 state 对象。而且代理的方式也不是使用一般的 new Proxy 方式,而是使用 Proxy.revocable API,在每次执行完后可以 revoke 代理的目标,防止对 target 的意外修改;
- 最后通过 finalize 进行结果处理,并返回 nextState。finalize 的过程,实际上做的就是从 state 将 copy_ 拿出来,进行复制一份,然后默认对返回给用户的 result 进行冻结操作,不让外部直接修改 nextState。
本文是精读 Immer 源码的第一篇,下一篇会将解读 plugins 部分的源码,敬请期待。