JavaScript Object.freeze:外层冻结了,里面的数组为什么仍然能增加元素
一份共享配置已经调用 Object.freeze,某个模块却仍然把服务器地址加进了数组。问题通常不在冻结失效,而在冻结只作用于当前对象自己的属性:外层保存的数组引用不能替换,数组对象本身却还没有被冻结,内部元素仍然可以变化。
可以把配置想成一个固定的目录页。冻结目录页,会阻止改写页上的指向;被指向的内容是否能够编辑,要看那份内容自己的状态。这个区别对多模块共享配置尤其重要,因为使用者可能拿到同一个子对象,任何一处修改都会被其他地方看到。
AI概念配图,非真实界面:以抽象物件说明本文主题,不代表运行结果。
先复现允许发生的修改
下面代码保存为 demo.js,使用 Node.js 18 或更高版本运行。开头开启严格模式,使非法属性写入明确抛出 TypeError。第一段冻结外层后,有意修改重试次数并向地址数组添加一项,证明“外层已冻结”并不能代表整份配置已经固定。
随后按已知结构分别冻结 retry 和 servers,再验证同样的修改会失败。这里选择列出具体路径,因为示例配置结构很小,责任范围清楚。代码没有声称提供一个能处理任意对象、任意引用图的通用深度冻结工具。
"use strict";
const assert = require("node:assert/strict");
const config = { retry: { limit: 1 }, servers: ["east"] };
assert.equal(Object.freeze(config), config);
assert.equal(Object.isFrozen(config), true);
assert.throws(() => { config.retry = { limit: 9 }; }, TypeError);
config.retry.limit = 2;
config.servers.push("west");
assert.deepEqual(config.servers, ["east", "west"]);
assert.equal(Object.isFrozen(config.retry), false);
console.log(`outer frozen: ${Object.isFrozen(config)}; nested changed: ${config.retry.limit}`);
Object.freeze(config.retry);
Object.freeze(config.servers);
assert.throws(() => { config.retry.limit = 3; }, TypeError);
assert.throws(() => config.servers.push("north"), TypeError);
assert.throws(() => { config.servers[0] = "south"; }, TypeError);
assert.equal(config.retry.limit, 2);
assert.deepEqual(config.servers, ["east", "west"]);
const lookup = Object.freeze(new Map());
lookup.set("active", true);
assert.equal(Object.isFrozen(lookup), true);
assert.equal(lookup.get("active"), true);
console.log("plain object graph and Map boundary checks passed");检查冻结状态,也要检查行为
程序先打印外层为冻结状态、内部次数却已经变成二,最后输出边界检查通过。Object.isFrozen 能回答某一个对象当前是否被冻结,不会递归检查子对象。对配置的验收应覆盖真实修改路径,例如重写字段、替换子对象、数组追加与数组元素赋值。
冻结会原地影响传入对象,并返回同一个对象,而不会替你制作副本。若该对象还被别的模块当作可编辑草稿,冻结会改变它们的操作结果。因此应在配置构造完毕、进入共享阶段时统一处理,避免某个读取者临时冻结后给其他写入者留下意外。
严格模式下的赋值错误比较容易观察;在非严格模式中,部分非法赋值可能只是没有生效。不能仅靠没有异常就判断修改成功,也不能只靠是否抛错判断数据安全。示例同时检查最终值与冻结状态,把执行现象和数据结果对应起来。
复杂对象需要不同的只读设计
末尾的 Map 例子更进一步:冻结 Map 对象后,set 仍然可以改变映射条目。原因是映射数据并不等同于普通可写属性。把它放进“递归遍历属性后 freeze”的函数,也不会自动获得业务上只读的集合,需要单独约定对外可用的操作。
如果配置确实只由普通数据对象和数组构成,递归冻结可以成为一种实现选择,但要处理循环引用和重复引用,并明确哪些对象属于自己的数据。对任意传入对象盲目递归,既可能无法终止,也可能冻结别处仍需要修改的共享资源。
访问器属性也提醒我们,冻结对象不等于所有读取都永远返回同一个值。读取属性可能调用 getter,而 getter 可以依赖外部变化。想要稳定配置快照,应先明确数据模型,避免把带运行时行为的对象与普通静态字段混在一起验收。
真正接入项目时,可以在构建配置的唯一入口完成校验,再冻结已知的普通对象与数组,并保留本文这种变更尝试测试。若需求只是禁止调用方改动共享状态,也可以改成只暴露查询函数或返回独立结果;关键是让接口承诺与实际可变边界一致。


