Незмінність та змінність у JavaScript
Мутабельність (mutability) означає, що об'єкт можна змінювати безпосередньо після створення. Іммутабельність (immutability) означає, що для будь-якої зміни створюється новий об'єкт, а оригінал залишається недоторканим.
Теорія
TL;DR
- Мутабельні об'єкти схожі на спільну дошку: хто має посилання, той змінює ті самі дані напряму
- Іммутабельні оновлення схожі на ксерокопії: редагування дає нову копію, оригінал залишається як є
- Головна різниця: мутація змінює спільну область пам'яті; іммутабельність виділяє нову пам'ять і розриває зв'язок між посиланнями
- Використовуй іммутабельність для спільного стану (React props, Redux store); мутабельність - для приватних, перформанс-критичних операцій
constНЕ робить об'єкт іммутабельним. Він тільки блокує перепризначення змінної.
Швидкий приклад
// Мутабельно: всі посилання бачать зміну
const mutableUser = { name: 'Alice' };
const ref = mutableUser;
mutableUser.name = 'Bob';
console.log(ref.name); // 'Bob' - спільне посилання змінилось
// Іммутабельно: оригінал залишається незмінним
const immutableUser = { name: 'Alice' };
const updated = { ...immutableUser, name: 'Bob' };
console.log(immutableUser.name); // 'Alice' - незмінний
console.log(updated.name); // 'Bob'Spread (...) копіює власні властивості в новий об'єкт за новою адресою пам'яті. Дві змінні тепер вказують на різні місця, тому зміни в одній не впливають на іншу.
Ключова різниця
Об'єкти в JavaScript - це посилальні типи. Мутабельна операція obj.name = 'Bob' перезаписує властивість у heap-слоті, який спільно використовують усі змінні, що вказують на obj. Іммутабельна операція { ...obj, name: 'Bob' } виділяє новий простір у heap і shallow-копіює власні властивості туди. Вартість O(n), але оригінальне посилання повністю ізольоване.
Коли що використовувати
- Спільний UI-стан (React props, useState) - іммутабельність. React порівнює за посиланням (
===), а не за вмістом. Мутація того самого об'єкта не викличе ре-рендер. - Redux store - іммутабельність. Дозволяє time-travel debugging і передбачувану історію стану.
- Перформанс-критичні приватні дані (tight loops, великі буфери) - мутабельність. Немає overhead від копіювання.
- Чисті функції і тестовані утиліти - іммутабельність. Передбачувані вхідні/вихідні дані, мемоїзація працює коректно.
- Локальні змінні з одним власником - мутабельність. Простіший код, без витрат.
Таблиця порівняння
| Властивість | Мутабельність | Іммутабельність |
|---|---|---|
| Зміна даних | На місці в оригіналі | Створюється новий об'єкт |
| Ре-рендер у React | Може не спрацювати (те саме посилання) | Завжди спрацьовує (нове посилання) |
| Побічні ефекти | Можливі між посиланнями | Ізольовані за визначенням |
| Налагодження | Важче простежити | Чітка історія змін |
| Перформанс | Немає overhead від копіювання | O(n) вартість копіювання |
| Типове використання | Приватні локальні дані, буфери | Спільний стан, Redux, props |
Як це працює у V8
V8 зберігає об'єкти як структури у heap. obj.prop = val перезаписує слот властивості через разименування вказівника - без алокації, константний час. { ...obj } запускає OrdinaryObjectCreate + CopyDataProperties: новий простір у heap, shallow-копія власних перелічуваних властивостей. Копія коштує O(n) відносно кількості властивостей. Але shallow означає, що вкладені об'єкти досі спільні - звідси найпоширеніша пастка зі spread.
Типові помилки
Припускати, що spread робить глибоку копію:
const state = { user: { profile: { friends: ['Alice'] } } };
const copy = { ...state }; // тільки shallow copy!
copy.user.profile.friends.push('Bob');
console.log(state.user.profile.friends); // ['Alice', 'Bob'] - оригінал змінивсяSpread копіює тільки верхній рівень властивостей. Вкладені об'єкти залишаються спільними посиланнями. Саме ця пастка з вкладеними посиланнями найчастіше застає розробників зненацька, коли вони вперше приходять у React-проект. Виправлення: structuredClone(state) (Node 17+, Chrome 98+) для повної глибокої копії.
Мутувати стан напряму у React:
// Неправильно: те саме посилання, React пропускає ре-рендер
const markDoneWrong = () => {
todos[0].done = true;
setTodos(todos); // shallow перевірка посилання провалюється - UI завис
};
// Правильно: новий масив з новим об'єктом
const markDoneCorrect = () => {
setTodos(todos.map(todo =>
todo.id === 1 ? { ...todo, done: true } : todo
));
};Reconciler React використовує shallow-порівняння посилань. Якщо посилання не змінилось, React пропускає ре-рендер повністю. Без помилки, без попередження - просто застарілий UI.
Розраховувати на const для іммутабельності:
const arr = [1, 2];
arr.push(3); // працює без помилок
console.log(arr); // [1, 2, 3]const блокує перепризначення змінної, а не мутацію значення. Для shallow-іммутабельності використовуй Object.freeze(arr), для вкладених структур - іммутабельні патерни.
Мутувати аргументи функції:
// Неправильно: нечиста функція, ламає React.memo і Redux selectors
function addItem(list, item) {
list.push(item);
return list;
}
// Правильно: повертає новий масив
function addItem(list, item) {
return [...list, item];
}Мемоїзація покладається на стабільні посилання вхідних даних. Мутуєш вхідний масив - кеш мемо стає некоректним непередбачуваним чином.
Очікувати глибоку копію від Object.assign:
const copy = Object.assign({}, state); // shallow
copy.nested.arr.push(1); // мутує state.nested.arrObject.assign копіює тільки власні перелічувані властивості верхнього рівня. Та сама пастка, що і зі spread. Використовуй structuredClone або _.cloneDeep з lodash, коли потрібна повна глибина.
Де це зустрічається
- React useState:
setState({ ...prev, name: 'Bob' })- патерн з офіційних docs, створює нове посилання для reconciler - Redux Toolkit:
createSliceвикористовує Immer всередині - проксує мутації на draft і виробляє іммутабельний патч - Immer: дозволяє писати
state.user.name = 'Bob'всерединіproduce(), Immer автоматично перетворює це на іммутабельне оновлення - Lodash:
_.cloneDeep(obj)у Express middleware для безпечного клонування тіл запитів - Node.js streams: внутрішні Buffer мутабельні для перформансу, але event payloads між хендлерами копіюються іммутабельно
Питання на співбесіді
Q: Що виведе цей код і чому: const a = [1]; const b = a; a.push(2); console.log(b);
A: [1, 2]. Масиви - посилальні типи. b і a вказують на те саме місце у heap. push мутує на місці, тому b відображає зміну.
Q: Чому React не робить ре-рендер при прямій мутації стану?
A: Reconciler React порівнює посилання через ===. Якщо setState отримує те саме посилання, перевірка проходить як «без змін» і цикл рендерингу пропускається.
Q: Яка вартість іммутабельності для перформансу?
A: Shallow-копія через spread коштує O(n), де n - кількість власних властивостей. Бібліотеки на кшталт Immer використовують structural sharing: копіюється тільки змінений шлях, незмінені гілки зберігають оригінальні посилання. Для великого вкладеного стану це суттєво зменшує витрати на алокацію.
Q: Чим Object.freeze відрізняється від справжньої іммутабельності?
A: Object.freeze робить один рівень об'єкта read-only без виділення нового об'єкта. Кидає помилку у strict mode, тихо ігнорує запис в sloppy mode. Вкладені об'єкти залишаються мутабельними. Справжня іммутабельність у Redux-патернах означає нове посилання при кожному оновленні.
Q: Коли обирати structuredClone замість JSON.parse(JSON.stringify(x))?
A: Майже завжди. structuredClone підтримує циклічні посилання, Date, Map, Set і типізовані масиви. JSON.parse/stringify втрачає undefined і функції, перетворює Date на рядки і не вміє в циклічні посилання. Єдина причина для JSON.parse/stringify - підтримка середовищ, старших за Chrome 98 або Node 17, без поліфілу.
Q (senior): Як draft proxy в Immer забезпечує ефективні іммутабельні оновлення?
A: Immer обгортає стан у Proxy. Всередині produce() він відстежує, які шляхи були доступні і змінені. При комміті застосовує structural sharing: незмінені піддерева зберігають оригінальні посилання, копіюються тільки змінені вузли. Оновлення одного поля у великому об'єкті коштує O(depth), а не O(total properties). Тому Redux Toolkit використовує Immer за замовчуванням замість ручного spread на кожному рівні вкладеності.
Приклади
Пастка спільного посилання
const original = { name: 'Alice', scores: [10, 20] };
const copy = { ...original };
copy.name = 'Bob';
copy.scores.push(30); // мутує original.scores!
console.log(original.name); // 'Alice' - верхній рівень в порядку
console.log(original.scores); // [10, 20, 30] - вкладений масив змінивсяSpread створює новий об'єкт верхнього рівня, але вкладені властивості досі вказують на ті самі посилання. Для безпечного оновлення вкладеного масиву копіюй і його: { ...original, scores: [...original.scores, 30] }.
Оновлення todos у React
const [todos, setTodos] = useState([
{ id: 1, text: 'Learn JS', done: false }
]);
// Неправильно: мутує оригінал, React пропускає ре-рендер
const markDoneWrong = () => {
todos[0].done = true;
setTodos(todos); // те саме посилання - UI не оновиться
};
// Правильно: новий масив з новим об'єктом для зміненого елемента
const markDoneCorrect = () => {
setTodos(todos.map(todo =>
todo.id === 1 ? { ...todo, done: true } : todo
));
};map повертає новий масив. Spread всередині створює новий об'єкт todo. React отримує нове посилання, бачить зміну і запускає ре-рендер.
Глибоке клонування через structuredClone
const state = {
user: { profile: { friends: ['Alice'] } }
};
// Node 17+, Chrome 98+
const safeCopy = structuredClone(state);
safeCopy.user.profile.friends.push('Bob');
console.log(state.user.profile.friends); // ['Alice'] - оригінал безпечний
console.log(safeCopy.user.profile.friends); // ['Alice', 'Bob']structuredClone робить повну глибоку копію. Підтримує вкладені об'єкти, масиви, Date, Map, Set і циклічні посилання. Функції та символи не копіює - пам'ятай про це для об'єктів з методами.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.