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:
- perform
const scope = ImmerScope.enter()
Generate an instance of ImmerScope scope bound to the current Produce call - perform
this.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 execution
scope.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 so
result.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)
- perform
- If Base cannot generate Draft, then:
- perform
result = recipe(base)
- Result: undefined returns base; Otherwise, check whether result is
NOTHING
(an internal flag), returns undefined if, or result if
- perform
Produce mainly does three things:
- call
createProxy
Draft 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
- call
processResult
The 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 objectTrapsstate
The 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 for
DRAFT_STATE
Return the state object directly (which will be used in the final processing of the result) - Get the property of State drafts. Drafts
state.base
Proxy 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
- if
state.copy
If 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
- if
value ! == state.base[prop]
Value is returned directly - Otherwise, the
state.copy
Assign values to drafts (the copy also contains the child object’s proxy, discussed in the Set section)
- if
- If not returned in advance, execute
createProxy(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 do
markChanged(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:
- the
state.modified
Set to true - Shallow copy
state.base
And put thestate.drafts
Assign to the copy object and assign tostate.copy
. That is to say,state.copy
Proxy, which contains child objects, is used in GET, as we’ve seen before - the
state.drafts
Set to null - If state has parent, recurse
markChanged(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 directly
state.base
- If state is not marked, execute
this.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…