Example

import produce from "immer"

const baseState = [
    {
        todo: "Learn typescript".done: true
    },
    {
        todo: "Try immer".done: false}]// baseState remains the same, nextState is the new object after the change
const nextState = produce(baseState, draftState => {
    draftState.push({todo: "Tweet about it"})
    draftState[1].done = true
})
Copy the code

I met Immer

I first heard about Immer a few months ago. At that time, I wrote a state management library to promote it in the company. My classmates in the group sent me the GitHub address of Immer, saying that there was a state management library based on Proxy, claiming good performance. It is not a state management library, but rather Immutable. Js is a convenient way to manipulate Immutable data. It gives users a draftState, which they can modify at will, and returns a new data, unchanged. DraftState is a Proxy, and reading and writing to it goes into a getter/setter that’s defined internally. Basically, when you get an object from draftState, it returns a Proxy, and when you assign, It will assign to the copy object of the original object. Finally, the copy object is returned.

In fact, Proxy is also used in my project to simplify the operation related to message sending. But the state in my project is mutable (it was immutable at first, but then things changed to enable some functionality…). So Immer and I have a little bit of a bone to pick, but I don’t care much about it.

The source code parsing

However, after changing companies, I frequently heard from my new colleagues that Immer wanted to be used in our projects. Although I think it is still not in line with our scene, is not going to use, but listen to more feel or a complete look at the source code, perhaps can draw lessons from what the corners of the things……

produce

Produce is a function that is exposed directly to the user, and it is an instance method of the Immer class (I’ll explain below without looking at the code) :

export class Immer {
    constructor(config) {
        assign(this, configDefaults, config)
        this.setUseProxies(this.useProxies)
        this.produce = this.produce.bind(this)
    }
    produce(base, recipe, patchListener) {
        // curried invocation
        if (typeof base === "function" && typeofrecipe ! = ="function") {
            const defaultBase = recipe
            recipe = base

            // prettier-ignore
            return (base = defaultBase, ... args) = >
                this.produce(base, draft => recipe.call(draft, draft, ... args)) }// prettier-ignore
        {
            if (typeofrecipe ! = ="function") throw new Error("if first argument is not a function, the second argument to produce should be a function")
            if(patchListener ! = =undefined && typeofpatchListener ! = ="function") throw new Error("the third argument of a producer should not be set or a function")}let result

        // Only plain objects, arrays, and "immerable classes" are drafted.
        if (isDraftable(base)) {
            const scope = ImmerScope.enter()
            const proxy = this.createProxy(base)
            let hasError = true
            try {
                result = recipe.call(proxy, proxy)
                hasError = false
            } finally {
                // finally instead of catch + rethrow better preserves original stack
                if (hasError) scope.revoke()
                else scope.leave()
            }
            if (result instanceof Promise) {
                return result.then(
                    result= > {
                        scope.usePatches(patchListener)
                        return this.processResult(result, scope)
                    },
                    error => {
                        scope.revoke()
                        throw error
                    }
                )
            }
            scope.usePatches(patchListener)
            return this.processResult(result, scope)
        } else {
            result = recipe(base)
            if (result === undefined) return base
            returnresult ! == NOTHING ? result :undefined}}Copy the code

Produce receives three parameters. Normally, base is the original data, recipe is the place where the user performs the modification logic, and patchListener is the place where the user receives the patch data and then performs some custom operations.

Produce determines whether base is a function or not. If it is, it assigns base to recipe and returns a function that receives base. Produce (base, (draft) => {… }), but if in some cases you want to receive the recipe function before receiving base, you can use produce((draft) => {… })(base); the most common scenario is with React setState:

// state = { user: { age: 18 } }
this.setState(
    produce(draft= > {
        draft.user.age += 1}))Copy the code

Of course you can also pass in the default base, const changeFn = produce(recipe, base), either just changeFn() or changeFn(newBase), which overwrites the previous base.

Here is the main flow:

  • If base is an object (including an array) and draft can be generated, then:
    • performconst scope = ImmerScope.enter()Generate an instance of ImmerScope scope bound to the current Produce call
    • performthis.createProxy(base)Create proxy (Draft) and execute the draftscope.drafts.push(proxy)Save the proxy to scope
    • Call the user-passed recipe function with proxy and save the return value as result
    • Called if there is no error during recipe executionscope.leave, resets immerscope.current to its initial state (null in this case), and executes if an error occursscope.revoke(), reset all states.
    • Check if result is a promise, and return if soresult.then(result => this.processResult(result, scope))Otherwise, return directlythis.processResult(result, scope)(It’s actually executed before returningscope.usePatches(patchListener), patch related things are not the main process, leave it at that)
  • If Base cannot generate Draft, then:
    • performresult = recipe(base)
    • Result: undefined returns base; Otherwise, check whether result isNOTHING(an internal flag), returns undefined if, or result if

Produce mainly does three things:

  • callcreateProxyDraft is generated for users
  • Execute the recipe passed in by the user, intercept the read and write operation, and go to the getter/setter inside the proxy
  • callprocessResultThe final result of the parse assembly is returned to the user

Now let’s step by step explore the parts involved.

Create a draft

You’ll notice that Immer’s class declaration does not have the createProxy instance method, but this. CreateProxy (base) is executed in Produce. Is it magic? CreateProxy exists in proxy.js and es5.js files. Es5. js is a compatible solution for environments that do not support proxies. Immer.js starts with the import of both files:

import * as legacyProxy from "./es5"
import * as modernProxy from "./proxy"
Copy the code

This.useProxies (); this.useProxies (); SetUseProxies will judge useProxies:

  • Is true: Assign (this, modernProxy)
  • is false: assign(this, legacyProxy)

The createProxy function is now attached to this. Here is a look at the createProxy function in proxy.js:

export function createProxy(base, parent) {
    const scope = parent ? parent.scope : ImmerScope.current
    const state = {
        // Track which produce call this is associated with.
        scope,
        // 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,
        // The base state.
        base,
        // The base proxy.
        draft: null.// Any property proxies.
        drafts: {},
        // The base copy with any updated values.
        copy: null.// Called by the `produce` function.
        revoke: null
    }

    const {revoke, proxy} = Array.isArray(base)
        ? // [state] is used for arrays, to make sure the proxy is array-ish and not violate invariants,
          // although state itself is an object
          Proxy.revocable([state], arrayTraps)
        : Proxy.revocable(state, objectTraps)

    state.draft = proxy
    state.revoke = revoke

    scope.drafts.push(proxy)
    return proxy
}
Copy the code
  • Build a state object based on base, with properties that we’ll talk about when we use them
  • Check whether base is an array. If so, create base based on arrayTraps[state]Is created based on objectTrapsstateThe Proxy

ArrayTraps basically forward parameters to objectTraps. The key functions in objectTraps are get and set, which intercept values and assignments to proxies.

Intercept value operation

function get(state, prop) {
    if (prop === DRAFT_STATE) return state
    let {drafts} = state

    // Check for existing draft in unmodified state.
    if(! state.modified && has(drafts, prop)) {return drafts[prop]
    }

    const value = source(state)[prop]
    if(state.finalized || ! isDraftable(value))return value

    // Check for existing draft in modified state.
    if (state.modified) {
        // Assigned values are never drafted. This catches any drafts we created, too.
        if(value ! == state.base[prop])return value
        // Store drafts on the copy (when one exists).
        drafts = state.copy
    }

    return (drafts[prop] = createProxy(value, state))
}
Copy the code

Get receives two parameters. The first parameter is state, which is the first parameter (target object) passed during Proxy creation, and the second parameter is prop, which is the name of the property to be obtained. The logic is as follows:

