preface

To understand how something works, you usually need to understand the motivation behind it. Second, you need to be able to understand the basic concepts on which this thing is based. Meet these two things and you will understand things better.

The motivation for immer.js, or the pain point to be addressed, is to make it easier and more readable to modify complex (deeply nested) objects. The principle of IMMER is based on the concept of Proxy in ES6. Therefore, to understand imMER, in fact, you need to have the experience of modifying complex states in practice, and this process is a strong pain point for you, then you can feel the pleasure of using IMMER. Second, you need to be familiar with the concept of Proxy.

The focus of this article will be on the implementation of the principles, and motivation and proxy concepts will be covered, but not in depth. If you are not familiar with motivation and proxy, please come back to this article after you understand it.

Let’s start with an example

Suppose we now have a User component that has an initialized state. The function of the component is to change the value of the state.address.geo. As follows:

const initialUser = {
  "id": 1."name": "Leanne Graham"."username": "Bret"."email": "[email protected]"."address": {
    "street": "Kulas Light"."suite": "Apt. 556"."city": "Gwenborough"."zipcode": "92998-3874"."geo": {
      "lat": "37.3159"."lng": "81.1496"}},"phone": "1-770-736-8031 x56442"."website": "hildegard.org"."company": {
    "name": "Romaguera-Crona"."catchPhrase": "Multi-layered client-server neural-net"."bs": "harness real-time e-markets"}};function User() {
  const [user, setUser] = useState(initialUser);

  return (
    <div>
      <label>Modify user. Address. Geo. Lat</label>
      <input
        value={user.address.geo.lat}
        onChange={e= >{ setUser({ ... user, address: { ... user.address, geo: { ... user.address.geo, lat: e.target.value } } }) }} /></div>)}Copy the code

Because this state is a complex (deeply nested) state, we had to implement it with a large number of spread operators. Not only is it hard to read, but it’s also easy to make mistakes when you write. This is not a very complicated example, because sometimes we even need to determine whether a field exists or not, and if not, we need to create the field first, and then add attributes to it. Is there a better solution to the complexity of writing a status update? Immer was born.

Using the above example, the immer code looks like this (leaving out the initialUser part) :

import produce from 'immer';

// The initialUser part is omitted

function User() {
  const [user, setUser] = useState(initialState);

  return (
    <div>
      <label>Modify user. Address. Geo. Lat</label>
      <input
        value={user.address.geo.lat}
        onChange={e= >{ const newUser = produce(user, draft => { draft.address.geo.lat = e.target.value; }); setUser(newUser); }} / ></div>)}Copy the code

Obviously, this is more readable, and the reader can easily see what you are trying to change; Second, the nesting of a large number of spread operators is avoided and errors are less likely.

But some students will say, this is a cloneDeep way. I will make a deep copy of the initialUser object, and then modify the address.geo. Lat of the copy, and finally setUser.

import {cloneDeep} from 'lodash';

function User() {
  const [user, setUser] = useState(initialState);

  return (
    <div>
      <label>Modify user. Address. Geo. Lat</label>
      <input
        value={user.address.geo.lat}
        onChange={e= >{ const newUser = cloneDeep(user); newUser.address.geo.lat = e.target.value; setUser(newUser); }} / ></div>)}Copy the code

Indeed, this solution seems to solve the problem of readability and code brevity pretty well, but there is one problem. That is, all the things that have not changed, have been unnecessarily copied. In other words, if I make a deep copy of an object, the object will be completely different from the original object. Although I change only user.address.geo. Use === to compare, the return value is false. But immer is different:

import {cloneDeep} from 'lodash';
const copy = cloneDeep(initialUser);
console.log(copy.company === initialUser.company); // false

import produce from 'immer';
const copy2 = produce(initialUser, () = > {});
console.log(copy2.company === initialUser.company); // true
Copy the code

This actually leads to another problem, which is that if there happens to be another component that uses the company attribute, every time you modify user.address.geo. Lat, the other component will also render. ShouldComponentUpdate: the last attribute is not the same as the new one, so we need to re-render it. But you can avoid this with immer.

