Appearance
对象不可变性:freeze、seal 与 preventExtensions
你是否思考过,为什么 JavaScript 提供了三种不同的方式来"锁定"对象?Object.freeze()、Object.seal() 和 Object.preventExtensions() 有什么区别?为什么 const 声明的对象仍然可以修改属性?
在前面的章节中,我们了解了属性描述符如何控制单个属性的行为。本章将探讨对象级别的不可变性机制,了解 V8 如何实现这些约束,以及它们对性能的影响。
JavaScript 中的"不可变"
首先要明确:JavaScript 中没有真正的深度不可变。所有不可变性 API 都只是浅层的。
javascript
const obj = {
x: 1,
nested: { y: 2 }
};
Object.freeze(obj);
obj.x = 100; // 无效(严格模式下抛出 TypeError)
console.log(obj.x); // 1
// 但嵌套对象仍可修改!
obj.nested.y = 200;
console.log(obj.nested.y); // 200三种不可变性级别
JavaScript 提供了三个 API,按限制强度递增:
1. Object.preventExtensions() —— 不可扩展
效果:不能添加新属性,但可以修改和删除现有属性。
javascript
const obj = { x: 1 };
Object.preventExtensions(obj);
// 无法添加新属性
obj.y = 2;
console.log(obj.y); // undefined(严格模式下抛出 TypeError)
// 可以修改现有属性
obj.x = 10;
console.log(obj.x); // 10
// 可以删除现有属性
delete obj.x;
console.log(obj.x); // undefined
// 检查对象是否可扩展
console.log(Object.isExtensible(obj)); // false2. Object.seal() —— 密封对象
效果:不能添加或删除属性,但可以修改现有属性的值。
实际上,Object.seal() 等价于:
- 调用
Object.preventExtensions() - 将所有属性的
configurable设置为false
javascript
const obj = { x: 1, y: 2 };
Object.seal(obj);
// 无法添加新属性
obj.z = 3;
console.log(obj.z); // undefined
// 无法删除现有属性
delete obj.x;
console.log(obj.x); // 1(删除失败)
// 可以修改现有属性的值
obj.x = 10;
console.log(obj.x); // 10
// 无法改变属性特性
Object.defineProperty(obj, 'x', {
enumerable: false // TypeError: Cannot redefine property
});
// 检查对象是否密封
console.log(Object.isSealed(obj)); // true
console.log(Object.isExtensible(obj)); // false(seal 隐含了 preventExtensions)3. Object.freeze() —— 冻结对象
效果:不能添加、删除或修改属性。对象完全只读。
实际上,Object.freeze() 等价于:
- 调用
Object.seal() - 将所有数据属性的
writable设置为false
javascript
const obj = { x: 1, y: 2 };
Object.freeze(obj);
// 无法添加新属性
obj.z = 3;
console.log(obj.z); // undefined
// 无法删除现有属性
delete obj.x;
console.log(obj.x); // 1
// 无法修改现有属性
obj.x = 10;
console.log(obj.x); // 1(修改失败)
// 检查对象是否冻结
console.log(Object.isFrozen(obj)); // true
console.log(Object.isSealed(obj)); // true
console.log(Object.isExtensible(obj)); // false三者的关系
Object.freeze()
↓ 包含
Object.seal()
↓ 包含
Object.preventExtensions()或者用集合表示:
freeze ⊂ seal ⊂ preventExtensionsV8 中的实现
V8 如何实现这些不可变性约束?
Map 标志位
V8 在对象的 Map(隐藏类)中使用标志位标记对象的可扩展性:
Map 对象结构(部分):
+---------------------------+
| Bit Flags |
+---------------------------+
包含:
- is_extensible(是否可扩展)
- is_prototype_map
- is_deprecated
等等当调用 Object.preventExtensions() 时:
- V8 创建一个新的 Map(或复用现有的不可扩展 Map)
- 将新 Map 的
is_extensible标志设置为false - 对象的 Map 指针更新为新 Map
后续尝试添加属性时,V8 检查 Map 的 is_extensible 标志,如果为 false,拒绝操作。
seal 和 freeze 的实现
Object.seal() 和 Object.freeze() 需要修改所有属性的特性:
javascript
// Object.seal() 的简化实现
function seal(obj) {
Object.preventExtensions(obj);
const props = Object.getOwnPropertyNames(obj);
for (const prop of props) {
Object.defineProperty(obj, prop, {
configurable: false
});
}
return obj;
}
// Object.freeze() 的简化实现
function freeze(obj) {
Object.seal(obj);
const props = Object.getOwnPropertyNames(obj);
for (const prop of props) {
const descriptor = Object.getOwnPropertyDescriptor(obj, prop);
if (descriptor.writable) { // 只修改数据属性
Object.defineProperty(obj, prop, {
writable: false
});
}
}
return obj;
}V8 的实际实现更高效,但原理类似:遍历所有属性,批量修改属性描述符。
性能影响
修改属性描述符会导致 Map 转换:
javascript
const obj1 = { x: 1, y: 2 };
const obj2 = { x: 3, y: 4 };
// obj1 和 obj2 共享 Map M0
Object.freeze(obj1);
// obj1 转换到新的 Map M1(所有属性 writable: false, configurable: false)
// obj2 仍然使用 Map M0
function getX(obj) {
return obj.x;
}
// 函数 getX 现在看到两种 Map,从单态变为多态
getX(obj1);
getX(obj2);深度冻结
由于 JavaScript 的 Object.freeze() 是浅层的,需要递归实现深度冻结:
javascript
function deepFreeze(obj) {
// 冻结对象本身
Object.freeze(obj);
// 递归冻结所有属性值
Object.getOwnPropertyNames(obj).forEach(prop => {
const value = obj[prop];
// 如果属性值是对象且未冻结,递归冻结
if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {
deepFreeze(value);
}
});
return obj;
}
const obj = {
x: 1,
nested: {
y: 2,
deepNested: {
z: 3
}
}
};
deepFreeze(obj);
obj.x = 100; // 无效
obj.nested.y = 200; // 无效
obj.nested.deepNested.z = 300; // 无效
console.log(obj); // { x: 1, nested: { y: 2, deepNested: { z: 3 } } }注意:深度冻结可能很昂贵,尤其是对大型对象树。而且,它不处理循环引用:
javascript
const obj1 = { x: 1 };
const obj2 = { y: 2 };
obj1.ref = obj2;
obj2.ref = obj1; // 循环引用
// 需要改进 deepFreeze 以处理循环引用
function deepFreezeSafe(obj, frozen = new WeakSet()) {
if (frozen.has(obj)) return obj; // 已经处理过
Object.freeze(obj);
frozen.add(obj);
Object.getOwnPropertyNames(obj).forEach(prop => {
const value = obj[prop];
if (value !== null && typeof value === 'object') {
deepFreezeSafe(value, frozen);
}
});
return obj;
}
deepFreezeSafe(obj1);性能测试
不可变对象的性能特征:
javascript
const mutableObj = { x: 1, y: 2 };
const sealedObj = Object.seal({ x: 1, y: 2 });
const frozenObj = Object.freeze({ x: 1, y: 2 });
// 测试读取性能
function testRead(obj) {
let sum = 0;
for (let i = 0; i < 1000000; i++) {
sum += obj.x + obj.y;
}
return sum;
}
console.time('Mutable Read');
testRead(mutableObj);
console.timeEnd('Mutable Read');
// 典型输出:Mutable Read: 5ms
console.time('Sealed Read');
testRead(sealedObj);
console.timeEnd('Sealed Read');
// 典型输出:Sealed Read: 5ms(几乎无差异)
console.time('Frozen Read');
testRead(frozenObj);
console.timeEnd('Frozen Read');
// 典型输出:Frozen Read: 5ms(几乎无差异)
// 读取性能没有明显差异,因为 V8 的内联缓存仍然有效javascript
// 测试写入性能(会失败的操作)
function testWrite(obj) {
for (let i = 0; i < 1000000; i++) {
obj.x = i; // frozen/sealed 对象上会静默失败
}
}
console.time('Mutable Write');
testWrite(mutableObj);
console.timeEnd('Mutable Write');
// 典型输出:Mutable Write: 10ms
console.time('Frozen Write');
testWrite(frozenObj);
console.timeEnd('Frozen Write');
// 典型输出:Frozen Write: 2ms(更快,因为操作被忽略)实用模式
1. 配置对象
javascript
const CONFIG = Object.freeze({
API_URL: 'https://api.example.com',
TIMEOUT: 5000,
MAX_RETRIES: 3,
// 嵌套对象需要手动冻结
FEATURES: Object.freeze({
NEW_UI: true,
DARK_MODE: false
})
});
// 防止意外修改配置
// CONFIG.API_URL = 'https://evil.com'; // 无效2. 枚举
javascript
const Status = Object.freeze({
PENDING: 'pending',
RUNNING: 'running',
SUCCESS: 'success',
FAILED: 'failed'
});
function processTask(status) {
switch (status) {
case Status.PENDING:
// ...
break;
case Status.RUNNING:
// ...
break;
// ...
}
}
// 无法修改枚举值
// Status.PENDING = 'waiting'; // 无效3. 不可变数据结构
javascript
class ImmutablePoint {
constructor(x, y) {
this.x = x;
this.y = y;
Object.freeze(this); // 在构造函数中冻结
}
// 返回新实例而不是修改现有实例
move(dx, dy) {
return new ImmutablePoint(this.x + dx, this.y + dy);
}
}
const p1 = new ImmutablePoint(0, 0);
const p2 = p1.move(10, 20);
console.log(p1); // ImmutablePoint { x: 0, y: 0 }
console.log(p2); // ImmutablePoint { x: 10, y: 20 }
// p1.x = 100; // 无效4. 函数参数保护
javascript
function processData(config) {
// 冻结参数,防止函数内部意外修改
config = Object.freeze({ ...config });
// 现在可以安全地传递 config 到其他函数
// 不用担心被修改
someOtherFunction(config);
}const vs freeze
很多开发者混淆 const 和 Object.freeze():
javascript
// const 只防止重新赋值
const obj1 = { x: 1 };
// obj1 = {}; // TypeError: Assignment to constant variable
obj1.x = 2; // OK,可以修改属性
obj1.y = 3; // OK,可以添加属性
console.log(obj1); // { x: 2, y: 3 }
// Object.freeze() 防止修改对象本身
let obj2 = { x: 1 };
Object.freeze(obj2);
obj2 = {}; // OK,可以重新赋值(let 声明)
console.log(obj2); // {}
obj2 = Object.freeze({ x: 1 });
obj2.x = 2; // 无效
obj2.y = 3; // 无效
console.log(obj2); // { x: 1 }
// 结合使用
const obj3 = Object.freeze({ x: 1 });
// obj3 = {}; // TypeError
// obj3.x = 2; // 无效
// 真正的不可变绑定陷阱与最佳实践
陷阱 1:浅层冻结
javascript
const obj = Object.freeze({
arr: [1, 2, 3],
nested: { x: 1 }
});
// obj 本身冻结了
obj.newProp = 'value'; // 无效
// 但嵌套对象和数组仍可修改
obj.arr.push(4);
console.log(obj.arr); // [1, 2, 3, 4]
obj.nested.x = 2;
console.log(obj.nested.x); // 2
// 解决方案:深度冻结
const deepFrozenObj = deepFreeze({
arr: [1, 2, 3],
nested: { x: 1 }
});陷阱 2:原型链
javascript
const proto = { x: 1 };
const obj = Object.create(proto);
obj.y = 2;
Object.freeze(obj);
// obj 的自有属性冻结
obj.y = 3; // 无效
// 但原型属性仍可修改!
proto.x = 10;
console.log(obj.x); // 10(通过原型链访问)
// 需要同时冻结原型
Object.freeze(proto);陷阱 3:性能误解
javascript
// 误解:freeze 会提高性能(因为对象不可变)
// 实际:freeze 可能降低性能(因为 Map 转换)
const arr1 = [];
for (let i = 0; i < 1000; i++) {
arr1.push({ x: i, y: i * 2 });
}
// 所有对象共享同一个 Map
const arr2 = [];
for (let i = 0; i < 1000; i++) {
arr2.push(Object.freeze({ x: i, y: i * 2 }));
}
// 每个 freeze 可能导致 Map 转换,破坏共享
function sumX(arr) {
let sum = 0;
for (const obj of arr) {
sum += obj.x;
}
return sum;
}
console.time('Mutable');
sumX(arr1);
console.timeEnd('Mutable');
// 典型输出:Mutable: 0.1ms
console.time('Frozen');
sumX(arr2);
console.timeEnd('Frozen');
// 典型输出:Frozen: 0.15ms(可能稍慢)最佳实践
何时使用 freeze/seal/preventExtensions:
- 配置对象:防止意外修改全局配置
- 枚举和常量:定义不应改变的值集合
- 不可变数据结构:实现函数式编程风格
- API 返回值:防止调用者修改内部状态
何时不使用:
- 性能关键路径:频繁创建和访问的对象
- 大型对象树:深度冻结成本高昂
- 临时对象:生命周期短的对象没必要冻结
本章小结
JavaScript 提供了三个 API 来限制对象的可变性,按强度递增:preventExtensions、seal、freeze。这些 API 都是浅层的,只影响对象本身,不影响嵌套对象。
核心概念:
- preventExtensions:不能添加新属性
- seal:不能添加或删除属性(= preventExtensions + configurable: false)
- freeze:不能添加、删除或修改属性(= seal + writable: false)
- V8 实现:通过 Map 的
is_extensible标志和属性描述符实现
关键区别:
const防止重新赋值,不防止修改对象freeze防止修改对象,不防止重新赋值- 结合使用:
const obj = Object.freeze({...})
性能影响:
- 读取性能无明显差异
- 修改属性特性会导致 Map 转换,可能破坏 Map 共享
- 深度冻结对大型对象树成本高昂
最佳实践:
- 用于配置对象、枚举、常量定义
- 需要深度不可变时实现
deepFreeze - 注意处理原型链和循环引用
- 避免在性能关键路径过度使用
在下一章中,我们将探讨 Map 和 Set 集合,了解 V8 如何实现这些基于哈希表的高效数据结构。
思考题:
- 为什么
Object.freeze()不能真正阻止对象被修改(考虑 Reflect 和 Proxy)? - 如果一个对象已经
freeze,后续访问属性是否还需要检查writable标志?V8 能否优化这种情况? - 实现一个性能更好的
deepFreeze,使用 WeakMap 缓存已冻结的对象。