Discuss in detail the principle and limitations of immer.js to efficiently copy and freeze “objects”

the beginning of the story,

immer.js should be a library that became popular in 2019, it can efficiently copy an object (for example, relative to JSON.parse(JSON.stringify(obj)) ), and freeze the modification permissions of some values on this object .

But I found that some classmates respected the large-scale use of immer.js in the project to manipulate objects, but they did not give a reason for me to agree with his approach, so I decided to study immer.js principle, I hope it can be used better and more accurately immer.js .

Don’t use it for the sake of use, it may be the key to learn and understand the idea of a certain technology and apply it in the right place. Today, we will analyze it from the perspective of principle immer.js suitable usage scenarios.

1. What’s wrong with copying objects?

 1: Simplest copy

The following object needs to be copied, and change the name attribute to 金毛2 :

 const obj1 = {
    name: '金毛1',
    city: '上海'
 }

To copy directly:

 const obj2 = obj1;

obj2.name = '金毛2';

console.log(obj1) // {name:'金毛2', city:'上海'}

The above is the simplest example, and the reason is well known because direct const obj2 = obj1; belongs to direct let obj2 the address points to obj1 .

So if you don’t want every modification obj2 affect obj1 , then we can make a deep copy of obj1 and play with it:

 const obj2 = JSON.parse(JSON.stringify(obj1));
      obj2.name = '金毛2'
      console.log(obj1) // {name: '金毛1', city: '上海'}
      console.log(obj2) // {name: '金毛2', city: '上海'}
 2: larger objects

In the actual project, the object to be manipulated may be far more complex than the example, such as city If it is a huge object, then when JSON.parse(JSON.stringify(obj1)) will waste a lot of performance in copying city property, but we might just want a name different object.

As you may quickly think, destructuring directly with the spread operator:

 const obj1 = {
        name: '金毛1',
        city: {
            '上海': true,
            '辽宁': false,
            '其他城市': false
        }
    }
    const obj2 = { ...obj1 }
    obj2.name = '金毛2'
    console.log(obj1.city === obj2.city)
 3: For multi-layered objects

For example, the name attribute is a multi-level nested type, at this time we only want to change the value of the inner basename: 2022 used name :

 const obj1 = {
        name: {
            nickname: {
                2021: 'cc_2021_n',
                2022: 'cc_2022_n'
            },
            basename: {
                2021: 'cc_2021',
                2022: 'cc_2022'
            }
        },
        city: { '上海': true }
    }

    const obj2 = { ...obj1 }
    obj2.name = {
        ...obj1.name
    }
    obj2.name.basename = {
        ...obj1.name.basename
    }
    obj2.name.basename['2022'] = '金毛2'
    console.log(obj1.name.basename === obj2.name.basename) // false
    console.log(obj1.name.nickname === obj2.name.nickname) // true
    console.log(obj1.city === obj2.city) // true

In the above code, we need to repeatedly deconstruct the object in order to reuse city and nickname and other objects, but if we not only want to modify obj2.name.basename['2022'] , but How to modify a variable in an object n ? At this time, we need a plug-in to help us encapsulate these tedious steps.

2. Basic capabilities of immer.js

Let’s directly demonstrate the usage of immer.js to see how elegant it is:

 1: Install
 yarn add immer
 2: use

immer provides the produce method, the second parameter received by this method draft is the same value as the first parameter, the magic is in produce方法内操作draft会被记录下来, 比如—c001dff3f7427b62c47ed3cf7ed2b50c draft.name = 1 obj2name becomes 1 and the name attribute becomes obj2独有的 :

 const immer = require('immer')

const obj1 = {
    name: {
        nickname: {
            2021: 'cc_2021_n',
            2022: 'cc_2022_n'
        },
        basename: {
            2021: 'cc_2021',
            2022: 'cc_2022'
        }
    },
    city: { '上海': true }
}

const obj2 = immer.produce(obj1, (draft) => {
    draft.name.basename['2022'] = '修改name'
})

console.log(obj1.name.basename === obj2.name.basename) // false
console.log(obj1.name.nickname === obj2.name.nickname) // true
console.log(obj1.city === obj2.city) // true
 3: Specify 2 values

We also make a modification to city , so only nickname attributes are reused at this time:

 const immer = require('immer')

const obj1 = {
    name: {
        nickname: {
            2021: 'cc_2021_n',
            2022: 'cc_2022_n'
        },
        basename: {
            2021: 'cc_2021',
            2022: 'cc_2022'
        }
    },
    city: { '上海': true }
}

