Introduction to the

Immer is an excellent open source project and was awarded the title of “one of the most influential JS open Source projects” in 2019. It makes it easy to manipulate “immutable” data with JS in an intuitive way, and React, which relies on this data principle, is the framework that benefits the most.

As an example, when manipulating data without Immer, this is how it is done:

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"})
Copy the code

With Immer, the code becomes simpler:

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})})Copy the code

Immer allows us to modify data immutable in a way that is more consistent with the JS way of modifying data. Immer allows us to modify data immutable in a way that is more consistent with the JS way of modifying data.

Immer’s source code is only about 1000 lines long, so its package is about 3KB compressed using Gzip. Support common array, object, Map, Set and other data structure operations, also manually enabled plug-in support fallback to ES5, very powerful.

This is the first Immer source code analysis, the main analysis of core part of the relevant source code.

Architecture diagram

Although Immer has not much source code, it has also designed a simple plug-in mechanism. Here is a diagram to understand the architecture behind it.

The entire Immer source code, there are two core source code:

  • Core is mainly the realization of the Core functions of Immer, and then split into proxy, Scope, Finalize and other three modules to assist the implementation of immerClass Core, Current implementation is some independent API exposed to the user;
  • Plugins include es5, Mapset, and Patches. These Plugins can be actively enabled through the API provided by Immer. All Plugins are disabled by default.

Now start to analyze the specific source code of the core module.

The core source

The entire core implementation is in the file immerclass. ts, which implements the Immer class:

interface ProducersFns {
    produce: IProduce
    produceWithPatches: IProduceWithPatches
}

export class Immer implements ProducersFns {
    useProxies_: boolean = hasProxies

    autoFreeze_: boolean = true

    constructor(config? : {useProxies? :boolean; autoFreeze? :boolean}) {
        if (typeofconfig? .useProxies ==="boolean")
            this.setUseProxies(config! .useProxies)if (typeofconfig? .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) = > {
        // Omit some code
    }

    produceWithPatches: IProduceWithPatches = (
	arg1: any, arg2? :any, arg3? :any) :any= > {
        // Omit some code
    }

    createDraft<T extends Objectish>(base: T): Draft<T> {
        // Omit the implementation code
    }

    finishDraft<D extends Draft<any>>( draft: D, patchListener? : PatchListener ): Dextends Draft<infer T> ? T : never {
       // Omit the implementation code
    }

    /** * 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 {
        // Omit the implementation code}}Copy the code

The Immer class implements the ProducersFns interface, which has two core methods: Produce and produceWithPatches, while Produce is actually the most core API when we use IMMER. It is exported from imMER class, so it is an instance method of imMER class. The produceWithPatches API is an enhanced version of produce with patch function, which will be mentioned in the subsequent analysis of the source code of the plug-in. Therefore, patches related parts will be skipped in the analysis of the source code of this part.

Other apis: createDraft, finishDraft, setAutoFreeze, setUseProxies, applyPatches, etc. are some extended apis, will only be used in some specific scenarios, will also be introduced in the subsequent source code analysis.

produce

Before we look at the source code, we need to know the two ways to use produce, the most basic way to use:

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
})
Copy the code

Alternatively, the first argument is passed directly to a method:

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")
Copy the code

The second method will be familiar if you have used Immer in React. In fact, we can use this curry method when we use hook setState:

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; })); } []);Copy the code

Having seen how to use it, let’s go back to the source code and take a look at the source code:

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")
Copy the code

The second method will be familiar if you have used Immer in React. In fact, we can use this curry method when we use hook setState:

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; })); } []);Copy the code

Having seen how to use it, let’s go back to the source code and take a look at the source code:

produce: IProduce = (base: any, recipe? :any, patchListener? :any) = > {
    // debugger
    // curried invocation
    if (typeof base === "function" && typeofrecipe ! = ="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 (typeofrecipe ! = ="function") die(6)
        if(patchListener ! = =undefined && typeofpatchListener ! = ="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 ||typeofbase ! = ="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)
    }
Copy the code

At first look at the source code may be a little meng force, we first through a flow chart, have a general understanding:

First, if we use the second method and just pass in a recipe function, produce returns a new curriedProduce function that does only one thing: it executes produce itself, Take the base argument of curriedProduce as state and the recipe function as the second argument to execute produce. So the essence, again, depends on the logic.

Others are checking for invalid arguments and then doing some error handling, i.e. calling the die function many times in the code, each number representing an error type, which is defined in errors.ts under utils.

Moving on to the isDraftable(Base) logic, Immer can only proxy plain objects and arrays by default, and there is a special custom class with the immerable attribute. Here’s an example:

import {immerable, produce} from "immer"

class Clock {
    [immerable] = true

    constructor(hour, minute) {
        this.hour = hour
        this.minute = minute
    }

    get time() {
        return `The ${this.hour}:The ${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) / /"
Copy the code

Immer itself exports a symbol variable of immerable, which is defined in env.ts under utils:

export const DRAFTABLE: unique symbol = hasSymbol
    ? Symbol.for("immer-draftable")
    : ("__$immer_draftable" as any)
Copy the code

If we add the symbol property to a class, the class instance can be proxyed by Produce.

For basic types like string, number, or Boolean, else is the last one. Produce does the recipe function normally, but it does simple processing and returns the result instead of following the core proxy logic.

We focused on the core proxy logic when an object can be Immer proxy. Focus on this section of code:

// 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)
}
Copy the code

scope

Immer creates a scope using the enterScope method. What is scope in Immer? There is a scope.ts file in the core directory that handles a series of operations on scope.

/** Each scope represents a produce call. */

Each call to produce generates a scope, similar to the concept of an invocation context, which stores the necessary context information during this call to produce.

export interfaceImmerScope { patches_? : Patch[] inversePatches_? : Patch[]// Patches are available only if patches are enabled, and contain information used to implement such functions as undoing and redoing
    canAutoFreeze_: boolean // State can be frozen
    drafts_: any[] // Save draft objectsparent_? : ImmerScope// The associated parent scopepatchListener_? : PatchListener// Patches are available only if the patches function is enabled
    immer_: Immer // Immer class instance
    unfinalizedDrafts_: number  // There is no number of draft objects to finalize
}

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}}Copy the code

