原创

Object.freeze 以后 Map 仍能 set:只读接口需要保护哪些修改入口

Object.isFrozen(map)已经为true,调用map.set却仍然成功。冻结没有失效,只是Map条目不属于Object.freeze所冻结的那组普通自有属性。

这类错误常出现在“把配置冻住以后交给插件”的代码里。普通对象属性不可重写,不等于内部集合不可变化;嵌套对象也不会自动递归冻结。需要只读合同,必须先确定哪些修改入口仍然存在。

下面先观察冻结Map的边界,再为一份固定的平面配置实现只暴露查询的接口。它没有提供通用深冻结器,也不把一份普通JavaScript对象包装成恶意代码沙箱。

冻结状态与条目变化可以同时成立

把程序保存为readonly_lookup.js,用现代浏览器控制台或Node.js运行。严格模式使不允许的普通属性赋值明确抛出异常。

'use strict';
const assert = (condition, message) => {
  if (!condition) throw new Error(message);
};
const map = new Map([['a', 1]]);
Object.freeze(map);
map.set('b', 2);
assert(Object.isFrozen(map) && map.get('b') === 2, 'Map entries remain mutable');
console.log('frozen Map size:', map.size);
try {
  map.extra = 3;
  throw new Error('ordinary property should be rejected');
} catch (error) {
  if (!(error instanceof TypeError)) throw error;
}
map.delete('a');
console.log('after delete:', [...map.entries()]);

const shallow = Object.freeze({ nested: { enabled: true } });
shallow.nested.enabled = false;
assert(shallow.nested.enabled === false, 'freeze is shallow');
console.log('nested changed:', shallow.nested.enabled);
let backing = 0;
const accessor = Object.freeze({
  get level() { return backing; },
  set level(value) { backing = value; }
});
accessor.level = 3;
assert(accessor.level === 3, 'existing setter still executes');
console.log('frozen accessor backing:', backing);

function makeLookup(entries) {
  const owned = new Map();
  for (const [key, value] of entries) {
    if (typeof key !== 'string' || owned.has(key)) throw new TypeError('unique string key required');
    if (!value || typeof value.label !== 'string' || typeof value.enabled !== 'boolean') {
      throw new TypeError('flat label/enabled record required');
    }
    owned.set(key, Object.freeze({ label: value.label, enabled: value.enabled }));
  }
  return Object.freeze({
    has: key => owned.has(key),
    get: key => owned.get(key),
    entries: () => [...owned.entries()]
  });
}
const original = { label: '编辑器', enabled: true };
const lookup = makeLookup([['editor', original]]);
original.enabled = false;
assert(lookup.get('editor').enabled === true, 'input object must not remain shared');
assert(lookup.has('editor') && lookup.get('missing') === undefined, 'lookup contract');
try {
  lookup.get('editor').enabled = false;
  throw new Error('returned record should be frozen');
} catch (error) {
  if (!(error instanceof TypeError)) throw error;
}
const snapshot = lookup.entries();
snapshot[0][0] = 'changed outside';
snapshot.push(['new', { label: 'external', enabled: false }]);
assert(lookup.has('editor') && !lookup.has('changed outside') && !lookup.has('new'), 'snapshot detached');
console.log('lookup still:', lookup.get('editor'));
console.log('query methods:', Object.keys(lookup).join(','));

Map的size先从1变2,delete以后仍能改变条目;新增extra却被拒绝。这组对照将普通属性与Map内部数据分开,不能只用isFrozen作为“对象完全不可变”的验收。

嵌套enabled也成功改变,因为冻结只作用于传入对象本身。把一份大配置树顶层冻结,只能保护顶层属性关系,不能承诺每个后代值都稳定。

查询接口不暴露写能力,内部还要拥有数据

makeLookup把Map留在闭包内,只返回has、get和entries。调用方没有拿到set、delete或clear入口,也不能从返回的属性中直接取得内部Map。

仅仅不暴露Map还不够。若内部仍保存原来的value,外部通过original修改enabled,查询结果也会变化。因此程序为当前两字段模型构造独立记录,再冻结记录,主动切断输入可变对象引用。

这里的字段模型很小,label是字符串、enabled是布尔值,不含可变后代。将来新增数组或对象字段时,需要重新决定复制与只读策略,不能只在接口上加一个属性名,就沿用这份平面模型的不可变结论。

某些业务反而需要实时查询共享Map,此时可以保留共享所有者,但应明确它是一份只读访问入口、底层内容会更新。只读权限与时间上不变是两种合同,名称最好让调用方能区分。

返回快照,也要知道复制到了哪一层

entries每次生成新外层数组和新键值对数组,因此修改返回的键或追加条目不会改内部索引。值记录仍然共享,但它们是当前模型中已经冻结的独立平面对象。

如果返回原生Map迭代器,调用者观察的是迭代发生期间的内部状态,不自动是一份固定快照。本文选择数组,适合小配置表,也承担复制开销;大数据集合可能需要分页或明确迭代合同,不能无条件一次展开全部内容。

get返回undefined代表不存在。不要因为允许的某个配置值也可能为undefined,就把存在性判断混成一次truthy检查。当前模型强制值为记录对象,has与get的范围容易解释;扩展类型时仍要保留清楚的缺失语义。

访问器还可能修改对象之外的状态

程序补了一项已经冻结的getter/setter属性。level的属性描述不能再任意重定义,现有setter却仍可被调用,并修改闭包里的backing;读取level于是得到新值。冻结普通属性关系,也没有禁止已有函数运行。

所以对外对象带着修改方法或setter时,不能只执行freeze就称它只读。应把会改状态的能力从交付接口中排除,或者明确这些方法仍然允许哪些变化。本例查询门面只有捕获私有数据的读取函数,没有把更新动作一起藏在一个名字看起来像查询的函数中。

输入验证读取label与enabled时,同样可能进入原值访问器。需要接收任意模型对象的系统,应先确定信任和转换边界;这份小工具仅接收预期的数据记录,不承担对未知对象执行过程的隔离。复制完成后,内部只保存新建平面记录,后续查询才有清楚的值合同。

递归冻结也不是所有对象的通用答案

对普通对象树,可以按自己的数据模型递归复制与冻结,但循环引用、访问器、原型和内部状态都会影响行为。遍历属性时还可能执行getter,操作本身不是毫无副作用的标记过程。

Map、Set、Date等对象有各自内部数据与修改方法,给它们执行Object.freeze不能统一阻止所有领域动作。应选择明确可序列化的值模型、专门查询门面或满足需求的持久数据结构,而不是在一个通用deepFreeze函数后面许下过宽保证。

类型系统中的只读声明也主要约束相应代码使用方式,不能让运行时出现的写入口自动消失。第三方JavaScript、类型断言或其他引用都可能绕开静态视图,因此跨边界交付仍需理解实际对象能力。

本例还假定可信运行环境,没有试图防御调用方修改全局内置原型或执行任意代码。只读配置接口解决常规误用与所有权,不等于提供隔离执行环境。需要插件沙箱时,应选择真正的权限与进程隔离方案。

最后从调用方尝试三种改动:修改原输入、修改get结果、修改entries快照。它们分别验证输入归属、值层保护和集合结构隔离,比一句isFrozen返回true更接近这份接口真正承诺的行为。

正文到此结束
评论插件初始化中...
Loading...