The nuances of copying objects in JavaScript

In addition to being the basis for the language itself, objects in JavaScript are a convenient way to store various data related to the same subject area and structure this data through key-value relationships. There are many nuances to working with these data structures, and today I would like to discuss the nuances of such a common task as creating copies of objects in JavaScript.

Shallow copies of objects

In JavaScript, values ​​of an object type are copied by reference to the memory location where the value is stored. Copying a value from a variable containing an object to another variable will only create a new reference, but not copy the object itself:

const object = { a: 1, b: 2 };

const clone = object;

console.log(object === clone); // true

Now, when making changes by accessing any variable, the same original object will change:

object.a = 2;

clone.b = 3;

console.log(object); // { a: 2, b: 3 }
console.log(clone); // { a: 2, b: 3 }

To create a full copy of an object, you can use the method Object.assign():

const object = { a: 1, b: 2 };

const clone = Object.assign({}, object);

console.log(object === clone); // false

Or the extension operator ...:

const object = { a: 1, b: 2 };

const clone = { ...object };

console.log(object === clone); // false

We can also create a new object in advance in a loop for...in Loop through the properties of the original object to iteratively copy them to the new one:

const object = { a: 1, b: 2 };

const clone = {};

for (const key in object) {
  clone[key] = object[key];
}

console.log(object === clone); // false

These methods are great for making “shallow” copies of objects that don't have properties containing other nested objects. If the object contains nested object properties, they will be copied not by value, but by reference:

const object = { a: 1, b: { c: 2 } };

const clone = { ...object };

object.b.c = 3;

console.log(clone.b); // { c: 3 }

console.log(object.b.c === clone.b.c); // true

This rule works for any object values: objects themselves, arrays, functions, class instances or calls to constructor functions, as well as other values ​​assigned by reference.

Flat objects are sufficient for most problems, and in general it is good practice to try to simplify and “flatten” objects so that only shallow copying is used. This is a simple and fast task for the interpreter, and is also a common approach when working with reactive libraries like React.js, which rely on shallow comparison algorithms for prop objects to determine changes in them between component renders.

Deep copying of objects

But not all problems can be solved only with the help of flat objects. Sometimes it is necessary to use a “deep” object copying approach, which recursively traverses and copies all nested object properties not by reference, but by value.

Let's look at examples of such tasks:

  • transferring copies of objects between different browser contexts: windows, frames and workers;

  • saving snapshots of states to compare them, move between them and store the history of changes;

  • checking the data in the buffer before saving it;

  • creating copies of states to perform isolated tests;

  • state caching and isolation of cached data from changes.

There is no simple, direct way to copy objects with all nested properties containing any values. Below I propose to consider the most popular methods of deep copying of objects, their limitations and nuances of use.

Ways to deep copy objects

JSON.parse(JSON.stringify(object))

This method works very fast, but has restrictions on the types of values ​​that can be serialized and deserialized, i.e. converted to a string and completely reconstructed from that string elsewhere. Allowed value types include: available for use in JSON format – these are strings, numbers, booleans, objects, arrays and special value null.

Values ​​of all other types can be converted to a JSON format structure in different ways. They can be ignored (in the case of undefined or functions as values) are replaced with an empty object {} (when working with built-in structures and classes such as Set, Map, RegExp, Error etc.), processed by built-in or using the self-written method toJSON() if it exists (for example, it is described in a built-in object Date), or otherwise converted to JSON format.

You can see a description of all possible cases of converting values ​​to a JSON string on MDN.

Serializing and deserializing an object using the built-in JSON object methods:

const object = {
  set: new Set([1, 3, 3]),
  regex: /abc/,
  date: new Date(123),
  string: "hello",
  array: [false, 1, "2"],
  node: document.body,
  function() {
    return 123;
  },
  withToJSON: {
    a: 1,
    toJSON() {
      return { a: 2 };
    },
  },
  obj: {
    a: 1,
    b: {
      c: 2,
    },
  },
};

const clone = JSON.parse(JSON.stringify(object));

After the transformation we get the following object in clone:

{
  "set": {},
  "regex": {},
  "date": "1970-01-01T00:00:00.123Z",
  "string": "hello",
  "array": [
    false,
    1,
    "2"
  ],
  "node": {},
  "withToJSON": {
    "a": 2
  },
  "obj": {
    "a": 1,
    "b": {
      "c": 2
    }
  }
}

This approach has limitations: it does not allow you to work with objects that contain “circular references”, that is, properties that contain a reference to the original object. When trying to serialize such an object, we receive a conversion error:

const object = { a: 1 };

object.a = object;

console.log(JSON.stringify(object)); // Uncaught TypeError: Converting circular structure to JSON

An error will also be received when trying to serialize a value of the type BigInt:

const object = { a: 100000n };

console.log(JSON.stringify(object)); // Uncaught TypeError: Do not know how to serialize a BigInt

The nuances of serialization can be control through the use of a function replacertransferred to JSON.stringify() as the second argument. In the same function you can process values ​​that lead to serialization errors:

const object = { a: 1, b: 100000n };

object.a = object;

