Appearance
JavaScript 中"常量"的三种语义
JS 中说到"常量",实际上在讨论三个不同层面的东西:
- 绑定不可变:
const保证变量名到内存地址的绑定不变,不保证地址里的内容不变 - 结构锁定:
Object.freeze/seal/preventExtensions限制对象属性的增删改 - 值不可变:
Object.freeze浅层冻结 + 手动递归 → deepFreeze
这三层经常被混为一谈,但在生产代码中的影响完全不同。
const:只锁绑定,不锁值
js
const obj = { count: 0 };
obj.count = 1; // ✅ 可以——修改的是堆里的数据,不是栈里的绑定
obj = {}; // ❌ TypeError——试图替换绑定
const arr = [1, 2, 3];
arr.push(4); // ✅ 可以——没有改变 arr 指向的数组引用
arr[0] = 0; // ✅ 可以const 在 V8 中的实现:const 变量在 Context slot 中存储。声明时 slot 被标记为 TDZ(kTheHole 哨兵值),初始化后哨兵被替换为实际值。与 let 的唯一区别是,const 的 slot 在 Ignition 解释器层面不允许重新赋值——StaCurrentContextSlot 指令会检查 slot 是否已经离开 TDZ,若已离开则对 const 抛 TypeError。
const 对 GC 没有任何特殊影响。const obj = {} 和 let obj = {} 创建的堆对象有完全相同的 GC 行为——对象在不可达时被回收,与声明关键字无关。
Object.freeze:V8 的优化路径
Object.freeze(obj) 做三件事:
- 将所有已有属性的
writable设为false - 将所有已有属性的
configurable设为false - 将对象的
extensible设为false
在 V8 中,冻结对象的 hidden class(Map)会被标记为 DONT_DELETE + READ_ONLY 的 attributes。这个标记让 V8 的 IC(Inline Cache)可以走快速路径——属性访问不需要检查是否有 getter/setter 拦截,因为冻结对象的所有属性都是不可配置的普通数据属性。
js
const obj = Object.freeze({ a: 1, b: 2 });
// 冻结后:
obj.a = 100; // ❌ 非严格模式:静默失败(无效果,不报错)
// ❌ 严格模式:TypeError
obj.c = 3; // ❌ 同上
delete obj.a; // ❌ 同上关键点:冻结后的静默失败是 bug 的温床。 非严格模式下,对冻结对象的写入不报错,只是无效果。如果你在生产代码中依赖写入成功(比如缓存更新、状态变更),它静默失败了——没有异常,日志里看不到,只能靠业务行为异常推测。
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);
}这个实现有三个问题:
1. 循环引用 → 栈溢出。 如果对象有循环引用(A → B → A),递归不会终止。修复方案是用 WeakSet 追踪已访问对象:
js
function 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);
}2. typeof null === 'object'。 原始版本的 guard typeof obj[prop] === 'object' && obj[prop] !== null 是正确的,但容易遗漏。很多简化版 deepFreeze 只写 typeof x === 'object',遇到 null 就报 Cannot read property。
3. 性能成本。 对一个有 10 万属性的深层嵌套对象做 deepFreeze,需要遍历所有自有属性并递归调用 Object.freeze,CPU 成本可观。更常见的做法是只在开发环境执行 freeze,生产环境跳过:
js
const state = process.env.NODE_ENV === 'development'
? deepFreeze(nextState)
: nextState;开发时 freeze 帮助发现意外的 state mutation,生产时跳过 freeze 避免不必要的 CPU 开销。
freeze vs seal vs preventExtensions:选择矩阵
三个 API 构成递进的限制层级:
text
preventExtensions → seal → freeze
(禁止添加) → +禁止删除 → +禁止修改| 操作 | preventExtensions | seal | freeze |
|---|---|---|---|
| 添加属性 | ❌ | ❌ | ❌ |
| 删除属性 | ✅ | ❌ | ❌ |
| 修改已有属性值 | ✅ | ✅ | ❌ |
| 修改属性描述符 | ✅ | ❌ | ❌ |
| configurable | 不变 | 全部→false | 全部→false |
| writable | 不变 | 不变 | 全部→false |
对应的检查方法:
js
Object.isExtensible(obj); // preventExtensions → false
Object.isSealed(obj); // seal → true(seal 包含 preventExtensions)
Object.isFrozen(obj); // freeze → true(freeze 包含 seal)isFrozen 返回 true 意味着 isSealed 也一定是 true,而 isSealed 返回 true 意味着 isExtensible 一定是 false——这三个条件有严格的包含关系。
const + freeze 的正确组合
如果你想要真正的"常量"——值不被改变:
js
// ❌ 只 const:引用不变,但值可变
const CONFIG = { timeout: 3000 };
CONFIG.timeout = 0; // 可以修改
// ✅ const + freeze:引用不变 + 第一层值不变
const CONFIG = Object.freeze({ timeout: 3000 });
CONFIG.timeout = 0; // 静默失败 / 严格模式报错
CONFIG.newProp = 1; // 同上
// ✅ 嵌套对象的安全方案
const CONFIG = Object.freeze({
timeout: 3000,
retry: Object.freeze({ times: 3, delay: 1000 })
});如果需要深层的运行时不可变性,也可以考虑 Immutable.js 或 Immer,而不是手写 deepFreeze。它们通过结构共享(structural sharing)避免整个对象树的复制开销,freeze 语义也已经内置在各自模型中。
生产中的原型冻结
一个较少提及但重要的用法:
js
Object.freeze(Object.prototype);
Object.freeze(Array.prototype);冻结内置原型可以防止原型污染攻击——恶意代码无法通过 Object.prototype.polluted = 'evil' 影响所有对象。代价是某些库(如 polyfill)可能依赖修改内置原型。通常在关键的 Node.js 服务或经过充分测试的 SPA 中启用,配合 'use strict' 让修改立即抛错而非静默失败。
