← назад к разделу

«Скопировал объект, поменял копию — испортился оригинал» — возможно, самый массовый баг в 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 работают дешевле.

Коротко

  • Присваивание, передача в функцию, помещение в массив — везде копируется ссылка, не объект. === сравнивает «тот же ли объект», а не содержимое.
  • { ...obj } — копия одного уровня; вложенность остаётся общей. Обновление вложенного — пересборка каждого уровня на пути.
  • Глубокая копия — structuredClone (не JSON-трюк). Дорого — использовать по необходимости.
  • Неизменяемость — про скорость сравнения: новая ссылка = сигнал «изменилось». Мутация состояния в React — невидимое изменение.
  • Знать пары: sort мутирует — toSorted нет; push мутирует — spread/concat нет.

Что почитать дальше

  • Типы в JavaScript — где начинается тема «значение против ссылки».
  • Состояние: локальное и серверное — как неизменяемость работает в React-приложении.
  • Промисы изнутри — следующая тема цикла.