So immer, in addition to solving the problem of readability and simplicity of writing, solves a very important problem, which is the structurally sharing of the things that weren’t changed.

So how exactly does immer achieve this function and effect?

digression

On the issue of state sharing, the React technology community already had many immutability related libraries, such as immutable.js, but the IMmutable.js API is very verbose and complex, just look at the documentation. And immutable. Js implements immutability in a completely different way than immer. Immutable. This is why immer has gained such acceptance in the community: the simplicity of the API and the nature of state sharing make immer a clean break in the React community.

There are many articles on the web about how immutable.js works, and I’ve excerpted one for those who are interested in it as a way to expand their horizons.

Two concepts to master before implementing the IMmer principle

Introducing Immer says:

How does Immer work?

Well, two words; 1) Copy-on-write. 2). Proxies.

So to understand immer you need to understand and be familiar with these two concepts.

Copy on write is a virtual concept, or a programming idea or method.

Proxy is a specific syntax, although it is also based on an idea, but in this case it is the very specific ES6 proxy syntax.

These two concepts are not the focus of this article, which will briefly list them rather than go into depth. Before we begin to understand the principles of IMmer, it is recommended that we focus on and delve into the specific syntax of proxy. Almost all implementations of IMmer are based on this syntax. Copy on write this concept, because it is a relatively virtual concept, we as a programming idea to understand it.

copy on write

A direct excerpt from Wikipedia:

Copy-on-write (COW), sometimes referred to as implicit sharing or shadowing, is a resource-management technique used in computer programming to efficiently implement a “duplicate” or “copy” operation on modifiable resources. If a resource is duplicated but not modified, it is not necessary to create a new resource; the resource can be shared between the copy and the original. Modifications must still create a copy, hence the technique: the copy operation is deferred until the first write. By sharing resources in this way, it is possible to significantly reduce the resource consumption of unmodified copies, while adding a small overhead to resource-modifying operations.

The core of this concept lies in the two things I highlighted above, if there are keywords, two: shared and defferred. I won’t elaborate on it. I suggest you come back to this paragraph after reading the article, you may have a deeper understanding.

proxy

If you are not familiar with proxy, you are advised to read MDN directly. I don’t want to do too much explanation and narration here.

To summarize, a proxy is a proxy for a source object. When you read/write a proxy, it will enter the handler’s get, set, or delete methods to achieve object hijacking. In this way you get an enhanced version of the source object, sort of like AOP, right?

When implementing immer, you also need to master a new syntax called Map in ES6. From my understanding, the main reason is that the key of Object must be a string, while the key of Map can be an Object. In order to obtain the content of copy, immer uses Map. If you don’t understand, you can leave it and look behind. It should come naturally when you look at the actual example.

Immer principle

Next comes the main topic of this article, how to implement an IMmer.

The first edition

We first use JS to implement a simple shared state copy function:

const state = {
  "phone": "1-770-736-8031 x56442"."website": "hildegard.org"."company": {
    "name": "Romaguera-Crona"."catchPhrase": "Multi-layered client-server neural-net"."bs": "harness real-time e-markets"}};constcopy = {... state}; copy.website ='www.google.com';

console.log(copy.company === state.company); // true
Copy the code

The short answer is to implement a shallow copy of the spread operator and then modify the website property. So how do you do that with a proxy?

const state = {
  "phone": "1-770-736-8031 x56442"."website": "hildegard.org"."company": {
    "name": "Romaguera-Crona"."catchPhrase": "Multi-layered client-server neural-net"."bs": "harness real-time e-markets"}};let copy = {};
