Привет, Хабр! Меня зовут Александр Григоренко, я фронтенд-разработчик.

В языке JavaScript ключевой сущностью является объект — структура данных, в которой хранятся значения, каждое из которых ассоциировано с уникальным ключом — строкой или значением типа Symbol. Объекты в JS являются основой концепции прототипного наследования, которая позволяет выстраивать иерархию сущностей в программе. Они лежат в основе остальных непримитивных типов данных, таких как массивы, множества, мапы и даже функции, которые в JS также могут являться значениями. В конце концов, примитивные значения, такие как числа и строки, могут рассматриваться средой исполнения на этапе интерпретации как объекты, у которых есть собственные свойства и методы.

Кроме основы для работы самого языка, объекты в JavaScript — это удобный способ хранить различные данные, относящиеся к одной предметной области и структурировать эти данные через связи ключ-значение. В работе с этими структурами данных есть множество нюансов, и сегодня я хотел бы обсудить нюансы такой часто встречающейся задачи, как создание копий объектов в JavaScript.

Поверхностные копии объектов

В JavaScript значения объектного типа копируются по ссылке на ячейку памяти, в которой хранится это значение. Копирование значения из переменной, содержащей объект, в другую переменную, приведёт лишь к созданию новой ссылки, но не к копированию самого объекта:

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

const clone = object;

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

Теперь при внесении изменений с обращением к любой переменной будет меняться один и тот же исходный объект:

object.a = 2;

clone.b = 3;

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

Чтобы создать полноценную копию объекта, можно использовать метод Object.assign():

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

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

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

Или оператор расширения ...:

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

const clone = { ...object };

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

Также мы можем заранее создать новый объект и в цикле for...in перебрать свойства исходного объекта, чтобы итеративно скопировать их в новый:

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

const clone = {};

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

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

Эти способы отлично подходят для создания "поверхностных" копий объектов, у которых нет свойств, содержащих другие вложенные объекты. Если же объект содержит вложенные объектные свойства, они будут скопированы не по значению, а по ссылке:

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

Это правило работает для любых объектных значений: самих объектов, массивов, функций, экземпляров классов или вызовов функций-конструкторов, а также других значений, присваивающихся по ссылке.

Для решения большинства задач хватает плоских объектов, и в целом хорошей практикой будет стремиться к упрощению и "уплощению" объектов, чтобы использовать только поверхностное копирование. Это простая и быстровыполнимая задача для интерпретатора, а также общепринятый подход в рамках работы с реактивными библиотеками типа React.js, которые опираются на алгоритмы поверхностного сравнения объектов пропсов для определения изменений в них между рендерами компонентов.

Глубокое копирование объектов

Но не все задачи можно решить только с помощью плоских объектов. Иногда необходимо использовать подход "глубокого" копирования объектов, который рекурсивно обходит и копирует все вложенные объектные свойства не по ссылке, а по значению.

Давайте рассмотрим примеры таких задач:

  • передача копий объектов между разными браузерными контекстами: окнами, фреймами и воркерами;

  • сохранение слепков состояний для их сравнения, перемещения между ними и хранения истории изменений;

  • проверка данных в буфере перед их сохранением;

  • создание копий состояний для проведения изолированных тестов;

  • кэширование состояний и изоляция кэшированных данных от изменений.

Не существует простого и прямого способа копировать объекты со всеми вложенными свойствами, содержащими любые значения. Ниже предлагаю рассмотреть самые популярные способы глубокого копирования объектов, их ограничения и нюансы использования.

Способы глубокого копирования объектов

JSON.parse(JSON.stringify(object))

Этот способ работает очень быстро, но имеет ограничения по типам значений, которые могут быть сериализованы и десериализованы, т.е. преобразованы к строке и полностью восстановлены из этой строки в другом месте. К разрешённым типам значений относятся типы, доступные для использования в формате JSON — это строки, числа, булевые значения, объекты, массивы и специальное значение null.

Значения всех остальных типов могут быть приведены к структуре формата JSON разными способами. Они могут быть проигнорированы (в случае с undefined или функциями в виде значений), заменены на пустой объект {} (при работе с такими встроенными структурами и классами, как Set, Map, RegExp, Error и пр.), обработаны встроенным или самописным методом toJSON() в случае его наличия (например, он описан во встроенном объекте Date), или же преобразованы к формату JSON иным способом.

