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.
Швидкий приклад
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 глибокий
const settings = Object.freeze({ db: { host: 'localhost' } });
settings.db.host = 'prod-server'; // Спрацює - мутація пройшла
console.log(settings.db.host); // 'prod-server'Посилання на db не можна замінити, але сам об'єкт, на який воно вказує, - звичайний мутабельний. Freeze заблокував лише обгортку. Рішення: рекурсивний deep freeze.
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 для глибокого клонування
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 копіює дескриптори властивостей
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); // 100Object.assign() читає значення і записує його як звичайну data-властивість. Прапор writable: false із дескриптора джерела зникає. Для копіювання повних дескрипторів використовуй Object.getOwnPropertyDescriptors() разом із Object.defineProperties().
4. Пропуск неперераховуваних властивостей
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
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-інструментах.
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 для клонування.
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() для схеми форми
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)); // falseObject.seal() - правильний вибір, коли конфіг-об'єкт має відому структуру, яка не повинна змінюватися, але значення полів мають оновлюватися в рантаймі. Зустрічається рідше за freeze, але це саме той інструмент, коли freeze був би надто суворим.
Edge case з дескрипторами та assign
Це ловить досвідчених розробників, які вважають, що assign зберігає descriptor-прапори.
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); // 100Object.assign() викликав геттер token на джерелі і зберіг рядок як звичайну data-властивість на target. Якщо потрібно скопіювати сам геттер, використовуй Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)).
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.