Published Aug 27, 2024
Lazy shallow cloning in JavaScript
Lazy shallow cloning in JavaScript is an efficient method we’ve implemented to clone objects, particularly useful for large nested objects like those returned from application endpoints such as Amazon SP API and Shopify. This approach works by referencing existing objects and only creating a shallow copy when changes are made, ensuring efficient and resource-conscious cloning.
This technique optimizes performance and memory usage by avoiding the computational overhead of deep cloning every nested object in one go. As a result, the application can handle large data structures more effectively, ensuring faster response times and enhanced user experience.
Problem statement
Deep cloning, which involves creating an entirely independent copy of an object or array, can be both memory and CPU-intensive. This can lead to performance bottlenecks, particularly when dealing with large or complex data structures. On the other hand, shallow cloning offers a lightweight solution by copying only the first level of the object or array, leaving nested structures referenced. However, there are scenarios where changes to nested elements are required, necessitating a more efficient approach. Lazy shallow cloning addresses this challenge by deferring the clone operation until necessary, optimizing performance and resource usage.
Lazy shallow cloning references the original object without creating a new clone. If new changes are needed to update the object in any nested level, it creates a shallow clone of the nested object, updates it, and propagates the changes to the parent object (again, a shallow clone in the parent). This avoids unnecessary cloning and improves performance.
Understanding lazy shallow cloning
Lazy shallow cloning references the original object, ensuring its immutability. We intercept every set call made on the object, and this is where Proxy in JS comes into play. Proxy is instrumental in intercepting the set calls. Inside the set call of the proxy, we add the logic to the shallow clone and iteratively update the parent.
The get call of the object also needs to be intercepted to return nested proxy objects, which will help intercept the set calls on nested objects. We need our cloned object behavior to be the same as the original object, so we need to handle all the operations like delete, has, getOwnPropertyDescriptor, and stringify.
Lazy clone flow chart
Methods for lazy shallow cloning
Using proxy on the original object
Proxy is a powerful feature in JavaScript that allows you to intercept and customize operations on objects. By creating a proxy for the original object, you can intercept property access and modification, enabling lazy, shallow cloning on the object passed in the handler.
We can maintain a modifiedObjects set to keep track of modified and shallow-cloned objects to avoid re-cloning the same object.
function createLazyShallowClone(object) {
return new Proxy(object, { modifiedObjects: new WeakSet(), target: object, ...proxyHandler })
}
Proxy handler
The proxy handler is an object that contains traps for various operations on the proxy object. By defining the set trap, you can intercept property assignments and perform shallow cloning when necessary.
const ProxyHandler = {
updateParent: null,
updateFunc,
ownKeys,
get,
set,
has,
getPrototypeOf,
setPrototypeOf,
deleteProperty
}
Get Trap
The get trap intercepts property access on the proxy object. When a nested object is accessed, the get trap returns a new proxy for the nested object, enabling lazy shallow cloning on the nested structure. We pass updateParent function to the nested proxy to update the parent object. Every update is done on shallow cloned object created in createTarget function.
function get (_, name) {
if (name === '_original') { return this.target }
if (name === 'toJSON') { return () => { return this.target } }
const val = this.target[name]
if (val && (isPlainObject(val) || isArray(val))) {
const updateParent = this.updateFunc(name) // Update Parent on shallow clone of child
return new Proxy(val, {
...this,
target: val,
updateParent
})
}
return val
}
Using createTarget with memoisation
The createTarget function is used to create a shallow copy of the object. Once shallow copy is made we can use the same object for any new updates as we are not going to change the original object.
function createTarget (target) {
// if the path is already modified, we don't need to create a new object
// it will not modify original reference object any more, we can return the same object
if (this.modifiedObjects.has(target)) {
return target
}
const newTarget = Array.isArray(target) ? [] : {}
Object.assign(newTarget, target)
this.modifiedObjects.add(newTarget)
return newTarget
}
Using set trap
The set
trap intercepts property assignments on the proxy object. When a property is updated, the set
trap creates a shallow clone of the object and updates the parent object with the new value. This ensures that changes to nested objects are propagated to the top-level object.
function set (_, name, value) {
if (name === '_original') {
return false
}
if (util.types.isProxy(value)) {
if (!value._original) {
value = Object.assign({}, value)
} else {
value = value._original
}
}
// return if same value is updated
if (this.target[name] === value) return
const temp = this.target
this.target = createTarget.call(this, this.target) // shallow clone the target
this.target[name] = value
// if object not shallow cloned, no need to update parent
if (this.updateParent && temp !== this.target) { this.updateParent(this.target) }
return true
}
Lazy shallow cloning in action
Lazy shallow cloning involves cloning objects or arrays only when necessary, deferring the operation until it’s actually needed. This approach can further optimize performance by avoiding unnecessary clones. Here, unchanged nested objects are shared between original and cloned objects, reducing memory usage.
#### Implementation Example
Consider a scenario where you need to update an object's property conditionally. Using lazy shallow cloning, you can ensure the clone is created only if the condition is met.
```javascript
const a = { e: 1, b: { c: 2, d: 4 } };
const clone = createLazyShallowClone(a)
clone.b.c = 3; // Shallow clone created for b, c. d will be shared between original and clone
delete clone.e
console.log(original); // { e: 1, b: { c: 2, d: 4 } } original object is not mutated
console.log(clone); // { b: { c: 3, d: 4 } } updated object
In this example, the shallow clone is created only if the value of the specified key is different from the current value, thus optimizing performance.
Benchmark metrics
Displaying the benchmark data on a 5MB dataset, we conducted a thorough analysis of the data ( 16836 records, 10 fields, 1 array of strings, 1 array of objects ) and compared the performance of lazy, shallow cloning with deep cloning. Our comprehensive approach ensures the reliability of our findings.
Lazy, shallow cloning initialization is much faster compared to other cloning methods we followed, but if we try to update all the fields in the clone, we see drastic degradation in performance. This is due to the proxy’s set and get interceptor calls, which increase the time. During serialization of the object, we are not using the proxy object but the original object. Here, we are not seeing any performance issues.
Here is a table showing the benchmark data:
Operation |
Lazy Shallow Cloning |
Lodash Deep Cloning |
JSON Deep Cloning |
Clone Initialisation |
0.09ms |
118.04ms |
86.72ms |
Operation |
* Lazy Shallow Cloning |
* Lodash Deep Cloning |
* JSON Deep Cloning |
10% unique update |
63.58ms |
1.07ms |
3.78ms |
20% unique updates |
90.74ms |
5.7ms |
8.40ms |
40% unique updates |
172.89ms |
7.79ms |
8.81ms |
60% unique updates |
238.99ms |
8.27ms |
10.36ms |
80% unique updates |
300.99ms |
8.72ms |
10.53ms |
100% unique updates |
380.01ms |
9.70ms |
11.95ms |
*Here, the overall time will include object Initialisation and cloning in the general process. |
Operation |
Lazy Shallow Cloning |
Lodash Deep Cloning |
JSON Deep Cloning |
JSON Serialize clone |
25.75ms |
30.84ms |
28.37ms |
Operation |
Lazy Shallow Cloning |
Lodash Deep Cloning |
JSON Deep Cloning |
Mem Usage (With No updates + Original object) |
5.83 MB |
23.35 MB |
22.35 MB |
Mem Usage (20% Field Updates) |
11.25 MB |
23.86 MB |
21.77 MB |
Mem Usage (40% Field Updates) |
18.50 MB |
24.17 MB |
22.08 MB |
Mem Usage (60% Field Updates) |
19.40 MB |
24.50 MB |
22.66 MB |
Mem Usage (80% Field Updates) |
25.61 MB |
24.93 MB |
22.82 MB |
Mem Usage (100% Field Updates) |
31.41 MB |
25.18 MB |
23.22 MB |
Lazy shallow cloning in JavaScript offers a practical and efficient alternative to deep cloning. By referencing the original object and deferring the clone operation until necessary, developers can significantly reduce memory and CPU usage for objects with few updates.
Implications
Lazy cloning is slow and memory intensive if it tries to modify all fields in the object due to our proxy’s set and get calls, while shallow cloning and checks on the object take time.
Lazy shallow cloning can also cause issues in scenarios where the original object is expected to be mutated. In such cases, it is important to ensure that the original object is not modified directly. Lazy shallow cloning is best suited for scenarios where the original object is immutable. We can make the object immutable by applying the lazy cloning to the original object and using the clone object in further operations.
Util.inspect is used to convert the object to string. This will not work with lazy shallow cloning as it will return the original object referenced through proxy internal bindings functions, need to have reference of our handler target where all our objects updates are made. We need to pass reference to handler target to util.inspect.
Future Work
Further optimizations and enhancements can be explored, such as the immutability of objects and modifying internal bindings of proxy to return cloned objects internally.
Experiment with lazy shallow cloning in your projects and see the performance improvements for yourself. Share your experiences and any tips you discover along the way!
Additional Resources