Immutable data tool library immutability-helper

In the process of learning functional programming languages, there are three important features:

  • Functions are first class citizens
  • Data is immutable
  • lazy evaluation

Although JavaScript has the characteristics of a functional language, unfortunately, it still does not have the major advantage of immutable data.

In the case of developing complex systems, immutability has two very important properties: non-modification (reduces the occurrence of errors) and structural sharing (saving space). Being unmodifiable also means that the data is easy to trace back and observe.

When front-end development talks about immutable data, the first thing that comes to mind is the Immer library. Immer uses
ES6’s proxy to implement the immutable data structure of js at almost minimal cost. React also improves performance by incorporating immutable data structures. However, Immer
is still somewhat invasive. So is there a better, non-intrusive solution? This article will introduce another tool, immutability-helper , which is also described in React Performance Optimization .

Shallow copy implements immutable data

The simplest immutable data structure is deep copy.

const newUser = JSON . parse ( JSON . stringify (user));
newUser[key] = value;

But this is unacceptable for most scenarios. It consumes a lot of time and space and makes complex systems unusable.

In fact, shallow copy can be used to implement immutable data structures in development, which is also the solution used by immutability-helper. Let’s first construct the following data:

const user = {
   name : "wsafight" ,
   company : {
     name : "Testing Company" ,
     otherInfo : {
       owner : "Testing Company Boss" ,
    },
  },
  schools : [
    { name : "Test Elementary School" },
    { name : "Test Junior High School" },
    { name : "Test High School" },
  ],
};

How can we change user.company.name without changing the original data? code show as below

//Modify company name 
const newUser = {
  ...user,
  company : {
    ...user.company , name
     : " Upgrade Testing Company" ,
  },
};

user === newUser;
// false

user.company === newUser.company ;
 // false

user. company . otherInfo === newUser. company . otherInfo ;
 // true

newUser.schools === user.schools ; //
 true

We did not change the original user data, and at the same time obtained newUser that shared other data structures. At the same time, if the current function requires data backtracking, even if the current object is directly stored in an array, the memory usage will not be very large. Of course, Immer Patches handles backtracking better, and I will continue to interpret other tool libraries for immutable structures in the future.

immutability-helper usage

Using shallow copies to implement immutable data structures is good, but it is too complex to write. When developers face complex data structures, they are overwhelmed. It’s also easy to write bugs.

So kolodny wrote immutability-helper to help us build immutable data structures.

import update from  "immutability-helper" ;

// Modify the company name 
const newUser = update (user, {
   company : {
     name : {
       $set : "Upgrade test company" ,
    },
  },
});

We can see that the update function passes in the previous data and an object structure and gets the new data. $set means replacing the current data. In addition, there are other commands.

Operations on arrays

  • { $push: any[] } Push some arrays based on the current array data
  • { $unshift: any[] } Unshift some arrays for the current array data
  • { $splice: {start: number, deleteCount: number, …items: T[]}[] }
    make the parameters call each item on the target, pay attention to the order
// Added the user's school 
const newUser = update (user, {
   schools : {
     $push : [
      { name : "Test University" },
    ],
  },
});

const newUser = update (user, {
   schools : {
     $unshift : [
      { name : "Test Kindergarten" },
    ],
  },
});

// Sorting operation 
const sourceItem = user[sourceIndex];
 const newUser = update (user, {
   schools : {
     $splice : [
      [sourceIndex, 1 ],
      [targetIndex, 0 , sourceItem!],
    ],
  },
});

const newUser = update (user, {
   schools : {
     // You can also put commands at the same time to operate 
    $unshift : [
      { name : "Test Kindergarten" },
    ],
    $push : [
      { name : "Test Kindergarten" },
    ],
    $splice : [],
  },
});

There is also $apply that can operate based on the current data.

// Each update is calculated based on the current data 
const newUser = update (user, {
   name : {
     $apply : ( name ) =>  ` ${name} change` ,
  },
});