A basic scope, if the patch function is not enabled, mainly contains canAutoFreeze_, drafts_, parent_, unfinalizedDrafts_, immer_ and other properties.

Create scope scope scope scope scope scope scope scope scope Scope Scope Scope Scope

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
}
Copy the code

This lets you create a Drafted Draft object, which is exactly the draft argument we passed to the function in the second argument to the Produce method. Let’s take a look at the Drafted type definition:

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)
Copy the code

The definition of Drafted is rather broad, with two generics deciding what type it is. Generally divided into several categories, from the code to create draft objects, we can see that the data to be propped up is Map, Set, array and object, or es5-enabled plug-in, they correspond to the creation of draft objects are slightly different. Take the most common array and object as an example. The draft object it creates looks something like this:

If created in a proxy-enabled environment, it is essentially a Proxy instance.

For the Map data type, the draft is created as follows:

Draft created by Map and Set is not a Proxy instance. There are some tricks involved in this, which will be analyzed later when we discuss mapSet plug-in.

A draft object is created using different methods based on its data type, whether Proxy is supported, and whether the ES5 plug-in is enabled. Therefore, the created draft objects may differ.

proxy

The createProxyProxy method is called when the data passed in is a plain object or array. The code for this method is as follows:

/** * 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
}
Copy the code

The method ends up constructing a state object, which is then proxy.revocable.

We may be a little confused by this, but I have two questions:

  • Why not directly proxy the data object we pass in instead of constructing a state object as a target to proxy?
  • Why Proxy data through Proxy. Revocable instead of using new Proxy?

First question, after reading most of the source code, my understanding is that the advantages of constructing a new object to proxy are:

  1. Because it can store a lot of data context, by putting it in this new object property, so it doesn’t have to create a lot of global objects to pass around, which is not very maintainable;
  2. There are also different data structures: Array, object, Map, Set and other data structures are inconsistent. If a new state object is not constructed, then the proxy implementation of each data structure will have to write a lot of different logic. If the state object is uniform, then the subsequent logic processing only needs to focus on the state object whose type is basically consistent.

Second question, we should first understand the role of Proxy. Revocable? Have a look at the MDN documentation:

The proxy.revocable () method can be used to create a revocable Proxy object.

The revoke method returned by proxy. revocable will be stored in the ProxyState object for later use.

In Immer’s design, if a Draft object is finalize, the Revoke will be implemented to finalize the draft object. This prevents accidental changes to the draft from causing data state disorder. So Immer uses proxy. revocable to Proxy objects.

Revoke to draft after finalize is referred to later.

Now that we know that Immer constructs a new ProxyState object to delegate, let’s look at the properties of ProxyState. Let’s look at its definition:

export interfaceImmerBaseState { parent_? : ImmerState// Parent state object
    scope_: ImmerScope // The scope object mentioned earlier
    modified_: boolean // Has it been modified to execute the recipe function passed in
    finalized_: boolean  // Whether the final Finalize process is completed
    isManual_: boolean  // Is it manual?
}

interface ProxyBaseState extends ImmerBaseState {
    assigned_: {
        [property: string] :boolean  // Remember to delete and modify properties. Properties are set to true when modified and false when deleted} parent_? : ImmerState revoke_():void  // Call proxy. revocable to return the REVOKE method
}

export interface ProxyObjectState extends ProxyBaseState {
    type_: ProxyType.ProxyObject / / state type
    base_: any // Raw data
    copy_: any  // Save the modified data. All additions, deletions, changes and checks on the draft will be proxy to this object
    draft_: Drafted<AnyObject, ProxyObjectState> / / draft object
}

export interface ProxyArrayState extends ProxyBaseState {
    type_: ProxyType.ProxyArray
    base_: AnyArray
    copy_: AnyArray | null
    draft_: Drafted<AnyArray, ProxyArrayState>
}

type ProxyState = ProxyObjectState | ProxyArrayState
Copy the code

ProxyState is finally a combination of ProxyObjectState and ProxyArrayState, both of which are derived from ProxyBaseState, which is derived from ImmerBaseState.

There are a few more important attributes to mention separately, one is base_ and the other is copy_. Use Base to store original data for later use. Use copy_ to store the modified data after executing the recipe function, which is convenient for proxy use and can be retrieved before the next change.

When the proxy traps function is executed, the lastest function actually reads the latest data from copy_ first:

export function latest(state: ImmerState) :any {
    return state.copy_ || state.base_
}
Copy the code

If the Proxy represents ProxyState, how does the change to target map to the data we pass in?

There are two properties of the ProxyState object: base__ and copy_. Any operation on the ProxyState object can be mapped to copy_ in traps

export const objectTraps: ProxyHandler<ProxyState> = {
	get(state, prop) {
            if (prop === DRAFT_STATE) return state

            const source = latest(state) // Here is the latest data
            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_! [propas 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
		constcurrentState: 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
                    typeofvalue ! = ="number" &&
                    // special case: handle new props with value 'undefined'(value ! = =undefined || prop in state.copy_)
		)
                return true

            // @ts-ignorestate.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)}}Copy the code

Get traps, which first fetch the latest data source using the lastest method, and readPropFromProto, which reads the property on the prototype if the property to be accessed does not exist.

If it exists, fetch value from source. If this get operation, ProxyState has gone through finalize process before or value is not a value that can be proxy, for example, the data type of Primitive, then return value directly.

Otherwise, a lazy proxy is done. When the property we want to get is an object or array, or any data type that can be drafTable, we go through the createProxy process again. This processing is similar to Vue3 Proxy processing. For nested object data, a new Proxy process will be performed only when the nested object is actually accessed. In this way, the nested object attributes can also be Proxy, and there will be no performance problems caused by recursive Proxy at the beginning of object nesting.

For other traps, the idea is similar; what is really processed is the source data returned by the lastest method, which is the data in the copy_ attribute. If copy_ is initially null, we initialize copy_ using prepareCopy(state) :

export function prepareCopy(state: {base_: any; copy_: any}) {
    if(! state.copy_) { state.copy_ = shallowCopy(state.base_) } }Copy the code

The arraryTraps of arrays are treated basically the same, so we won’t repeat the interpretation here.

For data structures such as maps and sets, the “proxy” approach is different. This logic is encapsulated separately in plugins/mapset.ts, which we will explain later when we talk about plug-ins.

After creating the proxy object, we then execute the recipe method, the second parameter we passed to the produce method:

try {
    result = recipe(proxy)
    hasError = false
} finally {
    // finally instead of catch + rethrow better preserves original stack
    if (hasError) revokeScope(scope)
    else leaveScope(scope)
}
Copy the code

The reecIPE method actually supports returning draft objects, but most scenarios generally do not return any value. And result also supports returning a Promise object, which is one of the following:

if (typeof Promise! = ="undefined" && result instanceof Promise) {
    return result.then(
	result= > {
            usePatchesInScope(scope, patchListener)
            return processResult(result, scope)
    },
    error= > {
	revokeScope(scope)
	throw error
    })
}
Copy the code

If a Promise is returned, the promise.then method is executed, the final result is returned, and the final processResult(result, scope) method is executed. Here, usePatchesInScope(Scope, patchListener) can be ignored for the moment. In the scenario where patches are not enabled, this function will not perform too much logical processing on patch.

So the last step for Produce is to execute the processResult method, and let’s see what that method does.

finalize

Previously, we repeatedly mentioned the finalize process. In fact, the logic behind processResult is to process the Finalize process after draft modification. Its code is wrapped in the core/finalize. Ts file:

export function processResult(result: any, scope: ImmerScope) {
    scope.unfinalizedDrafts_ = scope.drafts_.length
    constbaseDraft = scope.drafts_! [0]
    constisReplaced = result ! = =undefined&& result ! == baseDraft// recipe returns 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_!) }returnresult ! == NOTHING ? result :undefined
}
Copy the code

This function is finalize processing after draft modification. First of all, we ignore processing without Proxy.

In the example we gave at the beginning, our recipe method didn’t return a value either, so we ended up with the else logic of isact judgment, which implemented the Finalize method:

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_
}
Copy the code

The core logic of this method is that when the ProxyState object was not finalized, finalize the properties of the state object first, and clone the last returned data from copy_ according to different data types. FinalizeProperty is also applied to each property or item of the data if it is an object or array type:

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)) {
	constpath = rootPath && parentState && parentState! .type_ ! == ProxyType.Set &&// Set objects are atomic since they have no keys.! has((parentStateasExclude<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) } }Copy the code

That is, when an object or array is also an object or array, Finalize processing will be performed recursively.

After processing, maybeFreeze(rootScope, result, false) is executed. This method is used to freeze result by calling object. freeze to prevent the user from directly modifying result. That is, if an object is processed by the Produce method call, the new nextState does not allow direct modification by default. Let’s look at an example:

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)
Copy the code

We will see this error on the console:The Immer API setAutoFreeze allows you to manually turn off the freeze:

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" // No error will be reported
console.log(nextState)
Copy the code

After the freezing method is executed, finalize will return the final result to the outside, that is, produce will be used to obtain the final nextState.

conclusion

If you look at core code, in general, it doesn’t have very complicated logic. It is necessary to understand the design of Immer, such as scope and Freeze functions. Produce can be used flexibly in basic scenarios and React setState. Finally, a summary of core source code:

  • Produce supports flexible argument passing. Immer uses Proxy for plain objects and arrays. For Primitive types: Strings, booleans, numbers. Immer doesn’t use proxy logic, it just calls recipe and returns the result.
  • Each time produce is executed, Immer generates a scope to store the current context information, such as the current Immer class instance, draft object, and so on.
  • Immer uses Proxy by default, but it does not directly Proxy the data we pass in. Instead, it constructs ProxyState internally, and Proxy proxies this state object. Revocable API can be used to revoke the target of the Proxy after each execution to prevent accidental changes to the target.
  • Finally, the result is processed by Finalize, and nextState is returned. The process of Finalize, in fact, is to take copy_ out of state and copy a copy, and then freeze the result returned to the user by default, so as not to let the external directly modify nextState.

This is the first article to read the plugins section of the Immer source code closely, so stay tuned for the next article.