Skip to main content

Object.freeze(), object.seal() та object.assign() у JavaScript

Object.freeze(), Object.seal() та Object.assign() - це три статичні методи на Object, які вирішують дві різні задачі: freeze і seal контролюють незмінність об'єктів, а assign копіює властивості між ними.

Теорія

TL;DR

  • Object.freeze() блокує все: не можна ні додавати, ні видаляти, ні змінювати значення. Замкнений сейф.
  • Object.seal() фіксує структуру, але дозволяє оновлювати значення. Запаяна коробка.
  • Object.assign() копіює перераховувані власні властивості із джерел до цільового об'єкта, поверхнево. Ксерокс.
  • Всі три поверхневі: вкладені об'єкти залишаються мутабельними незалежно від того, що застосовано до батьківського.
  • Правило вибору: незмінний конфіг або константи - freeze. Фіксована форма, але значення оновлюються - seal. Клонування або злиття - assign.

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

javascript
const base = { a: 1, b: { c: 2 } }; const frozen = Object.freeze({ ...base }); const sealed = Object.seal({ ...base }); const copied = Object.assign({}, base); frozen.a = 99; // Не спрацює (TypeError у strict mode) frozen.b.c = 99; // Спрацює! Вкладений об'єкт не заморожений sealed.a = 99; // Спрацює - існуючі значення можна змінювати sealed.d = 4; // Не спрацює - нові властивості заборонені copied.a = 99; // Спрацює - копія незалежна на верхньому рівні console.log(base.a); // 1 - оригінал не змінився

Три методи, три різні поведінки. Зверни увагу: frozen.b.c все одно мутується, бо freeze блокує лише верхній рівень. Це класична пастка.

Ключова різниця

Object.freeze() встановлює кожен дескриптор властивості у { writable: false, configurable: false } і позначає об'єкт нерозширюваним. Object.seal() також позначає об'єкт нерозширюваним і встановлює configurable: false, але залишає writable: true - тому існуючі значення можна змінювати, а структуру - ні. Object.assign() не має нічого спільного з незмінністю: він ітерує джерела і копіює їх перераховувані власні властивості до цільового об'єкта поверхнево, читаючи через [[Get]] і записуючи через [[Set]].

Коли що використовувати

  • Константи на рівні всього застосунку, типи дій Redux або конфіг середовища: Object.freeze()
  • Схема форми або форма (shape) API-відповіді, де структура фіксована, але значення полів оновлюються: Object.seal()
  • Злиття налаштувань користувача з дефолтами без мутації жодного з них: Object.assign({}, defaults, userOptions)
  • Поверхневий клон перед трансформацією: Object.assign({}, obj) або spread-еквівалент { ...obj }
  • Заповнити відсутні поля на існуючому об'єкті з кількох джерел: Object.assign(target, source1, source2)

Таблиця порівняння

ФункціональністьObject.freeze()Object.seal()Object.assign()
Блокує додаванняТакТакНі (копіює до target)
Блокує видаленняТакТакНі
Блокує зміну значеньТакНіНі
Копіює властивостіНіНіТак (поверхнево)
Вкладені об'єктиТільки поверхневоТільки поверхневоТільки поверхнево
Типовий сценарійRedux-константи, конфігСхеми форм, напів-фіксований станКлонування/злиття, defaultProps

Object.preventExtensions() знаходиться нижче за обидва: він лише блокує додавання нових властивостей, залишаючи видалення та зміну значень відкритими. Freeze - найсуворіший, далі seal, далі preventExtensions.

Як це працює в рушії

V8 реалізує Object.freeze() через виклик PreventExtensions на об'єкті, а потім ітерує всі власні дескриптори властивостей і встановлює кожен у { writable: false, configurable: false }. Операції доступу до властивостей перевіряють ці прапори під час Set та Delete в intrinsics рушія.

Object.seal() також викликає PreventExtensions і встановлює configurable: false для кожного дескриптора, але пропускає прапор writable. Саме ця одна різниця пояснює, чому sealed.a = 99 спрацьовує, а frozen.a = 99 - ні.

Object.assign() запускає C++-цикл по кожному джерелу: перевіряє HasOwnProperty, читає через GetOwnPropertyDescriptor, потім записує до цільового об'єкта через DefineOwnProperty. Неперераховувані властивості та властивості ланцюжка прототипів пропускаються повністю. Оскільки читання відбувається через [[Get]], а не через копіювання дескриптора, геттери викликаються і їхнє повернуте значення зберігається на target як звичайна data-властивість.