The library also has $set, $unset, $merge for objects and $add, $remove for Map and Set. We can even customize instructions. I won’t introduce these one by one. If you encounter them, please check the documentation yourself.

Add helper function

Comparing the previous writing methods has undoubtedly been of great help to us. But it is still very uncomfortable for the current operation. Still need to write complex data structures.

Write the following function:

export  const  convertImmutabilityByPath = ( // Object path 
  path: string ,
   // Current operations 
  actions: Record< string , any >,
 ) => {
   // If path does not exist or is not a string, an empty object is returned directly if (!path || typeof path !== "string" ) {
     return {};
  
  
  }

  // actions does not have or is not an object, directly returns an empty object 
  if (
    !actions || Object . prototype . toString . call (actions) !== "[object Object]"
  ) {
    return {};
  }

  // Simply replace [ and ] with . and empty string without doing much logical processing 
  // Please do not create strange object paths, otherwise unknown errors may occur 
  const keys = path. replace ( /\[/g , ". " )
    .replace ( / \]/g , "" )
    .split ( " ." )
    .filter ( Boolean ) ;

  const  result : Record < string , any > = {};
   let current = result;

  const len ​​= keys.length ;

  // Construct object keys step by step according to the path 
  . forEach ( ( key: string , index: number ) => {
    current[key] = index === len - 1 ? actions : {};
    current = current[key];
  });

  return result;
};

The current code is in val-path-helper . The library has other functions and is still being written.

This way we can edit the data directly.

convertImmutabilityByPath (
   "schools[0].name" ,
  { $set : "Try Elementary School" },
);
// You can also use 'schools.0.name' 'schools.[0].name' 
// Even 'schools[0.name' will work

// We can also use this method to manipulate objects in the data 
convertImmutabilityByPath (
   `schools[ ${index} ]. ${key} ` ,
  { $set : value },
);

Actual testReact

Here we start to test the help of immutability-helper for react rendering. The code uses the Profiler API to view rendering costs.

function  App () {
   const [user, setUser] = useState ({
     name : "wsafight" ,
     company : {
       name : "testing company" ,
    },
    schools : [
      { name : "Test Primary School" , start : "1998-01-02" , end : "2004-01-02" },
      { name : "Test High School" , start : "2005-01-02" , end : "2007-01-02" },
    ],
  });

  /**
   * Profiler component, you can view the rendering
   */ 
  const  renderCallback = ( ...info ) => {
     console . log ( "Rendering reason" , info[ 1 ]);
     console . log ( "The rendering time spent in this update committed" , info[ 2 ]);
  };

  const  handleSchoolsChange = () => {
    user. schools [ 0 ]. name = "Test Primary School 1" ;
     setUser ({ ...user });
  };

  const  handleSchools2 = () => {
     // immutability-helper 
    const newUser = update (
      user,
      convertImmutabilityByPath ( "schools[0].name" , {
         $set : "Test Elementary School 2" ,
      }),
    );
    setUser (newUser);
  };

  const  handleSchools3 = () => {
    user. schools [ 0 ]. name = "Test Elementary School 3" ;
     // Deep copy 
    const newUser = JSON . parse ( JSON . stringify (user));
     setUser (newUser);
  };

  // Use useMemo to optimize performance, you can also use memo or shouldComponentUpdate 
  // If user.schools remains unchanged, it will not be re-rendered 
  const renderSchools = useMemo ( () => {
     return (
       < div >
        {user.schools.map((item) => {
          return (
            < div  key = {item.name} >
              {item.name}
              {item.start}
              {item.end}
            </div>​​
          );
        })}
      </div>​​
    );
  }, [user. schools ]);

  return (
     < div  className = "App" > 
      < Profiler  id = "render"  onRender = {renderCallback} > 
        < header  className = "App-header" >
          {user.name}
          < button  onClick = {handleSchools} > Modify School 1 </ button > 
          < button  onClick = {handleSchools2} > Modify School 2 </ button > 
          < button  onClick = {handleSchools3} > Modify School 3 </ button > 
          < div > {renderSchools } </ div > 
        </ header > 
      </ Profiler > 
    </ div >
  );
}

