Skip to content
JavaScript 中常量的三种语义
概述
JavaScript 中“常量”这一说法,实际上对应着三种不同层面的不变性:
- 绑定不可变:
const保证变量名到内存地址的绑定关系不可修改,但不限制堆中数据的改变。 - 结构锁定:
Object.freeze、Object.seal、Object.preventExtensions控制对象自身属性描述符和可扩展性。 - 值不可变:通过递归冻结(
deepFreeze)或不可变数据结构库,使对象在任意深度上不可变更。
清晰地分辨这三种语义,有助于理解 const 与 Object.freeze 的区别,以及在不同场景下如何选择合适的不变性策略。
const:绑定不可变
const 声明只禁止重新绑定变量名与内存地址的关联,堆中的对象内容仍然允许修改。
js
const obj = { count: 0 };
obj.count = 1; // ✅ 允许——修改的是堆中的属性值,栈中的引用不变
obj = {}; // ❌ TypeError——尝试替换整个绑定
const arr = [1, 2, 3];
arr.push(4); // ✅ 允许——没有改变 arr 指向的数组引用
arr[0] = 0; // ✅ 允许在 V8 引擎中,const 变量存放在上下文槽(Context slot)里。声明时该槽被标记为“暂时性死区”(TDZ),内部使用 kTheHole 哨兵值填充;初始化后哨兵替换为实际值。与 let 的唯一区别在于,Ignition 解释器在遇到 StaCurrentContextSlot 指令时会检查槽位是否已经离开 TDZ:若已离开且变量由 const 声明,则直接抛出 TypeError。垃圾回收方面,const obj = {} 和 let obj = {} 创建的堆对象行为完全一致——当对象不再可达时即被回收,与声明关键字无关联。
Object.freeze 的工作原理
Object.freeze(obj) 执行三个操作:
- 将所有自有属性的
writable描述符置为false。 - 将所有自有属性的
configurable描述符置为false。 - 将对象的
[[Extensible]]内部属性置为false。
在 V8 中,冻结操作会将对象的隐藏类(Map)标记上 DONT_DELETE 与 READ_ONLY 等属性位。这些标记使得内联缓存(Inline Cache)可以进入快速路径——属性访问无需检查是否存在 getter/setter 拦截,因为冻结对象的所有属性都是不可配置的普通数据属性。
js
const obj = Object.freeze({ a: 1, b: 2 });
// 冻结后:
obj.a = 100; // 非严格模式:静默失败,值不会改变,不报错
// 严格模式:TypeError
obj.c = 3; // 同上
delete obj.a; // 同上注意点
非严格模式下的静默失败容易让错误难以暴露。如果在代码中依赖对冻结对象的写入操作(例如缓存更新、状态变更),写入不会生效,也不抛出异常,仅能通过后续业务行为推断哪里出了问题。建议在开发或测试阶段开启严格模式,以便立即捕获此类错误。
递归冻结 (deepFreeze)
递归冻结对象的最简实现如下:
js
function deepFreeze(obj) {
Object.getOwnPropertyNames(obj).forEach(prop => {
if (typeof obj[prop] === 'object' && obj[prop] !== null) {
deepFreeze(obj[prop]);
}
});
return Object.freeze(obj);
}这一实现需要留意三个方面:
循环引用导致栈溢出
如果对象图中存在循环引用(A → B → A),递归不会终止,最终触发调用栈溢出。修复方法是使用WeakSet记录已访问的对象:jsfunction deepFreeze(obj, seen = new WeakSet()) { if (seen.has(obj)) return obj; seen.add(obj); Object.getOwnPropertyNames(obj).forEach(prop => { const val = obj[prop]; if (val !== null && (typeof val === 'object' || typeof val === 'function')) { deepFreeze(val, seen); } }); return Object.freeze(obj); }null的类型判断
简写实现typeof obj[prop] === 'object'会错误地将null当作对象,导致Cannot read property错误。原始版本中的 guard 语句typeof obj[prop] === 'object' && obj[prop] !== null已正确处理,但容易在精简代码时被遗漏。性能开销
对拥有大量属性或深层嵌套的对象执行deepFreeze,需要遍历所有自有属性并递归调用Object.freeze,CPU 成本较高。常见的处理方式是仅在开发阶段启用深度冻结,在部署时跳过:jsconst state = process.env.NODE_ENV === 'development' ? deepFreeze(nextState) : nextState;开发阶段冻结有助于发现意外的状态修改,部署时跳过则可避免额外的性能开销。
API:Object.seal 与 Object.preventExtensions
除了 Object.freeze,JavaScript 还提供了 Object.seal 和 Object.preventExtensions,它们对对象结构施加递进的限制:
| 操作 | preventExtensions | seal | freeze |
|---|---|---|---|
| 添加属性 | ❌ | ❌ | ❌ |
| 删除属性 | ✅ | ❌ | ❌ |
| 修改已有属性值 | ✅ | ✅ | ❌ |
| 修改属性描述符 | ✅ | ❌ | ❌ |
| configurable 变化 | 保持不变 | 全部→false | 全部→false |
| writable 变化 | 保持不变 | 保持不变 | 全部→false |
对应的检测方法具有严格的包含关系:
js
Object.isExtensible(obj); // preventExtensions 后为 false
Object.isSealed(obj); // seal 后为 true(隐含 preventExtensions)
Object.isFrozen(obj); // freeze 后为 true(隐含 seal 与 preventExtensions)也就是说,isFrozen 为 true 时 isSealed 必然为 true,而 isSealed 为 true 时 isExtensible 必然为 false。
const 与 Object.freeze 组合使用
若要使一个变量既不可重新赋值,其值的第一层也不可修改,可以同时使用 const 与 Object.freeze。
js
// ❌ 仅用 const:引用不变,内容可变
const CONFIG = { timeout: 3000 };
CONFIG.timeout = 0; // 可以修改
// ✅ const + freeze:引用不变,第一层属性不可修改
const CONFIG2 = Object.freeze({ timeout: 3000 });
CONFIG2.timeout = 0; // 非严格模式静默失败,严格模式报错
CONFIG2.newProp = 1; // 同上
// ✅ 包含嵌套对象时,需逐层冻结
const CONFIG3 = Object.freeze({
timeout: 3000,
retry: Object.freeze({ times: 3, delay: 1000 }),
});对于深层不可变的需求,也可考虑使用 Immutable.js 或 Immer 等库,它们通过结构共享(structural sharing)避免整棵对象树的复制,并内置了不可变语义。
冻结内置原型
冻结内置对象的原型(如 Object.prototype、Array.prototype)可以有效抵御原型污染攻击——恶意代码无法通过 Object.prototype.polluted = 'evil' 影响所有普通对象。
js
Object.freeze(Object.prototype);
Object.freeze(Array.prototype);这一做法的代价是:某些依赖修改内置原型的库(例如部分 polyfill)将无法正常工作。因此通常在可信赖依赖且经过充分测试的项目中启用,并配合 'use strict' 使任何修改尝试立即抛出错误,而非静默失败。