Це важливо знати з практичного досвіду: якщо мутуєш заморожену властивість у strict mode, V8 кидає TypeError одразу. У loose mode рушій просто повертає об'єкт без змін і без жодної помилки. Це мовчазне ігнорування - реальне джерело багів у продакшені, де помилка виникає далеко від місця мутації.

Типові помилки

1. Думати, що freeze глибокий

javascript
const settings = Object.freeze({ db: { host: 'localhost' } }); settings.db.host = 'prod-server'; // Спрацює - мутація пройшла console.log(settings.db.host); // 'prod-server'

Посилання на db не можна замінити, але сам об'єкт, на який воно вказує, - звичайний мутабельний. Freeze заблокував лише обгортку. Рішення: рекурсивний deep freeze.

javascript
function deepFreeze(obj) { Object.freeze(obj); Object.getOwnPropertyNames(obj).forEach(key => { const val = obj[key]; if (val && typeof val === 'object') deepFreeze(val); }); return obj; }

2. Використовувати assign для глибокого клонування

javascript
const orig = { nest: { val: 1 } }; const copy1 = Object.assign({}, orig); const copy2 = Object.assign({}, orig); copy1.nest.val = 99; console.log(copy2.nest.val); // 99 - спільне посилання!

Обидві копії посилаються на один і той самий об'єкт nest, бо assign скопіював лише посилання. Рішення: structuredClone(obj) (Node 17+, сучасні браузери) або JSON.parse(JSON.stringify(obj)) для простих серіалізованих даних.

3. Очікувати, що assign копіює дескриптори властивостей

javascript
const source = Object.create(null, { id: { value: 42, writable: false, enumerable: true } }); const target = Object.assign({}, source); target.id = 100; // Спрацює! writable:false не перенісся console.log(target.id); // 100

Object.assign() читає значення і записує його як звичайну data-властивість. Прапор writable: false із дескриптора джерела зникає. Для копіювання повних дескрипторів використовуй Object.getOwnPropertyDescriptors() разом із Object.defineProperties().

4. Пропуск неперераховуваних властивостей

javascript
const obj = { pub: 1 }; Object.defineProperty(obj, 'secret', { value: 2, enumerable: false }); const copy = Object.assign({}, obj); console.log(copy.secret); // undefined - пропущено

Assign торкається лише перераховуваних власних властивостей. Символьні ключі з enumerable: false також пропускаються. Це ловить людей, які додають метадані через defineProperty і потім дивуються, чому вони зникають після злиття.

5. Мовчазна помилка поза strict mode

javascript
const frozen = Object.freeze({ name: 'Alice' }); frozen.name = 'Bob'; // Ніякої помилки у loose mode console.log(frozen.name); // 'Alice' - зміна проігнорована

Завжди запускай код у strict mode, щоб TypeError виникав одразу. Якщо треба перевірити стан програмно - Object.isFrozen(obj), Object.isSealed(obj) та Object.isExtensible(obj) дають точну відповідь.

Де зустрічається в реальних проектах

  • React (застарілі класові компоненти): Object.assign() використовувався всередині для злиття (merge) defaultProps із пропсами, що приходять
  • Redux Toolkit: Object.freeze() на константах типів дій, щоб заблокувати випадкову мутацію в редьюсерах
  • Express та body-parser: Object.assign() для злиття дефолтних опцій middleware з налаштуваннями користувача
  • CLI-інструменти на Node.js (webpack config): Object.seal() на підмножинах process.env для фіксації структури
  • Lodash: _.assign() як кросбраузерний поліфіл; окрема утиліта deepFreeze у модулі utils

Питання на співбесіді

Q: Яка різниця в дескрипторах властивостей між freeze і seal?
A: Object.freeze() встановлює writable: false і configurable: false для кожної власної властивості. Object.seal() встановлює лише configurable: false і позначає об'єкт нерозширюваним, але залишає writable: true. Саме цей один прапор визначає, чому запечатаний об'єкт приймає зміну значень, а заморожений - ні.

Q: Чи працює Object.assign() з об'єктами без прототипу (null-prototype)?
A: Так. Він копіює перераховувані власні властивості поверхнево незалежно від ланцюжка прототипів джерела. Прототип ніколи не читається і не змінюється.

Q: Як перевірити стан об'єкта - чи він заморожений, запечатаний або розширюваний?
A: Object.isFrozen(obj), Object.isSealed(obj) та Object.isExtensible(obj). Заморожений об'єкт вважається також запечатаним і нерозширюваним, але зворотне не вірно. isFrozen повертає true тільки коли всі власні властивості також non-writable і non-configurable.

