Копирование объектов: поверхностное и глубокое

объекты≈ 7 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Строка const copy = user не создаёт копию — она создаёт второе имя для того же объекта. Разберёмся, как получить настоящую копию и почему «настоящая» бывает двух глубин.

Разделы темы:·

Переменная не хранит объект целиком — она хранит ссылку на него, адрес коробки на складе. Присваивание переписывает адрес в новую переменную, а склад остаётся один. Поэтому две переменные могут смотреть на один и тот же объект и мешать друг другу.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

С примитивами такого не бывает: let b = a для числа или строки даёт независимое значение. Объекты, массивы и функции — другое дело, и === для них отвечает не «одинаковое ли содержимое», а «один ли это объект».

Чтобы получить новый объект, его нужно создать явно и переложить в него свойства. Короткий способ — spread-синтаксис { ...obj }: он создаёт свежий объект и копирует в него все свои перечисляемые свойства исходного.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Object.assign(target, ...sources) делает то же самое, но пишет в существующий объект и возвращает его. Он удобен, когда нужно слить несколько источников: свойства правых источников перекрывают левые. Первым аргументом почти всегда стоит пустой {} — иначе вы мутируете чужой объект.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Копия через spread — всегда обычный объект. Прототип не переносится: скопировав экземпляр класса, вы получите набор данных без методов.

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

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Такие баги самые неприятные: код выглядит правильно, объект «скопирован», а правка в одном месте всплывает в другом через неделю. Как только в объекте появляется вложенность, нужно осознанно решить — вам хватит поверхностной копии или требуется глубокая.

const замораживает не объект, а привязку имени к ссылке. Менять содержимое можно, нельзя присвоить переменной другой объект.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Глубокая копия — это копия объекта вместе со всем содержимым на всех уровнях. Раньше её приходилось писать руками или тащить lodash, теперь в браузерах и Node есть встроенная функция structuredClone(value). Что ещё из lodash забрала себе платформа и когда маленькая библиотека утилит всё-таки нужна — на полке браузера в части II.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Она умеет то, обо что спотыкаются самодельные рекурсии: правильно копирует Date, Map, Set, массивы и типизированные массивы, а циклические ссылки не зацикливают её, а переносятся в копию как циклы. Единственное серьёзное ограничение — функции и DOM-узлы скопировать нельзя, на них будет ошибка DataCloneError.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Учебная версия на рекурсии: примитивы возвращаем как есть, объекты и массивы обходим по ключам. Она не умеет ни Date, ни циклы — но хорошо показывает саму идею.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Старый народный приём: превратить объект в JSON-строку и разобрать обратно — JSON.parse(JSON.stringify(obj)). Глубокая копия действительно получается, но по дороге объект проходит через формат, который знает только строки, числа, булевы значения, null, массивы и простые объекты. Всё остальное теряется молча.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Плюс на циклической ссылке этот приём просто падает с ошибкой. Вывод простой: для чистых данных с сервера он допустим, во всех остальных случаях берите structuredClone.

Что в объектеspreadstructuredCloneJSON
Вложенный объектобщийкопируетсякопируется
Функцияпереноситсяошибкаисчезает
Dateобщая ссылканастоящая Dateстрока
Map и Setобщая ссылкакопируютсяпустой {}
Свойство со значением undefinedостаётсяостаётсяключ исчезает
Циклическая ссылкане мешаетсохраняетсяошибка

  • Переменная хранит ссылку на объект, поэтому присваивание даёт второе имя, а не копию; === сравнивает именно идентичность объектов.
  • { ...obj } и Object.assign({}, obj) создают новый объект, но копируют только верхний уровень — вложенные объекты остаются общими.
  • Такая копия называется поверхностной; она безопасна для плоских данных и опасна, как только появляется вложенность.
  • structuredClone(obj) делает глубокую копию, переносит Date, Map, Set и циклические ссылки, но падает на функциях и DOM-узлах.
  • JSON.parse(JSON.stringify(obj)) тоже копирует вглубь, однако теряет функции, свойства со значением undefined, превращает Date в строку и ломается на циклах.

1/3Настройки слили вызовом Object.assign(defaults, prefs) — без пустого объекта первым аргументом. Что станет с defaults?

2/3Объявлено const user = { sizes: { h: 170 } } и const clone = { ...user }. Что выведет console.log(user.sizes === clone.sizes)?

3/3Что произойдёт при JSON.parse(JSON.stringify(obj)), если у obj есть свойство-метод и свойство типа Date?

Копия, которая ничего не делит с оригиналом

Код собирался сделать копию отчёта и поменять в ней счётчик просмотров, но испортил оригинал. Почините его так, чтобы source.meta.views остался равен 10.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Spread копирует только верхний уровень, поэтому meta у оригинала и копии — один объект. Нужна глубокая копия:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Для такого объекта сработал бы и JSON.parse(JSON.stringify(source)) — здесь только строки и числа. Но стоит добавить в meta дату или метод, и приём начнёт тихо терять данные, поэтому привычкой лучше сделать structuredClone.

Слияние настроек без порчи исходника

Напишите функцию merge(defaults, prefs), которая возвращает новый объект с настройками пользователя поверх умолчаний. Ни defaults, ни prefs меняться не должны.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.

Раскрываем оба объекта в новый литерал: свойства правого перекрывают левый, а оба источника остаются нетронутыми.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Эквивалент через Object.assign({}, defaults, prefs) — важен именно пустой первый аргумент. Вариант Object.assign(defaults, prefs) испортил бы умолчания. И помните: если внутри настроек появятся вложенные объекты, такое слияние останется поверхностным.