const obj2 = immer.produce(obj1, (draft) => {
    draft.name.basename['2022'] = '修改name'
    draft.city['上海'] = false
})

console.log(obj1.name.basename === obj2.name.basename) // false
console.log(obj1.name.nickname === obj2.name.nickname) // true
console.log(obj1.city === obj2.city) // false

3. Analysis of plug-in requirements

Let’s briefly sort out what we have done immer and see what difficulties we have to overcome:

  1. produce The first parameter of the method passes in the object to be copied.
  2. The second parameter of the produce method is a function, which records all the pairs draft 赋值操作 .
  3. The assigned object will generate a new object to replace the corresponding value on the body of obj2 .
  4. Operations on draft will not affect the first parameter of the produce method.
  5. If it has not been processed nickname , it will be reused directly.

Fourth, the core principle

produce The draft 3eaca4aecf7552aeba90b797c8ea0783— in the method is obviously a proxy object, then we can use the new Proxy method to generate the proxy object, and use get 与 set method to know which variables were changed.

Take the data structure in our example as an example:

 const obj1 = {
    name: {
        nickname: {
            2021: 'cc_2021_n',
            2022: 'cc_2022_n'
        },
        basename: {
            2021: 'cc_2021',
            2022: 'cc_2022'
        }
    },
    city: { '上海': true }
}

obj1.name.basename[2022] , 那么basename的key( 2022 )的指向, basename Change, he is not the original basename let’s call him basename改 .

basename的父级是name , 那namebasename不能继续使用原本的basename , but should point to basename改 , so the attribute name has also changed.

And so on. In fact, as long as we modify a value, the parent with this value will also be modified. If the parent is modified, the parent of the parent will also be modified, forming a 修改链 , So it may be necessary to use 回溯算法 for step-by-step modifications.

There is only one core goal, only the changed variables are newly created, and the rest of the variables are reused!

Five, the basic core code

First of all, the code I wrote by myself is a bit ugly, so what I am demonstrating here is written after reading many articles and videos. This version is only true Object & Array two data types, the principle is to understand Can:

 1: Write some basic tools and methods
 const isObject = (val) => Object.prototype.toString.call(val) === '[object Object]';
const isArray = (val) => Object.prototype.toString.call(val) === '[object Array]';
const isFunction = (val) => typeof val === 'function';

function createDraftstate(targetState) {
    if (isObject) {
        return Object.assign({}, targetState)
    } else if (isArray(targetState)) {
        return [...targetState]
    } else {
        return targetState
    }
}
  1. createDraftstate method is a shallow copy method.
 2: Entry method
 function produce(targetState, producer) {
    let proxyState = toProxy(targetState)
    producer(proxyState);
    return // 返回最终生成的可用对象
}
  1. targetState is the object to be copied, which is obj1 in the above example.
  2. producer is the processing method passed in by the developer.
  3. toProxy is a method to generate a proxy object, which is used to record the user’s assignment to those attributes.
  4. Finally, a copied object can be returned, and the specific logic here is a bit ‘circumstance’ to be discussed later.
 3: Core proxy method toProxy

The core capability of this method is right 操作目标对象的记录 , the following is the basic method structure demonstration:

 function toProxy(targetState) {
    let internal = {
        targetState,
        keyToProxy: {},
        changed: false,
        draftstate: createDraftstate(targetState),
    }
    return new Proxy(targetState, {
        get(_, key) {
        },
        set(_, key, value) {
        }
    })
}
  1. internal详细的记录下每个代理对象的各种值, 比如—02435205d2c73ab679e09e63c40064a5 obj2.name会生成一个自己的internal , obj2.name.nickname It will also generate one of its own internal , which is a bit abstract here, everyone.
  2. targetState : The original value is recorded, that is, the incoming value.
  3. keyToProxy : record which key was read (not modified), and the corresponding value of key .
  4. changed : Whether the key value of the current loop has been modified.
  5. draftstate : A shallow copy of the current value of this ring.
 4: get and set methods

Set a global one for internal use key , which is convenient for subsequent values:

 const INTERNAL = Symbol('internal')

get and set methods

 get(_, key) {
        if (key === INTERNAL) return internal
        const val = targetState[key];
        if (key in internal.keyToProxy) {
            return internal.keyToProxy[key]
        } else {
            internal.keyToProxy[key] = toProxy(val)
        }
        return internal.keyToProxy[key]
    },
    set(_, key, value) {
        internal.changed = true;
        internal.draftstate[key] = value
        return true
    }

