Skip to main content

Незмінність та змінність у JavaScript

Мутабельність (mutability) означає, що об'єкт можна змінювати безпосередньо після створення. Іммутабельність (immutability) означає, що для будь-якої зміни створюється новий об'єкт, а оригінал залишається недоторканим.

Теорія

TL;DR

  • Мутабельні об'єкти схожі на спільну дошку: хто має посилання, той змінює ті самі дані напряму
  • Іммутабельні оновлення схожі на ксерокопії: редагування дає нову копію, оригінал залишається як є
  • Головна різниця: мутація змінює спільну область пам'яті; іммутабельність виділяє нову пам'ять і розриває зв'язок між посиланнями
  • Використовуй іммутабельність для спільного стану (React props, Redux store); мутабельність - для приватних, перформанс-критичних операцій
  • const НЕ робить об'єкт іммутабельним. Він тільки блокує перепризначення змінної.

Швидкий приклад

js
// Мутабельно: всі посилання бачать зміну 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 робить глибоку копію:

js
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:

js
// Неправильно: те саме посилання, 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 для іммутабельності:

js
const arr = [1, 2]; arr.push(3); // працює без помилок console.log(arr); // [1, 2, 3]

const блокує перепризначення змінної, а не мутацію значення. Для shallow-іммутабельності використовуй Object.freeze(arr), для вкладених структур - іммутабельні патерни.

Мутувати аргументи функції:

js
// Неправильно: нечиста функція, ламає React.memo і Redux selectors function addItem(list, item) { list.push(item); return list; } // Правильно: повертає новий масив function addItem(list, item) { return [...list, item]; }

Мемоїзація покладається на стабільні посилання вхідних даних. Мутуєш вхідний масив - кеш мемо стає некоректним непередбачуваним чином.

Очікувати глибоку копію від Object.assign:

js
const copy = Object.assign({}, state); // shallow copy.nested.arr.push(1); // мутує state.nested.arr

Object.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 на кожному рівні вкладеності.

Приклади

Пастка спільного посилання

js
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

js
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

js
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 і циклічні посилання. Функції та символи не копіює - пам'ятай про це для об'єктів з методами.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?