Let’s see what happens.

Test button 1:

  • Click to modify school 1 to trigger the handleSchools function
  • Rendering reason update, this update committed, the rendering time spent is 0.8999999999068677
  • Rendering failed, since user.schools has not changed, renderSchools will not re-render
  • Click again to modify school 1 to trigger the handleSchools function
  • Rendering reason update, this update committed, the rendering time spent is 0.10000000009313226

Test button 2:

  • Click to modify school 2 to trigger the handleSchools function
  • Rendering reason update, this update committed, the rendering time spent is 1.6000000000931323
  • Rendering successful
  • Click Modify School 2 again to trigger the handleSchools function
  • No modifications were made, and renderCallback was not triggered.

Test button 3:

  • Click to modify school 3 to trigger the handleSchools function
  • Rendering reason update, this update committed, the rendering time spent is 1.300000000745058
  • Rendering successful
  • Click Modify School 3 again to trigger the handleSchools function.
  • Rendering reason update, this update committed, the rendering time spent is 0.5

Based on the above conditions, we can see the second benefit of immutability-helper. If the current data does not change, the object will not be changed and rendering will not be triggered.

Here try to increase the schools data length to 10002 and do another test. It is found that the rendering time spent has not changed much, both are around 40 ms. At this time, we use console.time to test the time difference between deep copy and immutability-helper.

const  handleSchools2 = () => {
   console . time ( "shallow copy" );
   const newUser = update (
    user,
    convertImmutabilityByPath ( "schools[0].name" , {
       $set : "Test Elementary School 2" ,
    }),
  );
  console . timeEnd ( "shallow copy" );
   setUser (newUser);
};

const  handleSchools3 = () => {
  user. schools [ 0 ]. name = "test elementary school 3" ;
   console . time ( "deep copy" );
   const newUser = JSON . parse ( JSON . stringify (user));
   console . timeEnd ( "deep copy" );
   setUser (newUser);
};

The result is as follows

  • Shallow copy: 1.807861328125 ms
  • Shallow copy: 0.165771484375 ms (second call)
  • Deep copy: 8.59716796875 ms

After testing, there is a 4 times performance gap, and then try adding data of 4 schools size to the data.

  • Shallow copy: 3.60302734375 ms
  • Shallow copy: 0.10107421875 ms (second call)
  • Deep copy: 28.789794921875 ms

It can be seen that as the data increases, the time gap becomes very scary.

Source code analysis

immutability-helper is only a few hundred lines of code. Implementation is also very simple. Let’s take a look at how the author developed this tool library.

The first is the tool function (retaining the core, environment judgment, error warning and other logic removal):

//Extract functions, which have certain performance advantages when used in large quantities. 
const hasOwnProperty = Object . prototype . hasOwnProperty ;
 const splice = Array . prototype . splice ;
 const toString = Object . prototype . toString ;

// Check type 
function  type <T>( obj : T) {
   return (toString. call (obj) as  string ). slice ( 8 , - 1 );
}

// Shallow copy, use Object.assign, if not, write a 
const assign = Object . assign || /* istanbul ignore next */ 
  (<T, S> ( target: T & any , source: S & Record< string , any > ) => {
     getAllKeys (source). forEach ( ( key ) => {
       if (hasOwnProperty. call (source, key)) {
        target[key] = source[key];
      }
    });
    return target as T & S;
  });

// Get the object key 
const getAllKeys = typeof  Object . getOwnPropertySymbols === "function" 
  ? ( obj: Record< string , any > ) => 
    Object . keys (obj). concat ( Object . getOwnPropertySymbols (obj) as  any )
  : /* istanbul ignore next */ 
    ( obj: Record< string , any > ) =>  Object . keys (obj);

