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 .
Contents
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.