Q: Який вплив заморожування на продуктивність?
A: Саме заморожування - одноразова операція з мінімальними витратами. Проблема виникає при виклику freeze або seal всередині гарячих циклів. Для примітивних значень вистачає const - freeze потрібен тільки для об'єктів.

Q: (Senior) Чому Object.assign() викликає геттери з джерела замість копіювання accessor-дескриптора?
A: За специфікацією Object.assign() використовує [[Get]] на джерелі і [[Set]] на цільовому об'єкті, а не [[GetOwnPropertyDescriptor]] і [[DefineOwnProperty]]. Геттер викликається, а його результат записується як звичайна data-властивість. Щоб скопіювати повний accessor-дескриптор, використовуй Object.getOwnPropertyDescriptors() разом із Object.create() або Object.defineProperties().

Q: (Senior) Чому у V8 мутація замороженої властивості кидає помилку в strict mode, але не в loose mode?
A: Операція Set перевіряє прапор writable у дескрипторі. У strict mode провалений запис іде по Throw-шляху і кидає TypeError. У sloppy mode semantics та сама перевірка провалюється, але Throw-шлях пропускається - рушій повертає без мутації і без помилки. Це поведінка, визначена специфікацією, а не особливість V8.

Приклади

Злиття конфігурації користувача з дефолтами

Типовий патерн в Express middleware, React-конфігурації та CLI-інструментах.

javascript
const defaults = { theme: 'light', apiKey: 'default-key', debug: false }; const userConfig = { theme: 'dark' }; // Часткове перевизначення const config = Object.assign({}, defaults, userConfig); // { theme: 'dark', apiKey: 'default-key', debug: false } // Заморожуємо, щоб жоден модуль не мутував конфіг const frozenConfig = Object.freeze(config); frozenConfig.theme = 'light'; // Не спрацює - конфіг захищений

Пізніші джерела перезаписують ранніші, тому userConfig.theme виграє над defaults.theme. Пустий {} першим аргументом гарантує, що defaults сам не мутується - Object.assign() мутує target, і тут target - це свіжий порожній об'єкт.

Пастка зі спільним вкладеним посиланням

Найпоширеніший реальний баг від використання assign для клонування.

javascript
const template = { name: 'untitled', meta: { version: 1, tags: [] } }; const doc1 = Object.assign({}, template); const doc2 = Object.assign({}, template); doc1.meta.version = 2; doc1.meta.tags.push('draft'); console.log(doc2.meta.version); // 2 - той самий об'єкт! console.log(doc2.meta.tags); // ['draft'] - той самий масив!

Обидва документи посилаються на один і той самий об'єкт meta, бо assign скопіював лише посилання. structuredClone(template) вирішує проблему. Для старіших середовищ JSON.parse(JSON.stringify(template)) працює для простих даних, але функції втрачаються, Date перетворюється на рядок, а undefined зникає.

Object.seal() для схеми форми

javascript
const formSchema = Object.seal({ username: '', email: '', role: 'viewer' }); formSchema.username = 'alice'; // Спрацює - значення можна змінювати formSchema.role = 'admin'; // Спрацює formSchema.password = '123'; // Не спрацює - форма (shape) зафіксована console.log(formSchema); // { username: 'alice', email: '', role: 'admin' } console.log(Object.isSealed(formSchema)); // true console.log(Object.isFrozen(formSchema)); // false

Object.seal() - правильний вибір, коли конфіг-об'єкт має відому структуру, яка не повинна змінюватися, але значення полів мають оновлюватися в рантаймі. Зустрічається рідше за freeze, але це саме той інструмент, коли freeze був би надто суворим.

Edge case з дескрипторами та assign

Це ловить досвідчених розробників, які вважають, що assign зберігає descriptor-прапори.

javascript
const source = Object.create(null, { id: { value: 42, writable: false, enumerable: true }, token: { get: () => 'secret', enumerable: true } }); const target = Object.assign({}, source); target.id = 100; // Спрацює - writable:false не скопіювався console.log(target.id); // 100 console.log(target.token); // 'secret' - геттер викликали, значення збережено const frozen = Object.freeze(target); frozen.id = 999; // Не спрацює - тепер freeze застосував writable:false console.log(frozen.id); // 100

Object.assign() викликав геттер token на джерелі і зберіг рядок як звичайну data-властивість на target. Якщо потрібно скопіювати сам геттер, використовуй Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)).

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

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

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

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