//Copy functions of all types 
//If it is not an array, Map, Set, or object, the copy value is directly returned 
function copy<T, U, K, V, X>(
   object : T extends  ReadonlyArray <U> ? ReadonlyArray <U>
    : T extends  Map <K, V> ? Map <K, V>
    : T extends  Set <X> ? Set <X>
    : T extends  object ? T
    : any ,
) {
  return  Array . isArray ( object )
    ? assign ( object . constructor ( object .length ), object )
    : ( type ( object ) === "Map" )
    ? new  Map ( object  as  Map <K, V>)
    : ( type ( object ) === "Set" )
    ? new  Set ( object  as  Set <X>)
    : ( object && typeof  object === "object" )
    ? assign ( Object . create ( Object . getPrototypeOf ( object )), object ) as T
    : /* istanbul ignore next */ 
      object  as T;
}

Then the core code (also retain the core):

export  class  Context {
   // Import all commands 
  private  commands : Record < string , any > = assign ({}, defaultCommands);

  //Add extension instructions (the instructions should not be the same as the data key in the object) 
  public extend<T>( directive : string , fn : ( param: any , old: T ) => T) {
     this . commands [directive] = fn;
  }

  // Function core 
  public update<T, C extends  CustomCommands < object > = never >(
     object : T,
     $spec : Spec <T, C>,
  ): T {
    // Enhance robustness. If the operation command is a function, change it to $apply 
    const spec = ( typeof $spec === "function" ) ? { $apply : $spec } : $spec;

    // Return object (array) 
    let nextObject = object ;
     // Traverse the object, obtain data items and instructions 
    getAllKeys (spec). forEach ( ( key: string ) => {
       // Passed in is an object, if the current key is If there is a command, perform the operation 
      if (hasOwnProperty. call ( this . commands , key)) {
         // Performance optimization, during the traversal process, if the object is still the current previous data, 
        const objectWasNextObject = object === nextObject;

        // Use commands to modify the object 
        nextObject = this . commands [key](
          (spec as  any )[key],
          nextObject,
          spec,
          object ,
        );

        // After modification, the two are calculated using the incoming function. If they are still equal, the previous data will be used directly 
        // In this case, the data will not be modified and the object will not change 
        if (objectWasNextObject && this . isEquals (nextObject, object )) {
          nextObject = object ;
        }
      } else {
         // Not in the instruction set, do other operations 
        // Similar to update(collection, {2: {a: {$splice: [[1, 1, 13, 14]]}}}); 
        // Parse the object After the rule, continue to call update recursively, and continue to recurse and continue to return 
        const nextValueForKey = type ( object ) === "Map" 
          ? this . update (( object  as  any  as  Map < any , any >). get (key), spec[ key])
          : this . update ( object [key], spec[key]);
         const nextObjectValue = type (nextObject) === "Map" 
          ? (nextObject as  any  as  Map < any , any >). get (key)
          : nextObject[key];
        // When the internal data changes, perform copy operation 
        if (
          ! this . isEquals (nextValueForKey, nextObjectValue) ||
           typeof nextValueForKey === "undefined" &&
            !hasOwnProperty. call ( object , key)
        ) {
          if (nextObject === object ) {
            nextObject = copy ( object  as  any );
          }
          if ( type (nextObject) === "Map" ) {
            (nextObject as  any  as  Map < any , any >). set (key, nextValueForKey);
          } else {
            nextObject[key] = nextValueForKey;
          }
        }
      }
    });
    // Return object 
    return nextObject;
  }
}

Finally, the analysis of general instructions

