Копирование и перенос данных в JavaScript: ссылки, structuredClone и transfer
Автор: Казачкин Даниил Михайлович · Обновлено
Присваивание объекта в JavaScript копирует ссылку на него, а не создаёт независимую копию данных. Чтобы выбрать между поверхностной копией, глубоким клонированием и переносом…
Присваивание объекта в JavaScript копирует ссылку на него, а не создаёт независимую копию данных. Чтобы выбрать между поверхностной копией, глубоким клонированием и переносом ресурса, нужно сначала определить, какие части состояния должны оставаться общими.
Среда и договорённость о слове «перенос»
Основной пример запускается в Node.js 22 или новее: сохраните его в copy.mjs и выполните node copy.mjs. В актуальном браузере используется та же глобальная функция structuredClone; DOM для примера не нужен. Предполагается понимание объектов, массивов и сравнения через ===. Перенос здесь означает специальную операцию над передаваемым ресурсом, а не универсальную семантику перемещения объектов из C++.
У переменной хранится значение. Для объекта это значение позволяет обратиться к одному и тому же объекту через разные переменные. Когда функция получает объект как аргумент, она получает значение этой ссылки. Она может изменить сам объект, но присваивание другой ссылки локальному параметру не переназначает переменную вызывающего кода. Поэтому формулировка «всё передаётся по ссылке» скрывает существенную разницу между изменением объекта и заменой локальной переменной.
Полный пример: три уровня независимости
const original = { title: 'План', progress: { done: 1 } };
const alias = original;
const shallow = { ...original };
const deep = structuredClone(original);
shallow.title = 'Копия';
shallow.progress.done = 2;
deep.progress.done = 9;
console.log(alias === original, shallow === original);
console.log(original.title, original.progress.done, deep.progress.done);
const bytes = new Uint8Array([10, 20, 30]);
const received = structuredClone({ bytes }, { transfer: [bytes.buffer] });
console.log(bytes.buffer.byteLength, received.bytes.join(','));Вывод: true false, затем План 2 9, затем 0 10,20,30. alias и original обозначают один объект. Spread создал новый внешний объект, поэтому новое название не затронуло исходное. Вложенный progress остался общим: запись через shallow видна через original. Глубокая копия получила независимый вложенный объект. Последняя операция передала содержимое буфера и отсоединила его от исходного представления.
Такой эксперимент лучше наблюдать через конкретные поля или сериализованный снимок. Консоль разработчика может показывать объект при позднем раскрытии, когда он уже изменён. Сообщение, которое выглядит как состояние «до», тогда визуально отражает состояние «после». Это особенность просмотра, а не доказательство неожиданного порядка исполнения.
Поверхностная копия как сознательный выбор
Поверхностная копия полезна, когда меняется только часть дерева состояния. Для обновления вложенного поля создают новые объекты вдоль изменяемого пути, а остальные ветви оставляют общими. Это позволяет сравнивать ссылки и не копировать большие неизменённые данные. Но дисциплина требует не изменять общие ветви после создания следующей версии состояния.
Spread копирует собственные перечисляемые свойства в новый объект. Он не сохраняет произвольный прототип, дескрипторы и поведение экземпляра класса. Если исходный объект содержит getter, чтение свойства при копировании может выполнить код. Поэтому операция не обязательно является безусловно пассивным снимком. Для обычных объектов данных это часто подходит; для объектов с поведением лучше явно определить метод преобразования в данные.
const запрещает переназначение переменной, но не делает объект неизменяемым. Object.freeze тоже не превращает всё вложенное дерево в глубокую неизменяемую структуру автоматически. Если задача — защитить контракт от случайных изменений, договоритесь о границе владения и способе обновления, а не полагайтесь на одну декларацию переменной.
Где подходит structuredClone
Алгоритм поддерживает циклические ссылки и ряд встроенных типов, включая массивы, Date, Map, Set и двоичные буферы. Повторные ссылки внутри копируемого графа сохраняют соответствующие связи: если два поля исходного объекта указывают на один вложенный объект, поля клона также будут указывать на один общий клонированный объект. При этом этот вложенный объект отличается от исходного.
Функции и DOM-узлы таким способом не клонируются; возникает DataCloneError. Не сохраняются произвольная цепочка прототипов и приватные поля пользовательского класса. Поэтому «глубокая копия» не означает точное воспроизведение любой сущности языка. Перед применением к незнакомой структуре определите допустимые типы и проверьте реальные данные на границе системы.
Приём JSON.parse(JSON.stringify(value)) является сериализацией через JSON. Он не равноценен structured clone: меняет представление дат, теряет некоторые значения и не поддерживает циклы или обычный BigInt. Иногда именно JSON и нужен для транспортного формата, но тогда полезнее назвать функцию сериализацией и проверить её контракт, чем обещать универсальное клонирование.
Transfer: передать ресурс и отказаться от старого доступа
Для ArrayBuffer список transfer означает передачу ресурса вместо дублирования его содержимого. Сам объект с данными всё равно должен входить в передаваемое значение. Добавить буфер только в список переноса недостаточно для получения его через результат. Передавать нужно буфер, а не объект Uint8Array, который является представлением поверх него.
После успешного переноса исходный буфер отсоединён; все представления, использовавшие этот буфер, затронуты. Не продолжайте читать данные через старые ссылки. Это полезно при передаче крупного блока вычислителю, который становится следующим владельцем, но неудобно, если исходному коду данные ещё нужны. В последнем случае нужна копия или другой явно согласованный механизм обмена. Отмена последующей операции не возвращает перенесённый ресурс автоматически.
Практика: обновить только выбранную ветвь
Создайте новую версию профиля с другим городом, сохранив общую ссылку на неизменённый список навыков. Исходный город должен остаться прежним. Решение ниже самодостаточно; перед запуском предскажите обе проверки равенства.
const profile = {
name: 'Лена',
address: { city: 'Томск', street: 'Учебная' },
skills: ['JavaScript'],
};
const updated = {
...profile,
address: { ...profile.address, city: 'Казань' },
};
console.log(profile.address.city, updated.address.city);
console.log(profile.address === updated.address);
console.log(profile.skills === updated.skills);Получатся Томск Казань, false, true. Новый адрес создан целиком, поэтому изменение города изолировано. Список навыков разделяется намеренно: если дальше требуется добавить навык только новой версии, этот массив также придётся заменить копией. Проверка ссылок здесь непосредственно проверяет архитектуру обновления, а не только видимый текст.
Частые вопросы
Можно ли «переместить» обычный объект присваиванием?
Нет. Можно передать ссылку и по соглашению перестать пользоваться старой переменной, но язык не делает другие ссылки недействительными. Специальный transfer применим только к поддерживаемым ресурсам.
Почему клон потерял методы класса?
Клонирование данных не восстанавливает произвольное поведение экземпляра. Отделите данные от поведения и при необходимости создавайте экземпляр через явный конструктор или фабрику.
Почему после переноса длина стала нулевой?
Для переданного буфера это ожидаемый признак отсоединения. Найдите все владельцы представлений до операции и проверьте, не используется ли один буфер несколькими частями приложения. Повторное копирование наугад может скрыть ошибку владения и увеличить расход памяти.
Связанные исследования
- Что сохраняет замыкание: окружения, связи и проверяемые предсказания — Модель замыканий через Environment Records: эксперименты с общей привязкой, снимком поля и окружениями итераций; граница между семантикой и удержанием памяти.