Як скопіювати об'єкт у JavaScript?
Копіювання об'єкта в JavaScript створює новий об'єкт з тими самими даними, але поведінка залежить від того, яке копіювання ти робиш: поверхневе чи глибоке.
Теорія
Коротко
- Поверхнева копія (shallow copy) - як ксерокс листа із закритим конвертом: лист є, але конверт веде до оригінального вмісту
- Глибока копія (deep copy) - ксерокс всього пакету разом із вмістом конверта
- Поверхнева дублює тільки верхній рівень; вкладені об'єкти залишаються спільними посиланнями
- Плоский об'єкт без вкладеності:
{...obj}. Вкладені об'єкти, які змінюються незалежно:structuredClone JSON.parse(JSON.stringify(obj))- глибока копія, але губить функції,Date,Mapіundefined
Перший погляд на код
const original = { user: { name: 'Alice' }, id: 1 };
// Поверхнева - вкладений об'єкт залишається спільним посиланням
const shallow = { ...original };
shallow.user.name = 'Bob';
console.log(original.user.name); // "Bob" - оригінал змінився!
// Глибока - вкладений об'єкт повністю незалежний
const deep = structuredClone(original);
deep.user.name = 'Charlie';
console.log(original.user.name); // "Alice" - оригінал в безпеціЗміна shallow.user.name змінює і original.user.name, бо обидва вказують на один і той самий об'єкт в пам'яті. structuredClone повністю розриває цей зв'язок.
Головна різниця
Поверхнева копія дублює тільки значення верхнього рівня. Примітиви (рядки, числа, булеви) копіюються за значенням і залишаються незалежними. Але вкладені об'єкти та масиви копіюються за посиланням: і оригінал, і копія вказують на ту саму адресу в пам'яті. Змінив вкладені дані через копію - змінив і оригінал. Глибока копія обходить всю структуру рекурсивно і створює нові об'єкти на кожному рівні. Жодних спільних посилань. Мутації залишаються ізольованими.
Коли що використовувати
- Плоский конфіг без вкладеності:
{...obj}абоObject.assign({}, obj) - React-стан із вкладеними об'єктами:
structuredClone(state)перед оновленням - Тільки JSON-дані, без Date, функцій і Map:
JSON.parse(JSON.stringify(obj)) - Об'єкт містить
Date,MapабоSet:structuredClone(Chrome 98+, Node 17.5+) - Legacy-середовище або складні класи:
lodash.cloneDeep - Спільні read-only дані, які ніколи не змінюються: поверхнева копія - швидша і достатня
Таблиця порівняння
| Метод | Тип | Функції? | Date/Map/Set? | Продуктивність |
|---|---|---|---|---|
{...obj} | Shallow | Ні | Ні (копіює посилання) | Найшвидший |
Object.assign({}, obj) | Shallow | Ні | Ні (копіює посилання) | Швидкий |
JSON.parse(JSON.stringify(obj)) | Deep | Ні (губить) | Ні (Date стає рядком) | Середній |
structuredClone(obj) | Deep | Ні (кидає помилку) | Так | Швидкий |
lodash.cloneDeep(obj) | Deep | Так | Так | Найповільніший |
Як це працює під капотом
Spread і Object.assign перебирають власні enumerable-ключі через внутрішній метод [[GetOwnPropertyKeys]] і копіюють значення за посиланням без рекурсії. Саме тому вони швидкі і зупиняються на першому рівні. JSON.stringify серіалізує об'єкт у рядок, пропускаючи non-enumerable властивості, функції і undefined. Потім JSON.parse відбудовує нове дерево об'єктів. structuredClone використовує алгоритм HTML Structured Clone - той самий механізм, що і в postMessage. Він підтримує Date, Map, Set і Blob, але явно відхиляє функції і WeakMap.
Типові помилки
1. Вважати, що spread копіює вкладені об'єкти
// Помилка
const state = { user: { prefs: {} } };
const copy = { ...state };
copy.user.prefs.dark = true;
console.log(state.user.prefs.dark); // true - стан змінився
// Виправлення: structuredClone(state)2. JSON-копія з функціями або undefined
JSON.stringify({ fn: () => {}, undef: undefined });
// Результат: "{}" - обидві властивості зникли без жодної помилки
// Виправлення: structuredClone або lodash.cloneDeep3. Date перетворюється на рядок після JSON
const obj = { created: new Date() };
const copy = JSON.parse(JSON.stringify(obj));
console.log(copy.created instanceof Date); // false - це вже рядок
// Виправлення: structuredClone зберігає екземпляр Date4. Object.assign з цільовим об'єктом
// Помилка - безпосередньо змінює myObject
Object.assign(myObject, source);
// Виправлення
Object.assign({}, myObject, source);5. Кругові посилання (circular references)
const obj = {};
obj.self = obj;
JSON.stringify(obj); // RangeError: circular structure
structuredClone(obj); // DataCloneError
// Виправлення: lodash.cloneDeep коректно працює з круговими посиланнямиДе це зустрічається на практиці
- React:
structuredClone(state)для незмінних оновлень вкладеного стану - Redux Toolkit: всередині використовує Immer, але
structuredCloneпідходить для міграції серіалізованого стану - Express-middleware:
const data = structuredClone(req.body), щоб не мутувати оригінальний запит - Node.js: spread для злиття конфігів,
structuredCloneдля передачі даних між worker-потоками - Lodash:
cloneDeepвикористовується в понад 1000 npm-пакетах як безпечний варіант для legacy-проектів
З мого досвіду, найпоширеніший production-баг з цієї теми - мутація React-стану через поверхневу копію вкладеного об'єкта. Компонент ніби ре-рендериться, але показує застарілі дані, бо посилання на вкладений об'єкт не змінилось.
Питання на співбесіді
Q: Що виведе original.b.c, якщо зробити const copy = { ...{ a: 1, b: { c: 2 } } } і потім copy.b.c = 3?
A: original.b.c стане 3. Spread копіює тільки верхній рівень, тому b залишається спільним посиланням.
Q: Чому JSON.stringify не серіалізує функції?
A: JSON - це текстовий формат за специфікацією RFC 8259. Функції в ньому не передбачені, тому JSON.stringify просто їх пропускає без помилки.
Q: Реалізуй поверхневу копію без вбудованих методів.
A: Object.keys(obj).reduce((acc, k) => { acc[k] = obj[k]; return acc; }, {}) - перебираєш власні ключі і копіюєш значення по одному.
Q: Коли structuredClone кидає помилку?
A: Кидає DataCloneError для типів, які не можна клонувати: WeakMap, WeakSet, функції, DOM-вузли. Також кидає на кругових посиланнях, на відміну від lodash.cloneDeep.
Q: Що відбувається з prototype-властивостями і non-enumerable props при spread?
A: Обидва губляться. Spread копіює тільки власні enumerable-ключі. Якщо оригінал створений через Object.create(proto), копія не матиме прив'язки до прототипу. Властивості, визначені через Object.defineProperty з enumerable: false, теж пропускаються.
Приклади
Пастка поверхневої копії на практиці
const config = {
server: { host: 'localhost', port: 3000 },
timeout: 5000
};
const devConfig = { ...config };
devConfig.timeout = 8000; // OK - примітив, незалежна копія
devConfig.server.port = 4000; // Погано - мутує оригінал!
console.log(config.timeout); // 5000 - в безпеці
console.log(config.server.port); // 4000 - зміненоЗміна timeout безпечна, бо це примітив, а примітиви копіюються за значенням. Зміна server.port мутує оригінал, бо config.server і devConfig.server вказують на один і той самий об'єкт. Щоб захистити вкладені дані, заміни spread на structuredClone(config).
Оновлення React-стану через structuredClone
const [userData, setUserData] = useState({
profile: { name: 'Alice', settings: { theme: 'dark' } }
});
// Неправильно: spread не захищає вкладені settings
const badUpdate = { ...userData };
badUpdate.profile.settings.theme = 'light';
setUserData(badUpdate); // стан вже змутовано ще до виклику setUserData
// Правильно: structuredClone створює повністю незалежну копію
const goodUpdate = structuredClone(userData);
goodUpdate.profile.settings.theme = 'light';
setUserData(goodUpdate);
// userData.profile.settings.theme залишається 'dark' до наступного рендеруНеправильний варіант мутує стан ще до виклику setUserData. React може не побачити зміну, бо посилання на profile не змінилось - старий і новий об'єкти стану виглядають однаково для reconciler.
Edge case: прототипи, non-enumerable властивості і кругові посилання
// Прототип і non-enumerable
const original = Object.create({ proto: 'shared' });
original.own = { nest: 'value' };
Object.defineProperty(original, 'hidden', { value: 'secret', enumerable: false });
const spread = { ...original };
console.log(spread.proto); // undefined - прототип загубився
console.log(spread.hidden); // undefined - non-enumerable пропущено
const json = JSON.parse(JSON.stringify(original));
console.log(json.proto); // undefined
const cloned = structuredClone(original);
console.log(cloned.proto); // 'shared' - structuredClone зберігає ланцюжок прототипів
// Кругове посилання
const circular = { name: 'test' };
circular.self = circular;
// JSON.stringify(circular) - RangeError
// structuredClone(circular) - DataCloneError
import _ from 'lodash';
const safe = _.cloneDeep(circular); // справляється коректноТільки structuredClone зберігає ланцюжок прототипів з Object.create. Тільки lodash.cloneDeep виживає при кругових посиланнях без помилок. Саме в цих двох ситуаціях spread і JSON або мовчки дають неправильний результат, або кидають виняток.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.