const defaultCommands = {
  $push( value : any , nextObject : any , spec : any ) {
     // Array addition, return concat new array 
    return value. length ? nextObject. concat (value) : nextObject;
  },
  $unshift( value : any , nextObject : any , spec : any ) {
     return value. length ? value. concat (nextObject) : nextObject;
  },
  $splice( value : any , nextObject : any , spec : any , originalObject : any ) {
     // Loop splice and call 
    value. forEach ( ( args: any ) => {
       if (nextObject === originalObject && args. length ) {
        nextObject = copy (originalObject);
      }
      splice.apply ( nextObject , args);
    });
    return nextObject;
  },
  $set( value : any , _nextObject : any , spec : any ) {
     // Directly replace the current value 
    return value;
  },
  $toggle( targets : any , nextObject : any ) {
     const nextObjectCopy = targets. length ? copy (nextObject) : nextObject;
     // Current object or array switches 
    targets. forEach ( ( target: any ) => {
      nextObjectCopy[target] = !nextObject[target];
    });

    return nextObjectCopy;
  },
  $unset( value : any , nextObject : any , _spec : any , originalObject : any ) {
     // Loop and delete 
    value after copying . forEach ( ( key: any ) => {
       if ( Object . hasOwnProperty . call (nextObject, key) ) {
         if (nextObject === originalObject) {
          nextObject = copy (originalObject);
        }
        delete nextObject[key];
      }
    });
    return nextObject;
  },
  $add( values ​​: any , nextObject : any , _spec : any , originalObject : any ) {
     if ( type (nextObject) === "Map" ) {
      values. forEach ( ( [key, value] ) => {
         if (nextObject === originalObject && nextObject. get (key) !== value) {
          nextObject = copy (originalObject);
        }
        nextObject.set ( key, value);
      });
    } else {
      values. forEach ( ( value: any ) => {
         if (nextObject === originalObject && !nextObject. has (value)) {
          nextObject = copy (originalObject);
        }
        nextObject.add ( value);
      });
    }
    return nextObject;
  },
  $remove( value : any , nextObject : any , _spec : any , originalObject : any ) {
    value. forEach ( ( key: any ) => {
       if (nextObject === originalObject && nextObject. has (key)) {
        nextObject = copy (originalObject);
      }
      nextObject.delete ( key);
    });
    return nextObject;
  },
  $merge( value : any , nextObject : any , _spec : any , originalObject : any ) {
     getAllKeys (value). forEach ( ( key: any ) => {
       if (value[key] !== nextObject[key]) {
         if (nextObject === originalObject) {
          nextObject = copy (originalObject);
        }
        nextObject[key] = value[key];
      }
    });
    return nextObject;
  },
  $apply( value : any , original : any ) {
     // Pass in the function and call the function directly to modify 
    the return  value (original);
  },
};

Based on the above code, we finally understand why the author needs to pass an object for processing. At the same time, we can also see that if the key value of the current data path is the same as the instruction, an error will occur.

other

convertImmutabilityByPath (
   `schools[ ${index} ].name` ,
  { $set : "Try Elementary School" },
);

What do you think of when you see the above code? It is the westore diff function that I recommended in a handwritten business data comparison library before .

const result = diff ({
   a : 1 ,
   b : 2 ,
   c : "str" ​​,
   d : { e : [ 2 , { a : 4 }, 5 ] },
   f : true ,
   h : [ 1 ],
   g : { a : [ 1 , 2 ], j : 111 },
}, {
  a : [],
   b : "aa" ,
   c : 3 ,
   d : { e : [ 3 , { a : 3 }] },
   f : false ,
   h : [ 1 , 2 ],
   g : { a : [ 1 , 1 , 1 ], i : "delete" },
   k : "del" ,
});
// result
{
  "a" : 1 , 
   "b" : 2 , 
   "c" : "str" ​​, 
   "de[0]" : 2 , 
   "de[1].a" : 4 , 
   "de[2]" : 5 , 
   " f" : true , 
   "h" : [ 1 ], 
   "ga" : [ 1 , 2 ], 
   "gj" : 111 , 
   "gi" : null , 
   "k" : null  
}

In the future, I will develop some interesting tools by combining diff and immutability-helper.

encourage

If you think this article is good, I hope you can give me some encouragement and help star it on my github blog.

blog address

References

immutability-helper

val-path-helper

immutability-helper practice and optimization