Описание всех возможных случаев преобразования значений к JSON-строке можно посмотреть на MDN.

Сериализация и десериализация объекта методами встроенного объекта JSON:

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));

После преобразования получим следующий объект в 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
    }
  }
}

У такого подхода есть ограничения: он не позволяет работать с объектами, которые содержат "циклические ссылки", то есть свойства, которые содержат ссылку на исходный объект. При попытке сериализовать такой объект, получим ошибку конвертации:

const object = { a: 1 };

object.a = object;

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

Ошибка будет получена и при попытке сериализации значения типа BigInt:

const object = { a: 100000n };

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

Нюансами сериализации можно управлять через использование функции replacer, переданной в JSON.stringify() в качестве второго аргумента. В этой же функции можно обработать значения, приводящие к ошибкам сериализации:

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' }

Похожий аргумент есть и у метода JSON.parse() — он называется reviver и позволяет контролировать нюансы десериализации (восстановления объекта из строки):

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))

Строковая сериализация и десериализация объекта через методы JSON.stringify() и JSON.parse() доступна и в среде Node.js. Кроме этого в Node.js есть ещё один похожий способ глубокого копирования объектов, который использует бинарную сериализацию и десериализацию объектов. Бинарное преобразование позволяет восстанавливать больше информации при десериализации:

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));

Метод v8.serialize() отличается от JSON.stringify() и даёт иной результат при преобразовании объектов:

{
  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 } }
}

Попытка сериализовать объект, содержащий функции в качестве значений свойств, приведёт к ошибке:

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

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

Бинарное преобразование также поддерживает циклические ссылки:

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)

Функция cloneDeep из библиотеки lodash — популярное решение для глубокого копирования объектов. cloneDeep рекурсивно проходится по всем свойствам объекта и клонирует их по заданным в функции правилам.

cloneDeep поддерживает циклические ссылки, и не завершается с ошибкой при работе с функциями, копируя их по ссылке, а не по значению. Такой алгоритм реализован через метод Object.create(), который создает новый объект, задаёт ему прототип в виде исходной функции и переприсваивает этот прототип в соответствующее свойство объекта-копии:

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

Таким образом, cloneDeep не завершается с ошибкой при работе с функциями и позволяет вызывать их по ссылке из объекта-копии с оригинальным лексическим окружением.

structuredClone(object)

Глубокое копирование объектов используется самой средой исполнения кода JavaScript для решения некоторых внутренних задач. За копирование объектов со сложной структурой в различных браузерных API отвечает так называемый structured clone algorithm. Этот алгоритм используется для создания копии объекта и передачи его между воркерами через метод postMessage(), для хранения данных в IndexedDB, для обработки сетевых запросов и работы с кэшем в Service Workers API и т.д.

В 2021 году в новых версиях браузеров стал доступен встроенный глобальный метод structuredClone(), который позволяет создать копию объекта с глубоким копированием всех уровней вложенности и использует для этого всё тот же structured clone algorithm. structuredClone() не является частью стандарта ECMAScript, но доступен в современных версиях браузеров и других хост-системах JavaScript.

Таблицу браузерной совместимости можно посмотреть на caniuse.com.

structuredClone() также доступен в средах Node.js >= 17.0.0 и Deno >= 1.13.

Пример использования 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);

Новый объект clone будет выглядеть следующим образом:

{
  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
    }
  },
}

Этот способ не позволяет скопировать функции в качестве значений и DOM-узлы, при попытке обработать такие значения будет брошено исключение 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() не копирует дексрипторы свойств объекта, сеттеры и геттеры, и в случае с геттером копирует только результирующее значение, а не саму функцию-геттер:

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

const clone = structuredClone(object);

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

Также при попытке скопировать экземпляр класса или вызов функции-конструктора, цепочка прототипов будет потеряна, и скопированный объект не будет указывать на класс, от которого он наследовался:

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

Полный список типов значений, подходящих для глубокого копирования в новый объект с помощью structuredClone(), можно посмотреть на MDN.

Важно упомянуть, что structuredClone() поддерживает работу с циклическими ссылками внутри объекта, в отличие от JSON.stringify().

