精读 Immer 源码(一)

1,767 阅读18分钟

简介

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 架构 (1).png 整个 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 接口,这个接口有两个核心的方法:produceproduceWithPatches,而 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)
    }

初看源码可能有点懵逼,我们先通过一个流程图,有个大概的认识: ​

Immer produce 执行流程.png

首先,如果我们使用第二种方式只传入一个 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 对象大概是这样: ​

image.png 如果在支持 Proxy 的环境下创建,本质就是一个 Proxy 实例。 ​

而对于 Map 的数据类型,创建的 draft 就如下图: image.png

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 这个方法?

第一个问题,看完了大部分源码,我的理解是,自己构造一个新的对象去代理的优点是:

  1. 因为它可以去存很多的数据上下文,通过放在这个新的对象属性里面,这样就不用通过创建很多的全局对象传来传去,可维护性不好
  2. 还有就是不同的数据结构:数组、对象、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)

我们就会在控制台看到这个报错: image.png 当然我们可以通过 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 部分的源码,敬请期待。