Mastering Deep Copy in JavaScript: From Basics to an Industrial-Grade Solution

conver

Introduction

Copying objects and arrays is a common task in JavaScript programming. Whether it’s to prevent accidental modifications to the original data or to create an independent copy of the data, understanding and mastering the concepts and implementation methods of shallow and deep copying is very useful for problem-solving. In this article, we will delve into the principles of deep copying and gradually build an industrial-grade solution.

What is Copying?

In JavaScript, copying usually targets reference types. Reference types include objects, arrays, functions, etc., while primitive types (such as numbers, strings, booleans) are directly copied when assigned or passed, because the storage of primitive types is different from that of reference types.

Shallow Copy

A new object is created, which has an exact copy of the property values of the original object. If the property is a primitive type, the copy is the value of the primitive type. If the property is a reference type, the copy is the memory address, so if one object changes this address, it will affect the other object.

Deep Copy

A complete copy of an object is made from memory, and a new area in the heap memory is allocated to store the new object. Modifying the new object will not affect the original object.

The “Beggar” Version

JSON.parse(JSON.stringify());

JSON.parse(JSON.stringify()) is a very common method for deep copying, but it has obvious limitations:

  • It cannot handle circular references, which can cause runtime errors.
  • It loses some data types (such as undefined, Function, Symbol, etc.).
  • It cannot handle special built-in objects (such as Date, RegExp, etc.).

Therefore, this method is only suitable for simple scenarios and is not recommended for use in complex projects.

Basic Version

Since we don’t know the depth of the object we want to copy, we can use recursion to solve the problem:

  • If it’s a primitive type, there’s no need to continue copying; just return it.
  • If it’s a reference type, create a new object, iterate over the object to be cloned, and add the properties of the object to be cloned to the new object after performing deep copying.

With this method, it’s easy to implement a basic version of deep copying:

function basicClone(obj) {
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }
  let target = Array.isArray(obj) ? [] : {};
  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      target[key] = basicClone(obj[key]);
    }
  }
  return target;
}

Circular References

When we tested our version with a test case, we found that it threw an error. Although this example is not likely to occur online, it does have a small probability of happening, so we need to modify our implementation accordingly.

const test = {
  a: 1,
  b: {
    c: 3
  }
};
test.test = test;

To solve this kind of circular reference problem, we need an extra variable, WeakMap, to temporarily store the reference relationship. The specific steps are as follows:

  • Check if the object has been cloned in the WeakMap.
  • If it has, return the cloned object directly.
  • If it hasn’t, store the current object as the key and the cloned object as the value.
function circleClone(target, map = new WeakMap()) {
  if (typeof target === 'object') {
    let cloneTarget = Array.isArray(target) ? [] : {};
    if (map.get(target)) {
      return map.get(target);
    }
    map.set(target, cloneTarget);
    for (const key in target) {
      cloneTarget[key] = circleClone(target[key], map);
    }
    return cloneTarget;
  } else {
    return target;
  }
}

The reason for using WeakMap instead of Map for temporary storage is that using Map can cause significant additional memory consumption, and sometimes it’s necessary to manually clear the properties of the Map to release the memory. WeakMap helps us cleverly resolve this issue.

Compatibility with Other Data Types

In the code above, we only considered ordinary object and array types. In fact, there are many more reference types in JavaScript, and we also need to support objects such as Date, RegExp, Map, Set, Function, and Symbol. Therefore, we have this version for compatibility.

function circleTypeClone(target, map = new WeakMap()) {
  if (typeof target !== 'object' || target === null) {
    return typeof target === 'symbol' ? Symbol(target.description) : target;
  }

  if (typeof target === 'function') {
    return target.bind({});
  }

  if (map.has(target)) return map.get(target);
  const constructor = target.constructor;
  const type = Object.prototype.toString.call(target).slice(8, -1);

  let cloneTarget;
  switch (type) {
    case 'Date':
    case 'RegExp':
      cloneTarget = new constructor(target);
      map.set(target, cloneTarget);
      return cloneTarget;
    case 'Map':
      cloneTarget = new Map();
      map.set(target, cloneTarget);
      target.forEach((value, key) => {
        cloneTarget.set(circleTypeClone(key, map), circleTypeClone(value, map));
      });
      return cloneTarget;
    case 'Set':
      cloneTarget = new Set();
      map.set(target, cloneTarget);
      target.forEach((value) => {
        cloneTarget.add(circleTypeClone(value, map));
      });
      return cloneTarget;
    case 'Array':
    case 'Object':
      cloneTarget = new constructor();
      map.set(target, cloneTarget);
      break;
    default:
      cloneTarget = new constructor(target);
      map.set(target, cloneTarget);
      return cloneTarget;
  }
  Object.setPrototypeOf(cloneTarget, Object.getPrototypeOf(target));
  Reflect.ownKeys(target).forEach((key) => {
    cloneTarget[key] = circleTypeClone(target[key], map);
  });

  return cloneTarget;
}

After the above optimizations, we have achieved a deep copying implementation that supports the following features:

  • Handling all primitive types.
  • Correctly handling circular references.
  • Supporting common built-in objects (such as Date, RegExp, Map, Set.).
  • Maintaining prototype chain inheritance.
  • Handling non-enumerable properties and Symbol keys.
  • Correctly handling function contexts.
  • Using WeakMap to optimize memory.
  • Accurate type judgment.

And this implementation still have some limitations:

  • It can’t support DOM objects. They contain many internal properties or methods that cannot be deep-copied.
  • It can’t support hidden properties of objects, such as [[scope]].
  • It can’t handle unclonable objects. Some objects like Promise have internal states and behaviors that cannot be deep-copied.

Ingore these minor disadvantages, the deep-clone function is capable of meeting the needs of most daily development requirement.