const replacer = (key, value) => {
  value = typeof value === "bigint" ? String(value) : value;

  return key === "a" ? undefined : value;
};

const clone = JSON.parse(JSON.stringify(object, replacer));

console.log(clone); // { b: '100000' }

The method has a similar argument JSON.parse() – it is called reviver and allows you to control the nuances of deserialization (restoring an object from a string):

const object = { date: new Date(123) };

const stringifiedObject = JSON.stringify(object);

console.log(stringifiedObject); // '{"date":"1970-01-01T00:00:00.123Z"}'

const reviver = (key, value) => {
  return key === "date" ? new Date(value).getMilliseconds() : value;
};

console.log(JSON.parse(stringifiedObject, reviver)); // { date: 123 }

v8.deserialize(v8.serialize(object))

String serialization and deserialization of an object through methods JSON.stringify() And JSON.parse() is also available in the Node.js environment. In addition, Node.js has another similar method for deep copying of objects, which uses binary serialization and deserialization of objects. Binary conversion allows you to recover more information when deserializing:

const v8 = require("v8");

const object = {
  set: new Set([1, 3, 3]),
  regex: /abc/,
  date: new Date(123),
  string: "hello",
  array: [false, 1, "2"],
  obj: {
    a: 1,
    b: {
      c: 2,
    },
  },
};

const clone = v8.deserialize(v8.serialize(object));

Method v8.serialize() differs from JSON.stringify() and gives a different result when transforming objects:

{
  set: Set(2) { 1, 3 },
  regex: /abc/,
  date: 1970-01-01T00:00:00.123Z,
  string: 'hello',
  array: [ false, 1, '2' ],
  obj: { a: 1, b: { c: 2 } }
}

Trying to serialize an object containing functions as property values ​​will result in an error:

const object = {
  function() {
    return 123;
  },
};

v8.serialize(object); // Uncaught Error: function() { return 123; } could not be cloned

Binary conversion also supports circular references:

const object = { a: 1 };

object.a = object;

console.log(v8.serialize(object)); // <ref *1> { a: [Circular *1] }
console.log(v8.serialize(object.a)); // <ref *1> { a: [Circular *1] }
console.log(v8.serialize(object.a.a.a.a)); // <ref *1> { a: [Circular *1] }

lodash.cloneDeep(object)

Function cloneDeep from the library lodash is a popular solution for deep copying of objects. cloneDeep recursively iterates through all the properties of the object and clones them according to the rules specified in the function.

cloneDeep supports circular references, and does not fail when working with functions, copying them by reference rather than by value. This algorithm is implemented through the method Object.create()which creates a new object, gives it a prototype in the form of the original function and reassigns this prototype to the corresponding property of the copy object:

import _ from "lodash";

const func = () => 123;

const object = { func };

const clone = _.cloneDeep(object);

// Эмулируем работу функции cloneDeep: создаём новый объект с прототипом
// в виде исходной функции и переприсваиваем этот прототип в новую переменную
const funcClone = Object.getPrototypeOf(Object.create(func));

// Сравниваем объекты по ссылке
console.log(funcClone === object.func); // true
console.log(funcClone === clone.func); // true

Thus, cloneDeep does not fail with an error when working with functions and allows you to call them by reference from a copy object with the original lexical environment.

structuredClone(object)

Deep copying of objects is used by the JavaScript runtime itself to solve some internal problems. The so-called structured clone algorithm. This algorithm is used to create a copy of an object and pass it between workers through the method postMessage()to store data in IndexedDBfor processing network requests and working with the cache in Service Workers API etc.

In 2021, a built-in global method became available in new versions of browsers structuredClone()which allows you to create a copy of an object with deep copying of all levels of nesting and uses the same structured clone algorithm. structuredClone() is not part of the ECMAScript standard, but is available in modern browsers and other JavaScript hosts.

The browser compatibility table can be viewed at caniuse.com.

structuredClone() also available in Node.js >= 17.0.0 and Deno >= 1.13 environments.

Usage example structuredClone():

const object = {
  set: new Set([1, 3, 3]),
  regex: /abc/,
  date: new Date(123),
  string: "hello",
  array: [false, 1, "2"],
  obj: {
    a: 1,
    b: {
      c: 2,
    },
  },
};

const clone = structuredClone(object);

New object clone will look like this:

{
  set: Set(2) { 1, 3 },
  regex: /abc/,
  date: 1970-01-01T00:00:00.123Z,
  string: "hello",
  array: [false, 1, "2"],
  obj: {
    a: 1,
    b: {
      c: 2
    }
  },
}

This method does not allow you to copy functions as values ​​and DOM nodes; when you try to process such values, an exception will be thrown DataCloneError:

const func = () => 123;

const object = { func };

const clone = structuredClone(object); // Uncaught DOMException: Failed to execute 'structuredClone' on 'Window': () => 123 could not be cloned
const node = document.body;

const object = { node };

const clone = structuredClone(object); // Uncaught DOMException: Failed to execute 'structuredClone' on 'Window': HTMLBodyElement object could not be cloned