  • If a prop forDRAFT_STATEReturn the state object directly (which will be used in the final processing of the result)
  • Get the property of State drafts. Draftsstate.baseProxy for child objects, for examplebase = { key1: obj1, key2: obj2 },drafts = { key1: proxyOfObj1, key2: proxyOfObj2 }
  • Returns the proxy if state has not been modified and a proxy corresponding to prop exists on drafts
  • ifstate.copyIf yes, takestate.copy[prop]Or takestate.base[prop]For the value
  • If state has already been computed or value cannot be used to generate proxy, return value directly
  • If state has been flagged for modification
    • ifvalue ! == state.base[prop]Value is returned directly
    • Otherwise, thestate.copyAssign values to drafts (the copy also contains the child object’s proxy, discussed in the Set section)
  • If not returned in advance, executecreateProxy(value, state)Generate child state proxies with value as base and state as parent, store them in drafts and return them

So once we’ve done get, we see that it’s a proxy that generates child objects, caches the proxy, and then returns the proxy, or if it can’t generate the proxy, returns a value.

Intercept assignment

function set(state, prop, value) {
    if(! state.modified) {// Optimize based on value's truthiness. Truthy values are guaranteed to
        // never be undefined, so we can avoid the `in` operator. Lastly, truthy
        // values may be drafts, but falsy values are never drafts.
        const isUnchanged = value
            ? is(state.base[prop], value) || value === state.drafts[prop]
            : is(state.base[prop], value) && prop in state.base
        if (isUnchanged) return true
        markChanged(state)
    }
    state.assigned[prop] = true
    state.copy[prop] = value
    return true
}
Copy the code

Set takes three arguments, the first two of which are the same as get, and the third value is the new value to be assigned as follows:

  • First check whether state is marked to change, if not, then:

    • Checks whether the new value is equal to the old value, and returns nothing if the new value is equal
    • Otherwise domarkChanged(state)(More on that later)
  • State. assigned[prop] is set to true to indicate that the property is assigned

  • Assign value to state.copy[prop]

The core of the set is to mark changes and assign the new value to the corresponding property of the copy object. Now look at margeChanged:

function markChanged(state) {
    if(! state.modified) { state.modified =true
        state.copy = assign(shallowCopy(state.base), state.drafts)
        state.drafts = null
        if (state.parent) markChanged(state.parent)
    }
}
Copy the code

A state needs to be marked only once, as follows:

  • thestate.modifiedSet to true
  • Shallow copystate.baseAnd put thestate.draftsAssign to the copy object and assign tostate.copy. That is to say,state.copyProxy, which contains child objects, is used in GET, as we’ve seen before
  • thestate.draftsSet to null
  • If state has parent, recursemarkChanged(state.parent). It makes sense, for exampledraft.person.name = 'Sheepy'For this operation, we will not only modify the Person tag, but also the draft tag

Parsing result return

processResult(result, scope) {
  const baseDraft = scope.drafts[0]
  constisReplaced = result ! = =undefined&& result ! == baseDraftthis.willFinalize(scope, result, isReplaced)
  if (isReplaced) {
    if (baseDraft[DRAFT_STATE].modified) {
      scope.revoke()
      throw new Error("An immer producer returned a new value *and* modified its draft. Either return a new value *or* modify the draft.") // prettier-ignore
    }
    if (isDraftable(result)) {
      // Finalize the result in case it contains (or is) a subset of the draft.
      result = this.finalize(result, null, scope)
    }
    if (scope.patches) {
      scope.patches.push({
        op: "replace".path: [].value: result
      })
      scope.inversePatches.push({
        op: "replace".path: [].value: baseDraft[DRAFT_STATE].base
      })
    }
  } else {
    // Finalize the base draft.
    result = this.finalize(baseDraft, [], scope)
  }
  scope.revoke()
  if (scope.patches) {
    scope.patchListener(scope.patches, scope.inversePatches)
  }
  returnresult ! == NOTHING ? result :undefined
}

Copy the code

Although the Immer Example recommends that the user modify the draft directly in the recipe, the user can also choose to return a result at the end of the recipe. Note that the “modify Draft” and “return new value” operations can only be used in either direction. The processResult function will throw an error if you do both. The core logic is to execute result = this.Finalize (baseDraft, [], scope). To return result, we will call Finalize (baseDraft, [], scope). Let’s look at this function:

/** * @internal * Finalize a draft, returning either the unmodified base state or a modified * copy of the base state. */
finalize(draft, path, scope) {
  const state = draft[DRAFT_STATE]
  if(! state) {if (Object.isFrozen(draft)) return draft
    return this.finalizeTree(draft, null, scope)
  }
  // Never finalize drafts owned by another scope.
  if(state.scope ! == scope) {return draft
  }
  if(! state.modified) {return state.base
  }
  if(! state.finalized) { state.finalized =true
    this.finalizeTree(state.draft, path, scope)

    if (this.onDelete) {
      // The `assigned` object is unreliable with ES5 drafts.
      if (this.useProxies) {
        const {assigned} = state
        for (const prop in assigned) {
          if(! assigned[prop])this.onDelete(state, prop)
        }
      } else {
        const {base, copy} = state
        each(base, prop => {
          if(! has(copy, prop))this.onDelete(state, prop)
        })
      }
    }
    if (this.onCopy) {
      this.onCopy(state)
    }

    // At this point, all descendants of `state.copy` have been finalized,
    // so we can be sure that `scope.canAutoFreeze` is accurate.
    if (this.autoFreeze && scope.canAutoFreeze) {
      Object.freeze(state.copy)
    }

    if (path && scope.patches) {
      generatePatches(
        state,
        path,
        scope.patches,
        scope.inversePatches
      )
    }
  }
  return state.copy
}
Copy the code

Let’s skip the hook function-like onDelete and onCopy and just look at the main flow:

  • Get State via Draft (the state object generated in createProxy, containing properties such as Base, Copy, drafts)
  • If state is not marked, return directlystate.base
  • If state is not marked, executethis.finalizeTree(state.draft, path, scope, and finally returnsstate.copy

Let’s look at finalizeTree:

finalizeTree(root, rootPath, scope) {
  const state = root[DRAFT_STATE]
  if (state) {
    if (!this.useProxies) {
      state.finalizing = true
      state.copy = shallowCopy(state.draft, true)
      state.finalizing = false
    }
    root = state.copy
  }

  constneedPatches = !! rootPath && !! scope.patchesconst finalizeProperty = (prop, value, parent) = > {
    if (value === parent) {
      throw Error("Immer forbids circular references")}// In the `finalizeTree` method, only the `root` object may be a draft.
    constisDraftProp = !! state && parent === rootif (isDraft(value)) {
      constpath = isDraftProp && needPatches && ! state.assigned[prop] ? rootPath.concat(prop) :null

      // Drafts owned by `scope` are finalized here.
      value = this.finalize(value, path, scope)

      // Drafts from another scope must prevent auto-freezing.
      if (isDraft(value)) {
        scope.canAutoFreeze = false
      }

      // Preserve non-enumerable properties.
      if (Array.isArray(parent) || isEnumerable(parent, prop)) {
        parent[prop] = value
      } else {
        Object.defineProperty(parent, prop, {value})
      }

      // Unchanged drafts are never passed to the `onAssign` hook.
      if (isDraftProp && value === state.base[prop]) return
    }
    // Unchanged draft properties are ignored.
    else if (isDraftProp && is(value, state.base[prop])) {
      return
    }
    // Search new objects for unfinalized drafts. Frozen objects should never contain drafts.
    else if (isDraftable(value) && !Object.isFrozen(value)) {
      each(value, finalizeProperty)
    }

    if (isDraftProp && this.onAssign) {
      this.onAssign(state, prop, value)
    }
  }

  each(root, finalizeProperty)
  return root
}
Copy the code

Each (root, finalizeProperty); finalizeProperty (prop, value); FinalizeProperty, although it looks like a lot of code, actually replaces the draft(proxy) property value in copy with draft[DRAFT_STATE]. Copy (these proxies were assigned when markChanged, As we said earlier), we get a real copy that we can eventually return to the user.

conclusion

Due to the lack of space, I will not explain patches in detail. The whole project is a little more complicated than I expected, but the core logic is mainly in bold as mentioned above.

It seems that there is no special reference for me…