Какой способ копирования выбрать, плюсы и минусы

Для большинства задач копирования объектов подойдут способы "поверхностного" копирования, такие как Object.assign() и оператор расширения .... Эти способы идентичны друг другу и отличаются только синтаксисом, позволяя создавать не только копии одиночных "плоских" объектов, но и составлять новые объекты из комбинации существующих:

const object1 = { a: 1 };

const object2 = { b: 2 };

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

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

Использование цикла for...in может быть полезно для создания "поверхностной" копии объекта, когда важен контроль над каждой итерацией и логикой копирования тех или иных свойств объекта.

Для некоторых задач "поверхностных" копий недостаточно, так как не всегда есть возможность оперировать только плоскими объектами. Для создания "глубоких" копий объектов существует несколько способов, и у каждого из них есть свои плюсы и минусы.

Создание копии посредством строковой сериализации и десериализации через методы JSON.stringify() и JSON.parse() — это очень быстрая и оптимизированная движками задача, однако у строкового преобразования самый большой список ограничений по доступным типам данных. Этот способ отлично подходит для копирования объектов, которые изначально существуют в формате, совместимым с JSON, например, если они были получены с сервера посредством HTTP-запроса.

Куда больший набор доступных для точного преобразования типов данных предоставляют методы бинарной сериализации и десериализации serialize и deserialize из модуля v8, но они доступны только из среды Node.js.

Наибольшую свободу в выборе типов значений, доступных для создания копий объектов, предоставляет функция cloneDeep из библиотеки lodash. Это стабильный, полностью оттестированный и очень популярный пакет с набором полезных утилит, и он уже установлен во многих проектах. Однако lodash — это внешняя зависимость, которая добавляет лишние 70Кб в общий бандл, в случае если библиотека была импортирована целиком, и около 17Кб — если сработал механизм tree shaking при импорте только функции cloneDeep. Если для вашего проекта критично обходиться как можно меньшим количеством внешних зависимостей, то вы можете написать собственную реализацию такой функции.

Вот пример функции для "глубокого" копирования, которая учитвает циклические ссылки:

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;
};

При необходимости внутри цикла for...in можно добавить дополнительные обработки нестандартных типов значений.

Для большинства прикладных задач можно обойтись относительно новым встроенным в браузер или другие среды глобальным методом structuredClone(). Он работает с большинством типов значений, и не поддерживает только функции и DOM-узлы в качестве значений свойств объекта. Метод structuredClone() работает на основе structured clone algorithm, который хорошо оптимизирован, а также поддерживает работу с transferable objects.

Transferable objects — это специальный тип объектов, которые могут быть безопасно доступны только одному потоку JavaScript одновременно, и перестают быть открыты для редактирования в изначальном объекте после их копирования, если являются частью изначального объекта.

Перевод выдержки из MDN о сценарии использования transferable objects:

Transferable objects могут быть перенесены, а не продублированы в клонированном объекте, с помощью свойства transfer параметра options. При переносе исходный объект становится непригодным для использования. Сценарий, в котором это может быть полезно — асинхронная проверка некоторых данных в буфере перед их сохранением. Чтобы избежать изменения буфера до сохранения данных, можно клонировать буфер и проверить эти данные. Если вы также передадите данные, любые попытки изменить исходный буфер будут неудачными, что предотвратит его случайное использование не по назначению:

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

В заключение

В этой статье мы исследовали, как работает копирование объектов в языке JavaScript. Сначала мы вспомнили о ссылочной природе хранения объектов и рассмотрели способы создания "поверхностных" копий объектов. Далее мы познакомились с рядом задач, в которых может быть недостаточно использовать только "плоские" объекты, и детально изучили нюансы "глубокого" копирования объектов в JavaScript, учитывающего вложенные объектные свойства. Наконец, мы сравнили эти способы, увидели их плюсы и минусы и определили случаи, когда лучше использовать тот или иной подход.

Надеюсь, данная статья была вам полезна, жду ваших оценок и комментариев!


Приглашаю вас подписаться на мой телеграм-канал: https://t.me/alexgriss, в котором я пишу о фронтенд-разработке, публикую полезные материалы, делюсь своим профессиональным мнением и рассматриваю темы, важные для карьеры разработчика.

Комментарии (0)