structuredClone() does not copy object property descriptors, setters and getters, and in the case of a getter, copies only the resulting value, and not the getter function itself:

const object = {
  get func() {
    return 123;
  },
};

const clone = structuredClone(object);

console.log(clone); // { func: 123 }

Also, if you try to copy an instance of a class or a call to a constructor function, the prototype chain will be lost, and the copied object will not point to the class from which it inherited:

class A {
  constructor() {
    this.a = 1;
  }
}

const object = new A();

const clone = structuredClone(object);

console.log(object instanceof A); // true
console.log(clone instanceof A); // false

Complete list of value types suitable for deep copying to a new object using structuredClone()you can look at MDN.

It's important to mention that structuredClone() supports working with circular references within an object, unlike JSON.stringify().

Which copying method to choose, pros and cons

For most object copying tasks, “shallow” copying methods are suitable, such as Object.assign() and extension operator .... These methods are identical to each other and differ only in syntax, allowing you to create not only copies of single “flat” objects, but also create new objects from a combination of existing ones:

const object1 = { a: 1 };

const object2 = { b: 2 };

const result = { ...object1, ...object2 };

console.log(result); // { a: 1, b: 2 }

Using a Loop for...in can be useful for creating a “shallow” copy of an object, when control over each iteration and the logic of copying certain object properties is important.

For some tasks, “surface” copies are not enough, since it is not always possible to operate only on flat objects. There are several ways to create “deep” copies of objects, and each of them has its pros and cons.

Creating a copy via string serialization and deserialization via methods JSON.stringify() And JSON.parse() is a very fast and engine-optimized task, but string conversion has the largest list of restrictions on the available data types. This method is great for copying objects that originally exist in a JSON-compatible format, for example if they were received from the server via an HTTP request.

A much larger set of data types available for precise conversion is provided by binary serialization and deserialization methods serialize And deserialize from module v8but they are only accessible from the Node.js environment.

The greatest freedom in choosing the types of values ​​available for creating copies of objects is provided by the function cloneDeep from the library lodash. It is a stable, fully tested and very popular package with a set of useful utilities, and it is already installed in many projects. However lodash is an external dependency that adds an extra 70Kb to the overall bundle if the entire library was imported, and about 17Kb if the mechanism worked tree shaking when importing only functions cloneDeep. If it is critical for your project to make do with as few external dependencies as possible, then you can write your own implementation of such a function.

Here is an example of a deep copy function that takes into account circular references:

const cloneDeep = (obj, visited = new WeakMap()) => {
  // Проверяем, был ли уже скопирован этот объект
  if (visited.has(obj)) {
    return visited.get(obj);
  }

  if (typeof obj !== "object" || obj === null) {
    return obj; // Если переданный аргумент не является объектом, возвращаем его же
  }

  // Создаем новый объект или массив в зависимости от типа obj
  const newObj = Array.isArray(obj) ? [] : {};

  // Регистрируем текущий объект как посещённый
  visited.set(obj, newObj);

  // Рекурсивно копируем свойства объекта или элементы массива
  for (let key in obj) {
    // Проверяем, является ли свойство собственным (не унаследованным)
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
      // Рекурсивно копируем каждый элемент
      newObj[key] = cloneDeep(obj[key], visited);
    }
  }

  return newObj;
};

If necessary inside the loop for...in you can add additional processing for non-standard value types.

For most application tasks, you can get by with the relatively new global method built into the browser or other environments structuredClone(). It works with most value types, and only supports functions and DOM nodes as object property values. Method structuredClone() works on the basis structured clone algorithmwhich is well optimized and also supports working with transferable objects.

Transferable objects are a special type of object that can only be safely accessed by one JavaScript thread at a time, and are no longer open for editing in the original object once they are copied if they are part of the original object.

Translation excerpts from MDN about the use case transferable objects:

Transferable objects can be carried over, rather than duplicated in the cloned object, using the property transfer parameter options. When migrated, the original object becomes unusable. A scenario where this might be useful is to asynchronously check some data in a buffer before storing it. To avoid modifying the buffer before saving the data, you can clone the buffer and check the data. If you also pass the data, any attempts to modify the original buffer will fail, preventing it from being accidentally misused:

const uInt8Array = Uint8Array.from({ length: 1024 * 1024 * 16 }, (v, i) => i);

console.log(uInt8Array.byteLength); // 16777216

const transferred = structuredClone(uInt8Array, {
  transfer: [uInt8Array.buffer],
});

console.log(uInt8Array.byteLength); // 0

Finally

In this article, we explored how object copying works in JavaScript. First we remembered the referential nature of object storage and looked at ways to create “shallow” copies of objects. Next, we got acquainted with a number of tasks in which it may not be enough to use only “flat” objects, and we studied in detail the nuances of “deep” copying of objects in JavaScript, taking into account nested object properties. Finally, we compared these methods, saw their pros and cons, and identified cases when it is better to use one or another approach.

I hope this article was useful to you, I look forward to your ratings and comments!


I invite you to subscribe to my telegram channel: https://t.me/alexgrisswhere I write about front-end development, publish useful content, share my professional opinions, and explore topics important to a developer's career.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *