«Скопировал объект, поменял копию — испортился оригинал» — возможно, самый массовый баг в JavaScript-коде. Его корень один: объекты живут по ссылке, и наивное «копирование» копирует ссылку, а не данные. Разберём, как копировать по-настоящему — и почему во фронтенде вместо изменения объектов их принято пересоздавать.
Присваивание — не копияспросят на собеседовании
const user = { name: "Аня", tags: ["admin"] };
const copy = user; // скопирована ССЫЛКА
copy.name = "Боря";
user.name; // "Боря" — это один объект с двумя именами
То же самое происходит на каждом шагу неявно: объект, переданный в функцию, — та же ссылка; объект, положенный в массив, — ссылка; объект в состоянии React — ссылка. Функция, которая «немного подправила» аргумент, правит объект вызывающего кода — это источник самых дальнобойных багов: место поломки и место проявления разделены десятками файлов.
Сравнение тоже по ссылке:
({ a: 1 }) === ({ a: 1 }); // false — разные объекты (скобки нужны: {} в начале строки парсер примет за блок)
const x = { a: 1 };
x === x; // true — та же ссылка
Оператор === для объектов отвечает на вопрос «это тот же самый объект?», а не «одинаковое ли содержимое?». Сравнения «по содержимому» в языке нет — его пишут руками или берут из библиотек.
Поверхностная копия: spread и его пределспросят на собеседовании
const copy = { ...user }; // объект
const arr2 = [...arr]; // массив
const merged = { ...a, ...b }; // слияние: b перекрывает a
Spread копирует один уровень. Вложенные объекты остаются общими ссылками:
const copy = { ...user };
copy.name = "Боря"; // безопасно — верхний уровень скопирован
copy.tags.push("editor"); // ОПАСНО — tags общий!
user.tags; // ["admin", "editor"] — оригинал изменился
Это называется поверхностной (shallow) копией, и для плоских объектов её достаточно. Обновить вложенное без порчи оригинала — значит пересобрать каждый уровень на пути:
const updated = {
...user,
address: { ...user.address, city: "Казань" },
};
Глубокая копия: structuredCloneспросят на собеседовании
Когда нужна независимая копия всего дерева — structuredClone:
const deep = structuredClone(user);
deep.tags.push("editor");
user.tags; // ["admin"] — оригинал не тронут
Он справляется с вложенностью, датами, Map/Set и даже циклическими ссылками. Не умеет: функции и DOM-узлы (бросит ошибку). Старый трюк JSON.parse(JSON.stringify(x)) жив в легаси, но хуже по всем пунктам: теряет undefined, превращает даты в строки, падает на циклах.
Глубокая копия — не default, а инструмент по требованию: на больших структурах она дорогая. Обычный рабочий режим — поверхностная копия изменяемых уровней, как в примере с адресом.
Неизменяемость: почему React сравнивает ссылки
Во фронтенде господствует соглашение: не изменять объекты — создавать изменённые копии. Причина прагматичная: сравнить два дерева по содержимому дорого, а по ссылке — мгновенно. React (и не только) определяет «изменилось ли состояние» через ===:
// Мутация: ссылка та же — React не видит изменения, ререндера нет
state.items.push(newItem);
setState(state);
// Иммутабельно: новая ссылка — изменение видно
setState({ ...state, items: [...state.items, newItem] });
Отсюда же — частые методы-пары: изменяющие push/sort/splice против создающих concat/toSorted/slice. Современный JS добавил неизменяемые версии: toSorted, toReversed, with — сортировка без порчи оригинала теперь одна строчка.
Заморозить объект по-настоящему умеет Object.freeze(obj) — молча игнорирует записи (в строгом режиме — ошибка). Но он поверхностный, как spread, и в прикладном коде используется редко: соглашение «не мутируем» плюс TypeScript readonly работают дешевле.
Глубже: Map, Set, WeakMap и WeakSetрасширенноеспросят на собеседовании
Обычный объект годится как словарь, пока ключи это строки, а записей немного. Map снимает оба ограничения: ключом может быть что угодно, в том числе объект или функция, порядок вставки гарантирован, размер известен через size, а добавление и поиск не замедляются с ростом, потому что внутри хеш-таблица, а не свойства с их прототипной цепочкой. Set хранит уникальные значения с теми же свойствами и даёт проверку has за постоянное время там, где array.includes перебирает всё.
живой пример
const user = { id: 1 };
const visits = new Map();
visits.set(user, 3);
visits.set("guest", 1);
console.log(visits.get(user), visits.size); // 3 2
const ids = new Set([1, 2, 2, 3]);
console.log([...ids], ids.has(2)); // [ 1, 2, 3 ] true
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
WeakMap и WeakSet принимают ключами только объекты и держат их слабо: если на объект больше никто не ссылается, сборщик мусора заберёт его вместе с записью. Поэтому их нельзя перебрать и у них нет size, зато они не создают утечек. Типичное применение WeakMap: привязать служебные данные к чужому объекту, кеш по объекту-запросу, приватное состояние без изменения самого объекта. WeakSet отвечает на один вопрос «видели ли мы этот объект», например, чтобы не обработать его дважды. Разница между ними только в том, что WeakMap хранит значение, а WeakSet только факт присутствия.
Коротко
- Присваивание, передача в функцию, помещение в массив — везде копируется ссылка, не объект.
===сравнивает «тот же ли объект», а не содержимое. { ...obj }— копия одного уровня; вложенность остаётся общей. Обновление вложенного — пересборка каждого уровня на пути.- Глубокая копия —
structuredClone(не JSON-трюк). Дорого — использовать по необходимости. - Неизменяемость — про скорость сравнения: новая ссылка = сигнал «изменилось». Мутация состояния в React — невидимое изменение.
- Знать пары:
sortмутирует —toSortedнет;pushмутирует — spread/concatнет. Mapдля словаря с любыми ключами и порядком вставки,Setдля уникальных значений с быстрымhas;WeakMapиWeakSetдержат объекты слабо, не мешают сборщику мусора и не перебираются.
Что почитать дальше
- Типы в JavaScript — где начинается тема «значение против ссылки».
- Состояние: локальное и серверное — как неизменяемость работает в React-приложении.
- Промисы изнутри — следующая тема цикла.