Skip to content

JavaScript 中"常量"的三种语义

JS 中说到"常量",实际上在讨论三个不同层面的东西:

  1. 绑定不可变const 保证变量名到内存地址的绑定不变,不保证地址里的内容不变
  2. 结构锁定Object.freeze/seal/preventExtensions 限制对象属性的增删改
  3. 值不可变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,若已离开则对 constTypeError

const 对 GC 没有任何特殊影响。const obj = {}let obj = {} 创建的堆对象有完全相同的 GC 行为——对象在不可达时被回收,与声明关键字无关。


Object.freeze:V8 的优化路径

Object.freeze(obj) 做三件事:

  1. 将所有已有属性的 writable 设为 false
  2. 将所有已有属性的 configurable 设为 false
  3. 将对象的 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
(禁止添加)    → +禁止删除 → +禁止修改
操作preventExtensionssealfreeze
添加属性
删除属性
修改已有属性值
修改属性描述符
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' 让修改立即抛错而非静默失败。