get method:

  1. if (key === INTERNAL)return internal : This is for the subsequent use of this key to get the internal instance.
  2. Each time the value is taken, it will be judged whether this key has a corresponding proxy attribute, if not, recursively use the toProxy method to generate a proxy object.
  3. The final return is the proxy object

set method:

  1. Every time we use set even the same assignment we think has changed, changed attribute becomes true .
  2. draftstate[key] that is, the value of its own shallow copy, which has become the value assigned by the developer.
  3. The final generated obj2 is actually composed of all draftstate .
 5: Backtracking method, change full link parent

The above code is only the most basic modification of a value, but as mentioned above, if a value changes, it will generate 修改链 from the beginning to the parent, then let’s write the backtracking method:

The outermost layer will accept a backTracking method:

 function toProxy(targetState, backTracking = () => { }) {

Internally, methods are used and defined:

 get(_, key) {
    if (key === INTERNAL) {
        return internal
    }
    const val = targetState[key];
    if (key in internal.keyToProxy) {
        return internal.keyToProxy[key]
    } else {
        internal.keyToProxy[key] = toProxy(val, () => {
            internal.changed = true;
            const proxyChild = internal.keyToProxy[key];
            internal.draftstate[key] = proxyChild[INTERNAL].draftstate;
            backTracking()
        })
    }
    return internal.keyToProxy[key]
},
set(_, key, value) {
    internal.changed = true;
    internal.draftstate[key] = value
    backTracking()
    return true
}

Inside get:

  1. Each call to toProxy generates a proxy object and passes a method. If this method is triggered, changed is changed to true , that is, the record itself is modified status.
  2. proxyChild : Get the changed subset.
  3. internal.draftstate[key] = proxyChild[INTERNAL].draftstate; : Assign the modified value of the subset to itself.
  4. backTracking() : Because its own value has changed, make its own parent do the same.

Inside set:

  1. backTracking() : Execute the method passed in by the parent, forcing the parent to change and pointing key to its new value which is draftstate .
 6: Principle combing

脱离代码总的来说一下原理吧, 比如我们取obj.name.nickname = 1 , 则会先触发obj身上的get方法, obj.namekeyToProxy上, obj.nameget方法, 为—983c626c32caaf4eb59dccc702b555c0 obj.name.nickname Mounted on keyToProxy , and finally obj.name.nickname = 1 triggered obj.name.nickname the set method.

set方法触发backTracking触发父级的方法, 父级将子元素的值draftstate对应的key .

All the proxy objects are in keyToProxy , but the last one returned is draftstate so there will be no multi-layer Proxy case (‘Matryoshka proxy’).

 7: Complete code
 const INTERNAL = Symbol('internal')

function produce(targetState, producer) {
    let proxyState = toProxy(targetState)
    producer(proxyState);
    const internal = proxyState[INTERNAL];
    return internal.changed ? internal.draftstate : internal.targetState
}

function toProxy(targetState, backTracking = () => { }) {
    let internal = {
        targetState,
        keyToProxy: {},
        changed: false,
        draftstate: createDraftstate(targetState),
    }
    return new Proxy(targetState, {
        get(_, key) {
            if (key === INTERNAL) {
                return internal
            }
            const val = targetState[key];
            if (key in internal.keyToProxy) {
                return internal.keyToProxy[key]
            } else {
                internal.keyToProxy[key] = toProxy(val, () => {
                    internal.changed = true;
                    const proxyChild = internal.keyToProxy[key];
                    internal.draftstate[key] = proxyChild[INTERNAL].draftstate;
                    backTracking()
                })
            }
            return internal.keyToProxy[key]
        },
        set(_, key, value) {
            internal.changed = true;
            internal.draftstate[key] = value
            backTracking()
            return true
        }
    })
}

function createDraftstate(targetState) {
    if (isObject) {
        return Object.assign({}, targetState)
    } else if (isArray(targetState)) {
        return [...targetState]
    } else {
        // 还有很多类型, 慢慢写
        return targetState
    }
}

module.exports = {
    produce
}

Six, immer’s code is somewhat irregular

Originally I wanted to use immer.js as the source code to display, but the source code has made a lot of compatible es5 code, the readability is poor, and the code specification does not meet the requirements, it is easy To give you an example of mistakes, let’s take a look at a few irregular points:

 1: Variable information is not semantic

This kind of digital transmission is really incomprehensible. In fact, it corresponds to the error code:

In fact, in terms of readability, you should at least write enum :

 2: Super ternary ‘can’t stop’

This is not much to say, it looks too ‘top’.

 3: It’s all any, does this ts still make sense…

7. Show off the interview experience on the ground

深拷贝 and 浅拷贝 belong to primary 八股文 , but if you give the interviewer a spin on the spot, it is estimated that you have finished writing a copy of immer.js . The interviewer’s silence is left, just raise this question by 2 levels, let’s teach him, a new storm has appeared!

Eight, the ability to freeze data: setAutoFreeze method

immer.js There is also an important ability, that is, the freezing attribute prohibits modification.

We can see from the picture that modifying the value of obj2 cannot take effect unless the immer instance on the setAutoFreeze method is used:

Of course, continue to use the immer method to modify the value:

Nine, special circumstances ‘big battle’

The example we wrote only has the core functions, but we can try it together immer.js it is not rigorous enough, so the following code immer is the source code and not written by us.

 1: the value does not change

We have a value equal to itself and see if it changes:

Although the set method is triggered, the passed-in object is still returned.

 2: function function and function again

After the function is executed, the return value changes

No new object is returned without changing the value:

 3: pop is a nonsensical modification

pop() can make the array change, but not triggered set method, what is the effect of this:

Although it does not trigger set , it will trigger the processing of functions in get .

X. immer.js Limitations

We have basically understood the working principle of immer.js , then you can actually feel that it is not necessary to use immer.js Proxy ordinary business development. Proxy also consumes performance.

Before using it, you can look up the amount you want to change to see how much the object remains unchanged after copying the object. It may be that the performance saved is really not much.

Although the experience of immer.js has been very good, there are still some learning costs.

But if the scene you are facing is 大&复杂 then immer.js is indeed a good choice, such as react the performance problem of the source code, the rendering problem of the map, etc.

Eleven, the use of react

This article is based on the principle of immer.js , so I put the react related here, for example, we useState declare a relatively deep object:

 function App() {
  const [todos, setTodos] = useState({
    user: {
      name: {
        nickname: {
          2021: 'cc_2021',
          2022: 'cc_2022'
        }
      },
      age: 9
    }
  });

  return (
    <div className="App" onClick={() => {
      // 此处编写, 更改nickname[2022] = '新name'
    }}>
      {
        todos.user.name.nickname[2022]
      }
    </div>
  );
}
 Method 1: full copy
 const _todos = JSON.parse(JSON.stringify(todos));
  _todos.user.name.nickname[2022] = '新的';
  setTodos(_todos)

Now that everyone sees JSON.parse(JSON.stringify(todos)) does this model think of our immer.js .

 Method 2: Destructuring assignment
 const _todos = {
    user: {
      ...todos.user,
      name: {
        ...todos.user.name,
        nickname: {
          ...todos.user.name.nickname,
          2022: '新的'
        }
      }
    }
  };
  setTodos(_todos)
 Method 3: The new variable triggers the update

Newly declare a variable, which is responsible for triggering the refresh mechanism of react :

 const [_, setReload] = useState({})

Every change todos will not trigger react refresh, and when setTodos react’s judgment mechanism thinks that the value has not changed and will not be refreshed, so other hooks are needed to trigger a refresh:

 todos.user.name.nickname[2022] = '新的';
  setTodos(todos)
  setReload({})
 Method 4: immer.js Trigger refresh

Install:

 yarn add immer

Introduce:

 import produce from "immer";

use:

 setTodos(
    produce((draft) => {
      draft.user.name.nickname[2022] = '新的';
    })
  );

setTodos method receives the function, then the function is executed and the parameter is the todos variable, immer.js the first parameter in the source code is the function and the relevant conversion processing is done:

image.png

But I still feel that it is better to have a separate entry method, the logic is all placed in produce it feels a bit messy, and when reading the source code directly, it will feel inexplicable!

 Method 5: immer.js provided hooks

Install:

 yarn add use-immer

Introduce:

 import { useImmer } from "use-immer";

use:

 // 这里注意用useImmer代替useState
 const [todos, setTodos] = useImmer({
    user: {
      name: {
        nickname: {
          2021: 'cc_2021',
          2022: 'cc_2022'
        }
      },
      age: 9
    }
  });

// 使用时:
 setTodos((draft) => {
    draft.user.name.nickname[2022] = '新的';
 }

13. Inspiration

The two articles I wrote recently are about how to optimize the technology to the extreme. The last article is how does the Qwik.js framework pursue extreme performance?! I deeply feel that some of the codes that I take for granted exist in the way of writing. The point that can be optimized to the extreme, sometimes writing code is like boiling a frog in warm water, and you get used to it when you write it. Can we think of some ways to make ourselves think outside the box and re-examine our abilities?

end

That’s it this time, hope to progress with you.