JavaScript Proxy 不变式:拦截读取,也不能改写已经锁定的属性事实

10-01 3阅读

给对象套上 Proxy,经常是为了记录访问、补默认值,或提供计算字段。一个常见误解是,只要写了 get 拦截器,读取结果就完全由自己决定。对象如果已经明确锁定某些属性,运行时仍会检查返回值是否与这些事实一致,检查失败会直接抛出异常。

这种约束叫不变式。本文只讨论属性读取:目标自身的数据属性同时不可写、不可配置时,拦截器必须返回相同的值。它与一般的只读需求不同,两个条件缺一不可。不要看到 writable 为 false,就认定任何代理转换一定失败。

分别构造三个目标

下面保存为 demo.mjs,并用 node demo.mjs 执行。locked 把 version 固定为七;flexible 虽然禁止直接赋值,却允许重新配置;silent 则拥有不可配置、没有 getter 的访问器。三组断言把不同描述符的边界分别固定下来。

JavaScript Proxy 不变式:拦截读取,也不能改写已经锁定的属性事实

AI概念示意图:以抽象物件说明本文主题,不代表真实界面或运行结果。

import assert from 'node:assert/strict';
const locked = {};
Object.defineProperty(locked, 'version', {
  value: 7, writable: false, configurable: false
});
const bad = new Proxy(locked, {get() {return 8;}});
assert.throws(() => bad.version, TypeError);

const flexible = {};
Object.defineProperty(flexible, 'version', {
  value: 7, writable: false, configurable: true
});
const changedView = new Proxy(flexible, {get() {return 8;}});
assert.equal(changedView.version, 8);
assert.equal(flexible.version, 7);

const silent = {};
Object.defineProperty(silent, 'hidden', {
  get: undefined, set(value) {}, configurable: false
});
const badSilent = new Proxy(silent, {get() {return 8;}});
assert.throws(() => badSilent.hidden, TypeError);
assert.equal(new Proxy(silent, {}).hidden, undefined);

const good = new Proxy(locked, {
  get(target, key, receiver) {
    if (key === 'label') return 'version seven';
    return Reflect.get(target, key, receiver);
  }
});
assert.equal(good.version, 7);
assert.equal(good.label, 'version seven');
console.log('checked: locked, configurable, accessor, forwarding');

错误发生在读取的那一刻

创建 bad 代理本身成功,执行 bad.version 才触发检查。因此把代理构造过程包进异常处理,并不能保证后面的读取安全。第一条断言要求抛出 TypeError,却不依赖具体错误文字,因为不同引擎可能采用不同的提示格式。

flexible 的读取结果是八,目标里的实际值仍为七。这个对照说明代理返回值和底层存储可以不同,但允许不同的前提受到描述符约束。把 configurable 改为 false 后,这种替换才会遇到前一组的限制;只观察值而不看描述符,很容易漏掉原因。

silent 是另一条边界:没有 getter 的不可配置访问器,读取必须报告 undefined。此时返回文字、数字或 null 都不能当作友好默认值。例子只返回空代理,便会自动转交正常读取,保持这个约定;badSilent 则因虚构数值而失败。

把需要增强的字段明确挑出来

good 只为不存在的 label 字段给出计算文字,其余读取交给 Reflect.get,并把 receiver 一起转发。运行结束只输出一行 checked,表示所有断言通过。该写法适合本文的普通对象,并不是把所有内置对象和私有字段都变成透明代理的万能适配器。

真实项目若允许目标在运行中变化,还要给虚拟字段划定命名范围。今天不存在的 label,明天可能被定义为锁定属性,固定返回文字的代码随即违反约束。可选择单独的包装对象承载展示字段,或先检查当前描述符,再决定能否计算替代结果。

也不要在 get 拦截器里为了省事再次读取 proxy[key]。这会重新进入同一个拦截器,形成递归,直到调用栈耗尽。代码中的 target 参数是底层对象,receiver 则保留实际接收者;把这两个角色区分开,才能既增加日志,又避免自己重复触发自己。

验收时同时测试普通与锁定对象

测试不应只覆盖自由增删的对象。把同一读取交给可配置只读属性、完全锁定的数据属性、缺失 getter 的访问器,再确认需要保留的 Symbol 键也能正确转发。这样能区分业务映射错误与引擎主动阻止的不一致,而不是把所有异常都归因于框架。

最后要注意,相同值的判定遵循规范的 SameValue 语义,不是简单把两边转成文字比较。对象值要求同一个引用,返回内容相等的新对象也不算相同。最省心的原则是对受约束字段保持原始读取,让额外展示信息拥有明确、独立的位置。

参考资料

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。