Author: Zhang Zhao

The use of objects in JS needs to pay special attention to the reference problem, the way to break the reference is common with deep copy. But deep copy is more performance intensive. In this paper, we introduce imMUTation-JS and IMMER libraries to deal with “immutable data”, and briefly analyze the implementation of IMMER. Finally, we compare and summarize the advantages and disadvantages of IMMUTation-JS and IMMER by testing data.

preface

JS variable types can be divided into basic types and reference types.

Reference types often have unintended side effects, so in modern JS development, experienced developers will consciously write down immutable data types with broken references in specific places.

Var a = [{val: = [val: = [val: = [val: = [val: =] Var b = a.map(item => item.val = 2) console.log(a[0].val) // 2Copy the code

From the above example, we can see that the intention is to change the value of each element in B to 2, but inadvertently change the result of each element in A, which is of course not desirable. Then if A is used somewhere, it’s easy for bugs to happen that we can’t anticipate and that we can’t debug (because its value has been accidentally changed).

When such problems are discovered, the solution is simple. In general, when passing a variable (such as an Object) of a reference type into a function, we can use object. assign or… Deconstruct the object and successfully break a layer of references. For example, the above question can be written as follows:

var a = [{ val: 1 }] var b = a.map(item => ({ ... item, val: 2 })) console.log(a[0].val) // 1 console.log(b[0].val) // 2Copy the code

But there is another problem with doing this, either object.assign or… The broken reference is only one layer, and there is some risk if the object is nested in more than one layer.

/ / deep nested object, here again under the element object inside a nested a var desc object a = [{val: 1, desc: {text:'a'} }] var b = a.map(item => ({ ... item, val: 2 })) console.log(a === b) //false
console.log(a[0].desc === b[0].desc) // true

b[0].desc.text = 'b'; Console. log(a[0].desc.text); // Change the contents of the object element in b console.log(a[0].desc.text); // the value of the element in a has been accidentally changed.Copy the code

A [0].desc === b[0]. Desc is still true, which means that inside the program a[0].desc and b[0].desc still refer to the same reference. If later code accidentally assigned directly to b[0].desc inside a function, it would definitely change the result of the a[0].desc part with the same reference. For example, in the example above, a direct “dot” operation on the nested object in B changed the result in A as well.

Deep copy

So after that, in most cases we’ll consider deep-copy operations to avoid all of the problems we encountered above. Deep copy, as its name implies, creates a new type recursively if it encounters a data type that might be referenced (for example, Object) during traversal.

// Obj must be a Plain Object, and all values must be Plain Objectsfunction deepClone(obj) {
  const keys = Object.keys(obj)
  return keys.reduce((memo, current) => {
    const value = obj[current]
    if (typeof value === 'object') {// If the current result is an object, we continue recursing the resultreturn {
        ...memo,
        [current]: deepClone(value),
      }
    }
    return {
      ...memo,
      [current]: value,
    }
  }, {})
}
Copy the code

Perform a simple test with the deepClone function above

var a = {
  val: 1,
  desc: {
    text: 'a',
  },
}
var b = deepClone(a)

b.val = 2
console.log(a.val) // 1
console.log(b.val) // 2

b.desc.text = 'b'
console.log(a.desc.text) // 'a'
console.log(b.desc.text) // 'b'
Copy the code

The deepClone above meets a simple need, but in real production, there are many factors to consider.

For example:

  • What do you do with the getters and setters and the contents of the prototype chain in the key?
  • Value is a Symbol.
  • What if value is a non-plain Object?
  • How to handle some circular references inside a value?

Because there are so many uncertainties, it is recommended to use the utility functions in large open source projects in real engineering practice. CloneDeep is known as Lodash.clonedeep, which is both safe and effective.

immutable

This concept of data that removes the side effects of reference data types is called immutable, meaning immutable data, but is better understood as immutable relations. The essence of immutable is that every time we create deepClone data, a side effect is performed on the new data without affecting the old data.

The side effects here are not limited to assigning attributes through “dot” operations. For example, the push, pop, and splice operations in array are non-IMmutable operations that change the array result. In contrast, operations such as slice and map, which return the result of a new array, are immutable.

DeepClone, however, is an immutable function that removes references, but is relatively expensive (it creates a new object entirely, and sometimes we don’t assign values, so it doesn’t matter if we keep references).

So in 2014, ImMUTABLE js was invented for Facebook to ensure that data is immutable, and to judge references between data at runtime, while maintaining performance.

Immutable – js profile

Immutation-js uses a different set of data structure apis, slightly different from our usual operations, to convert all native data types (Object, Array, etc.) to immutable internal objects (Map, List, etc.). And any operation will eventually return a new immutable value.

The above example uses immutable-js to modify it as follows:

const { fromJS } = require('immutable')
const data = {
  val: 1,
  desc: {
    text: 'a'Const a = fromJS(data) const a = fromJS(data) // Then we can call methods on internal objects like get getInset setConst b = a.sat (const b = a.sat ('val', 2)
console.log(a.get('val')) // 1
console.log(b.get('val')) // 2

const pathToText = ['desc'.'text']
const c = a.setIn([...pathToText], 'c')
console.log(a.getIn([...pathToText])) // 'a'
console.log(c.getIn([...pathToText])) // 'c'
Copy the code

Immutation-js also has its advantages in terms of performance. Here’s a simple example:

const { fromJS } = require('immutable')
const data = {
  content: {
    time: '2018-02-01',
    val: 'Hello World',
  },
  desc: {
    text: 'a'Const a = fromJS(data) const b = a.setin (['desc'.'text'].'b')
console.log(b.get('desc') === a.get('desc')) / /false// The value of content has not changed, so the content of A and B still refer to console.log(b.net ().'content') === a.get('content')) / /trueConst c = a.tojs () const d = b.tojs () // We find that all references are broken console.log(c.desc ===) d.desc) //false
console.log(c.content === d.content) // false
Copy the code

As we can see from the above example, we alter the contents of the desc object by manipulating the immutable-js built-in object. But we didn’t actually change the content result. When we compare with ===, we can see that desc’s reference is disconnected, but content’s reference remains connected.

In IMMUTation-JS data structures, deep objects are guaranteed to be strictly equal without modification, which is another feature of IMMUTABLE “structural sharing of deeply nested objects”. That is, the nested object retains the previous reference internally until it is modified, and breaks the reference after modification without affecting the previous result.

Those of you who use React a lot will be familiar with immutation-js, which is one of the reasons why immutation-js improves the performance of React pages.

Of course, there are more than a few examples that can achieve the effect of immutable. In this article, I would like to introduce the library that implements immutable.

Immer profile

The author of Immer is also the author of Mobx. Mobx seems to have merged Vue into React, and it’s already getting a good response from the community. Immer is another practice he made in immutable.

The biggest difference with IMMUTABLE is that immer is an API that uses native data structures rather than the built-in APIS that immutable uses after converting to built-in objects. Here’s a simple example:

const produce = require(‘immer’)

const state = { done: false, val: ‘string’, }

// All operations with side effects, Const newState = produce(state, (draft) => {draft.done = true}) const newState => {draft.done = true}

console.log(state.done) // false console.log(newState.done) // true

From the above example, we can see that all logic with side effects can be processed inside the function of the second parameter of produce. Any manipulation of the original data inside this function has no effect on the original object.

Here we can do anything in a function, such as a non-immutable API like Push splice, and the end result is independent of the original data.

This is where Immer’s biggest advantage is that it doesn’t cost much to learn because it has very few apis. It simply puts our previous operations into the second argument function of Produce.

Analysis of immer principle

Immer source code, using a new ES6 feature Proxy object. The Proxy object allows you to intercept certain operations and implement custom behaviors, but most JS students probably do not use this metaprogramming pattern very often in their daily business, so here is a quick and simple introduction to its use.

Proxy

The Proxy object takes two parameters, the first parameter is the object to be operated on, and the second parameter is to set the corresponding interception attribute. The attribute here also supports GET, set, etc., which is to hijack the read and write of the corresponding element, perform some operations in it, and finally return a Proxy object instance.

Const proxy = new proxy ({}, {get(target, key) {const proxy = new proxy ({}, {get(target, key) {'proxy get key', key)
  },
  set(target, key, value) {
    console.log('value', value)}}) // All read operations are forwarded to proxy.info inside get'proxy get key info'// All setup actions are forwardedsetMethod internal proxy.info = 1 //'value 1'
Copy the code

The first argument passed in the above example is an empty object, of course we can replace it with another object that already has content, namely the target in the function argument.

The proxy immer

What the IMmer does is it maintains a list of states internally, hijacks all the operations internally to see if there’s any change and ultimately decide how to return. The following example is a constructor that can be used in subsequent processing objects if an instance of it is passed to a Proxy object as the first argument:

class Store {
  constructor(state) {
    this.modified = false
    this.source = state
    this.copy = null
  }
  get(key) {
    if(! this.modified)return this.source[key]
    return this.copy[key]
  }
  set(key, value) {
    if(! this.modified) this.modifing()return this.copy[key] = value
  }
  modifing() {
    if (this.modified) return
    this.modified = true// The native API is used to implement a layer of IMmutable; // Arrays using slice create a new array. Copy = array.isarray (this.source)? this.source.slice() : { ... this.source } } }Copy the code

The Store constructor above omits much of the judgment compared to the source code. Modified, Source, copy, get, set, modifing. Modified serves as a built-in flag that determines how to set and return.

The key is modifing. If the setter is triggered and has not been changed before, the modified flag is manually set to true and a layer of immutable is manually implemented via the native API.

For the second parameter of Proxy, in the simplified implementation, we simply do a layer of forwarding, with any reading and writing of elements being forwarded to the internal methods of the Store instance.

const PROXY_FLAG = '@@SYMBOL_PROXY_FLAG'Const handler = {get(target, key) {// If this flag is encountered we return the target we operated onif (key === PROXY_FLAG) return target
    return target.get(key)
  },
  set(target, key, value) {
    return target.set(key, value)
  },
}
Copy the code

The purpose of putting a flag in the getter is to make it easier to get store instances from proxy objects in the future.

Finally, we can complete the Produce function, create a Store instance, and then create a proxy instance. The created proxy instance is then passed into the second function. So whatever side effects you do internally will eventually be resolved internally within the Store instance. Finally, the modified proxy object is obtained, and the proxy object has maintained two states internally, which one is returned by judging the value of Modified.

functionproduce(state, producer) { const store = new Store(state) const proxy = new Proxy(store, Handler) // Execute the producer function that we pass in. All the actions that we actually operate are proxy instances. Any actions that have side effects will be determined internally by the proxy to determine whether the store should be changed. Const newState = proxy[PROXY_FLAG]if (newState.modified) return newState.copy
  return newState.source
}
Copy the code

Thus, a minimalist version of the Store constructor, handler handling objects, and Produce handling state modules is completed, combining them into the tiniest version of immer, with many unnecessary checksums and redundant variables removed. But true IMmers have other capabilities within them as well, such as the structured sharing of deeply nested objects mentioned above.

Of course, Proxy is a new API that not all environments support and Proxy cannot polyfill, so Immer uses Object.defineProperty for compatibility in environments that do not support Proxy.

performance

Let’s use a simple test to test immer’s performance in the real world. This test uses a state tree with 100K states, and we record the time to manipulate 10K data for comparison.

Freeze: After the state tree is generated, it is frozen and cannot be operated. For normal JS objects, we can use object. freeze to freeze our generated state tree objects, like immer/immutation-js, which has its own freezing methods and logic.

Specific test files can be viewed by clicking: github.com/immerjs/imm… performance_tests/add-data.js

Here’s an example of what each abscissa means:

  • Just mutate: To operate directly with the native operation, freeze calls Object directly. Freeze freezes the entire Object.
  • Deepclone: Replicates the original data by deep copy. The time after freeze refers to the time when the deep-copy object is frozen.
  • Reducer: We manually pass… Or the native IMmutable API such as Object.assign to process our data. The time after freeze represents how long we freeze the new content we create.
  • Immutable JS: Means that we manipulate data Immutable. ToJS refers to converting built-in IMMUTABLE JS objects into native JS content.
  • Immer: Test data in a proxy-enabled environment and in an environment that does not support Proxy using defineProperty.

Based on the observation in the figure above, the following comparison results can be obtained:

  • From the perspective of Mutate and DeepClone, the Mutate benchmark sets a baseline for data change costs, while deepClone’s deep copy is much less efficient because it has no structure to share.
  • Immer using Proxy is about twice that of handwritten reducer, which can be ignored in practice.
  • Immer is roughly as fast as IMMUTABLE js. However, immutation-JS often ends up requiring toJS operations, where the performance overhead is high. Such as converting immutable JS objects back to normal objects, passing them to components, or transferring them over a network, etc. (there is also the upfront cost of converting data received from a server, for example, to immutable built-in objects).
  • DefineProperty is used in the ES5 version of IMmer, which is significantly slower to test. Try to use immer in a proxy-enabled environment.
  • In the freeze version, only Mutate, DeepClone, and native Reducer can recursively freeze the full-state tree, while the other test cases freeze only the modified portion of the tree.

conclusion

From the above example we can also summarize the advantages and disadvantages of comparing IMmutable js with immer:

  • The Immer API is very simple, with almost no difficulty in getting started, and the project migration is relatively easy. Immutable -js is much more complex to get started, and projects that use immutable-js are slightly more complex to migrate or adapt.
  • Immer cannot be used unless the environment supports Proxy and defineProperty. However, IMMUTABLE – JS supports compilation to ES3 code and is suitable for all JS environments.
  • The operating efficiency of Immer is greatly affected by environmental factors. Immutation-js is generally smooth in efficiency, but in the transformation process, fromJS and toJS are executed first, so some upfront efficiency costs are required.

Finally: Bytedance’s commercial front end team is hiring! Where can I find a place where I can talk about my dreams with tech celebrities every day, participate in the exchange and sharing of tech celebrities, enjoy four meals a day, top geek gear, free gym, and do challenging things with excellent people? Come and join us!

Resume should be sent to [email protected]