const handler = {
  set(target, prop, value){ copy = {... target};/ / shallow copy
    copy[prop] = value; // Assign a value to the copy object}}const proxy = new Proxy(state, handler);
proxy.website = 'www.google.com';

console.log(copy.website); // 'www.google.com'
console.log(copy.company === state.company); // true
Copy the code

At write time, it makes a shallow copy and then writes the properties. At this point, the copy and the original state share all the properties except for the write properties. Let’s wrap it up and implement our first version of Tiny-Immer:

function immer(state, thunk) {
  let copy = {};
  const handler = {
    set(target, prop, value){ copy = {... target};/ / shallow copy
      copy[prop] = value; // Assign a value to the copy object}};const proxy = new Proxy(state, handler);
  thunk(proxy);
  return copy;
}

const state = {
  "phone": "1-770-736-8031 x56442"."website": "hildegard.org"."company": {
    "name": "Romaguera-Crona"."catchPhrase": "Multi-layered client-server neural-net"."bs": "harness real-time e-markets"}};const copy = immer(state, draft= > {
  draft.website = 'www.google.com';
});

console.log(copy.website); // 'www.google.com'
console.log(copy.company === state.company); // true
Copy the code

The second edition

But what if we want to change state.com any.name? At this point the above code is not sufficient, we need to use the following thunk to make the change:

const copy = immer(state, draft= > {
  draft.company.name = 'google';
});
Copy the code

If you know AST, or how Babel compiled the above statement, you can see that the write operation is not actually captured by set. Because you only brokered state (hijacking), not state.company (hijacking). The above statement is compiled in two steps: the first step is the get process, which obtains draft.company; The second step is the set procedure, which writes the draft.company name attribute to the string ‘Google’. So, for this statement to go into set hijacking, the first ‘read’, or get, that draft.company returns is also a proxy. That means we need to add a get hijack.

Because the set process at this point is only a hijacking of the part, or the part to be modified, we need to cache the contents of this part. Finally, when the copy is actually performed, the modified content is retrieved. So in addition to increasing the hijacking of GET, we also need to increase the cache by a copy. And at the end, add a Finalize function, let the function help us to make a copy of the content that has been written.

I feel very difficult to say, and very difficult to understand, in fact, although this process is very clear, but if the simple language to describe it is very difficult to understand, or look at the code:

function immer(state, thunk) {
  let copies = new Map(a);// The Map key can be an object, which is ideal for caching modified objects

  const handler = {
    get(target, prop) { // Add a hijacking of get to return a Proxy
      return new Proxy(target[prop], handler);
    },
    set(target, prop, value) {
      constcopy = {... target};/ / shallow copy
      copy[prop] = value; // Assign a value to the copy objectcopies.set(target, copy); }};function finalize(state) { // Add a finalize function
    constresult = {... state};Object.keys(state).map(key= > { // iterate over the state key
      const copy = copies.get(state[key]);
      if(copy) { // If there is copy, it is modified
        result[key] = copy; // Use the modified content
      } else {
        result[key] = state[key]; // Otherwise, keep the original content}});return result;
  }

  const proxy = new Proxy(state, handler);
  thunk(proxy);
  return finalize(state);
}

const state = {
  "phone": "1-770-736-8031 x56442"."website": {site: "hildegard.org"}, // Note that the simple data type has been changed to an object to facilitate testing state sharing
  "company": {
    "name": "Romaguera-Crona"."catchPhrase": "Multi-layered client-server neural-net"."bs": "harness real-time e-markets"}};const copy = immer(state, draft= > {
  draft.company.name = 'google';
});

console.log(copy.company.name); // 'google'
console.log(copy.website === state.website); // true
Copy the code

Draft.com any.name = ‘Google’ is hijacked twice after the thunk function is changed to the above statement. The first is draft.company, which goes into the Draft get hijack. In order for Company to enter the set hijack during set, draft.com Pany must generate an agent for draft.com Pany, so that there will be a second hijack, after Draft.com Pany returns an agent, Draft.com any.name = ‘Google’ is the second write operation to be hijacked, which is to enter set. That’s why we have the get() {} part of the handler we added above. In fact, this is pretty close to an immer implementation. However, some necessary validation, caching, optimization, and recursive procedures for each subattribute are still missing, and the addition of delete, HAS and other hijacks is a relatively complete immer implementation.

And just to give you a little intuition of what double hijack means. And what is the final finalize process? I made an animation, but because of the limited size of PPT, I changed the state object above. In fact, the principle and process are the same.

The green ones represent the original object tree. The blue dot indicates that a Proxy has been created for the object, and the red dot indicates that a copy has been created in the Map of copies. In fact, this is equivalent to a symbol, which is convenient for finalize to identify which contents have been changed and which have not been changed.

For some reason, the GIF above is not moving…

Final version

By understanding the above two versions, you should be able to understand immer pretty well. Eventually we added the cache and other hijacking operations besides get set, plus some checksum recursion, and we were able to achieve a fairly complete IMmer, although I know the above steps are still a bit short of true IMmer. But the core of imMER is the above content, if you can understand the above content, the real IMMER code needs to implement the business logic, and imMER design ideas have nothing to do with.

Finally, we add all the above functions and the code is as follows:

function immer(baseState, thunk) {
    // Maps baseState objects to proxies
    const proxies = new Map(a)// Maps baseState objects to their copies
    const copies = new Map(a)const objectTraps = {
        get(target, prop) {
            return createProxy(getCurrentSource(target)[prop])
        },
        has(target, prop) {
            return prop in getCurrentSource(target)
        },
        ownKeys(target) {
            return Reflect.ownKeys(getCurrentSource(target))
        },
        set(target, prop, value) {
            const current = createProxy(getCurrentSource(target)[prop])
            const newValue = createProxy(value)
            if(current ! == newValue) {const copy = getOrCreateCopy(target)
                copy[prop] = newValue
            }
            return true
        },
        deleteProperty(target, property) {
            const copy = getOrCreateCopy(target)
            delete copy[property]
            return true}}// creates a copy for a base object if there ain't one
    function getOrCreateCopy(base) {
        let copy = copies.get(base)
        if(! copy) { copy =Array.isArray(base) ? base.slice() : Object.assign({}, base)
            copies.set(base, copy)
        }
        return copy
    }

    // returns the current source of trugth for a base object
    function getCurrentSource(base) {
        const copy = copies.get(base)
        return copy || base
    }

    // creates a proxy for plain objects / arrays
    function createProxy(base) {
        if (isPlainObject(base) || Array.isArray(base)) {
            if (proxies.has(base)) return proxies.get(base)
            const proxy = new Proxy(base, objectTraps)
            proxies.set(base, proxy)
            return proxy
        }
        return base
    }

    // checks if the given base object has modifications, either because it is modified, or
    // because one of it's children is
    function hasChanges(base) {
        const proxy = proxies.get(base)
        if(! proxy)return false // nobody did read this object
        if (copies.has(base)) return true // a copy was created, so there are changes
        // look deeper
        const keys = Object.keys(base)
        for (let i = 0; i < keys.length; i++) {
            if (hasChanges(base[keys[i]])) return true
        }
        return false
    }

    // given a base object, returns it if unmodified, or return the changed cloned if modified
    function finalize(base) {
        if (isPlainObject(base)) return finalizeObject(base)
        if (Array.isArray(base)) return finalizeArray(base)
        return base
    }

    function finalizeObject(thing) {
        if(! hasChanges(thing))return thing
        const copy = getOrCreateCopy(thing)
        Object.keys(copy).forEach(prop= > {
            copy[prop] = finalize(copy[prop])
        })
        return copy
    }

    function finalizeArray(thing) {
        if(! hasChanges(thing))return thing
        const copy = getOrCreateCopy(thing)
        copy.forEach((value, index) = > {
            copy[index] = finalize(copy[index])
        })
        return copy
    }

    // create proxy for root
    const rootClone = createProxy(baseState)
    // execute the thunk
    thunk(rootClone)
    // and finalize the modified proxy
    return finalize(baseState)
}

function isPlainObject(value) {
    if (value === null || typeofvalue ! = ="object") return false
    const proto = Object.getPrototypeOf(value)
    return proto === Object.prototype || proto === null
}

module.exports = immer
Copy the code

For those of you familiar with IMmer, this is actually the original code for the very first version of Immer. After stripping away the business logic and core ideas, you can read them carefully and you will gain.

Afterword.

Although I finished writing, I still felt incomplete, or did not express the content I wanted to express perfectly. In fact, there is still a long distance between my own understanding and the ability to clearly make others understand. This gap needs my continuous efforts. I hope my progress can gradually fill this gap, and this process is also a process of continuous progress. In the New Year, I hope I will become stronger and stronger, and I can break through each bottleneck better, faster, deeper and higher. Come on!