Вы знали, что теперь в JavaScript есть нативный способ делать глубокие копии объектов? Это стало возможным с помощью функции structuredClone, встроенной в среду выполнения JavaScript:

const calendarEvent = {
  title: "Builder.io Conf",
  date: new Date(123),
  attendees: ["Steve"]
}

// ????
const copied = structuredClone(calendarEvent)

Вы заметили, что в этом примере мы скопировали не только объект, но и вложенный массив, и даже объект Date?

И код работает именно так, как мы и ожидали:

copied.attendees // ["Steve"]
copied.date // Date: Wed Dec 31 1969 16:00:00
cocalendarEvent.attendees === copied.attendees // false

structuredClone может делать не только вышеперечисленное, но и также:

  • Клонировать бесконечно вложенные объекты и массивы.

  • Клонировать циклические ссылки.

  • Клонировать широкий спектр типов JavaScript, таких как: Date, Set, Map, Error, RegExp, ArrayBuffer, Blob, File, ImageData и многие другие.

  • Передавать любые передаваемые объекты.

Это безумие даже будет работать так, как мы и ожидали:

const kitchenSink = {
  set: new Set([1, 3, 3]),
  map: new Map([[1, 2]]),
  regex: /foo/,
  deep: { array: [ new File(someBlobData, 'file.txt') ] },
  error: new Error('Hello!')
}
kitchenSink.circular = kitchenSink

// ✅ Выполнено полное глубокое копирование
const clonedSink = structuredClone(kitchenSink)

Почему бы просто не сделать object spread?

Важным отметить, что мы говорим о глубоком копировании. Если же нужно просто выполнить поверхностное копирование, то есть копирование без включения вложенных объектов или массивов, то можно просто выполнить spread объекта:

const simpleEvent = {
  title: "Builder.io Conf",
}
// ✅ нет вложенных объектов или массивов
const shallowCopy = {...calendarEvent}

Или даже один из этих вариантов, если хотите:

const shallowCopy = Object.assign({}, simpleEvent)
const shallowCopy = Object.create(simpleEvent)

Но как только появляются вложенные элементы, мы сталкиваемся с проблемой:

const calendarEvent = {
  title: "Builder.io Conf",
  date: new Date(123),
  attendees: ["Steve"]
}

const shallowCopy = {...calendarEvent}

// ???? упс - мы добавили "Bob" и в копию и в воригинальное событие
shallowCopy.attendees.push("Bob")

// ???? упс - мы обновили дату копии и исходного события
shallowCopy.date.setTime(456)

Как видно, мы не сделали полную копию этого объекта.

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

Почему не JSON.parse(JSON.stringify(x))?

На самом деле это отличный хак и на удивление производительный, но с некоторыми недостатками, которые устраняет structuredClone.

Возьмем для примера:

const calendarEvent = {
  title: "Builder.io Conf",
  date: new Date(123),
  attendees: ["Steve"]
}

// ???? JSON.stringify преобразовал дату в строку
const problematicCopy = JSON.parse(JSON.stringify(calendarEvent))

Если вывести ProblematicCopy, мы получим:

{
  title: "Builder.io Conf",
  date: "1970-01-01T00:00:00.123Z"
  attendees: ["Steve"]
}

Мы хотели не этого. date должен быть не строкой, а объектом Date.

Это произошло потому, что JSON.stringify может обрабатывать только базовые объекты, массивы и примитивы. Любой другой тип может быть обработан непредсказуемым образом. Например, Dates преобразуются в string. Но Set просто преобразуется в {}.

Что-то JSON.stringify даже игнорирует – например, undefined или функции.

Скажем, если мы скопируем пример kitchenSink с помощью этого метода:

const kitchenSink = {
  set: new Set([1, 3, 3]),
  map: new Map([[1, 2]]),
  regex: /foo/,
  deep: { array: [ new File(someBlobData, 'file.txt') ] },
  error: new Error('Hello!')
}

const veryProblematicCopy = JSON.parse(JSON.stringify(kitchenSink))

То мы получим:

{
  "set": {},
  "map": {},
  "regex": {},
  "deep": {
    "array": [
      {}
    ]
  },
  "error": {},
}

Фу!

И да, пришлось удалить циклическую ссылку, которая у нас изначально для этого была, поскольку JSON.stringify просто выдает ошибки, если встречается с одной из них.

Метод JSON.stringify удобен, в случае если наши требования соответствуют его возможностям. Однако с помощью StructuredClone можно сделать многое из того, чего не может JSON.stringify.

Почему не _.cloneDeep?

До сих пор распространенным решением этой проблемы была функция cloneDeep библиотеки Lodash.

Она действительно работает так, как ожидается:

import cloneDeep from 'lodash/cloneDeep'

const calendarEvent = {
  title: "Builder.io Conf",
  date: new Date(123),
  attendees: ["Steve"]
}

// ✅ Все в порядке 
const clonedEvent = structuredClone(calendarEvent)

Но с одной оговоркой. Согласно данным работы расширения Import Cost в IDE, которое выводит вес в Кб всего, что я импортирую, эта функция занимает 17,4 Кб в сжатом виде (5,3 Кб в архиве):

Это предполагает, что вы импортируете только эту функцию. Если вместо этого импортировать более распространенным способом, не принимая в расчет, что tree shaking не всегда работает так, как ожидается, можно случайно импортировать до 25 Кб только для этой одной функции.

Хотя это и не станет концом света, в нашем случае это просто не нужно – не тогда, когда браузеры уже имеют встроенный structuredClone.

Что structuredClone не может клонировать 

Функции

Иначе они вызовут исключение DataCloneError:

// ???? Ошибка!
structuredClone({ fn: () => { } })

Узлы DOM

Также выбрасывают исключение DataCloneError:

// ???? Ошибка!
structuredClone({ el: document.body })

Дескрипторы свойств, сеттеры и геттеры

Также не клонируются аналогичные метадата-подобные фичи.

К примеру, при использовании геттера клонируется результирующее значение, но не сама функция геттера (или любые другие метаданные свойства):

structuredClone({ get foo() { return 'bar' } })
// Становится: { foo: 'bar' }

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

Не происходит обход цепочки прототипов. Поэтому в случае клонирования экземпляра MyClass клонированный объект больше не будет известен как экземпляр этого класса. Но все валидные свойства этого класса будут клонированы.

class MyClass { 
  foo = 'bar' 
  myMethod() { /* ... */ }
}
const myClass = new MyClass()

const cloned = structuredClone(myClass)
// Становится: { foo: 'bar' }

cloned instanceof myClass // ложь

Полный список поддерживаемых типов

Все, что не входит в приведенный ниже список, клонировать нельзя:

JS Built-ins

Array, ArrayBuffer, Boolean, DataView, Date, Error types (указанные в списке ниже), Map , Object (но только простые объекты – например, из объектных литералов), примитивные типы (за исключением symbolnumber, string, null, undefined, boolean, BigInt), RegExp, Set, TypedArray

Error types (Ошибки типизации)

Error, EvalError, RangeError, ReferenceError , SyntaxError, TypeError, URIError

Web/API типы

AudioData, Blob, CryptoKey, DOMException, DOMMatrix, DOMMatrixReadOnly, DOMPoint, DomQuad, DomRect, File, FileList, FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemHandle, ImageBitmap, ImageData, RTCCertificate, VideoFrame

Поддержка браузеров и сред выполнения

И здесь самое интересное – structuredClone поддерживается во всех основных браузерах, и даже в Node.js и Deno.

Правда, с одной оговоркой – поддержка Web Workers более ограничена:

Источник: MDN

Заключение

Мы долго этого ждали, и теперь у нас наконец-то есть structuredClone, благодаря которому глубокое клонирование объектов в JavaScript становится простым делом. Спасибо, Surma.


В заключение статьи приглашаем на открытое занятие «Прототипное наследование в JavaScript», которое состоится завтра вечером. На занятии мы разберемся, что такое прототипное наследование и как оно может помочь при разработке программ. В результате вы лучше поймете объектную модель Javascript и сможете писать ООП код с экономией памяти. Запись на урок открыта по ссылке.

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


  1. Alexandroppolus
    00.00.0000 00:00
    +2

    Переполнение стека там тоже не предусмотрели. Всё как в lodash.

    structuredClone(Array.from({length: 100000}).reduce(a => ({a})))

    Так что если у вас объект "совсем произвольный", то надо промыслить нерекурсивный вариант...

    ----

    const shallowCopy = Object.create(simpleEvent)

    Эх, Стив, Стив..


  1. ruslan_astratov
    00.00.0000 00:00
    +1

    "Что structuredClone не может клонировать 

    Функции"

    Ну и зачем он тогда такой нужен

    Ясно понятно

    .cloneDeep по-прежнему рулит

    P.S. За статью спасибо


    1. flancer
      00.00.0000 00:00
      +1

      Ну и зачем он тогда такой нужен

      для клонирования данных.


    1. iliazeus
      00.00.0000 00:00

      Ещё важно, что она использует ровно тот же самый structured clone алгоритм, что и другие места спецификации, например, отправка сообщений воркерам.

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


    1. krulod
      00.00.0000 00:00

      А зачем клонировать функции? Вы там классы реализуете на cloneDeep?


  1. rutexd
    00.00.0000 00:00
    +2

    StructuredClone и вправду отличная функция. Если кто то клонирует функции - то тут надо задуматься.


    1. nin-jin
      00.00.0000 00:00
      +1

      Если кто-то глубоко клонирует что-либо - надо задуматься.


  1. Ivanovich_7
    00.00.0000 00:00

    Object.assign рекурсивно вызывать можно и не нужен lodash


  1. demimurych
    00.00.0000 00:00

    Необходимость в клонировании данных, вне зависимости от языка - индикатор фундаментальной ошибки в архитектуре приложения, или, для случая JS - не знание особенностей базовой архитектуры языка.

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

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


    1. TotalAMD
      00.00.0000 00:00
      +1

      Можете раскрыть мысль какими-то примерами или пояснениями, пожалуйста?


  1. kirill-pavlovskii15
    00.00.0000 00:00

    Интересно какие use case могут быть при клонировании объектов с функциями ? не разу не сталкивался с такими случаями, а вот клонировать глубоко вложенные объекты приходилось как то, так что думаю встроенная функция в языке очень даже неплохо.