Skip to content

Proxy 与 Reflect:拦截对象操作而不破坏语义 ​

描述符规定某个属性的行为,Proxy 则可以介入整个对象的操作:读取、赋值、删除、枚举、调用、构造。它常用于响应式追踪和边界适配。写代理时最重要的问题不是“能截住多少操作”,而是“转发以后还是不是原来的语义”。

从记录读取开始 ​

js
const base = { price: 10, get total() { return this.price * 2; } };
const watched = new Proxy(base, {
  get(target, key, receiver) {
    console.log('读取', String(key));
    return Reflect.get(target, key, receiver);
  }
});
const discount = Object.create(watched);
discount.price = 6;
console.log(discount.total); // 先记录“读取 total”,再输出 12

查找 total 来到代理,但 receiver 是最初读取属性的 discount。Reflect.get 保留这个 receiver,getter 内的 this.price 因而取到 6。改写成 target[key] 会让 getter 的 this 变成 base,错误地得到 20。

Reflect 不是绕过代理的魔法:如果传给它的目标本身就是代理,它仍会触发代理。trap 里把 receiver 当目标再读同一个属性,容易无限递归。

写入应保留失败语义 ​

js
const record = new Proxy({}, {
  set(target, key, value, receiver) {
    if (key === 'count' && !Number.isInteger(value)) {
      throw new TypeError('count 必须为整数');
    }
    return Reflect.set(target, key, value, receiver);
  }
});
record.count = 3;
console.log(record.count); // 3

set trap 的返回值会转成布尔值,表示写入是否成功;返回 false 时,严格模式赋值抛错,Reflect.set 本身则返回 false。不要写完后忘了返回值,也不要在失败时固定返回 true。Reflect.set 保留 receiver,有时会进一步触发 receiver 上的 defineProperty;同时监听两个 trap 时,看见两次日志不一定是发生了两次赋值。

操作主要 trap
读取、赋值、in、删除get、set、has、deleteProperty
自有键与属性描述符ownKeys、getOwnPropertyDescriptor、defineProperty
原型、可扩展性getPrototypeOf、setPrototypeOf、isExtensible、preventExtensions
调用与构造apply、construct

Object.keys(proxy) 不只需要 ownKeys,还需要判断字符串键是否可枚举,因此会请求描述符。仅让 ownKeys 返回一个虚构名称,不保证它出现在 Object.keys 中。

一次赋值为什么会经过两个 trap ​

js
const calls = [];
const proxy = new Proxy({}, {
  set(target, key, value, receiver) {
    calls.push('set');
    return Reflect.set(target, key, value, receiver);
  },
  defineProperty(target, key, descriptor) {
    calls.push('defineProperty');
    return Reflect.defineProperty(target, key, descriptor);
  }
});
proxy.n = 3;
console.log(calls.join(',')); // set,defineProperty
console.log(proxy.n); // 3

第一步拦截 proxy.n = 3。转发时 target 没有 n,普通赋值算法准备在 receiver 上创建属性。receiver 仍是 proxy,定义自有属性于是再次经过代理,这次命中 defineProperty。两次日志对应同一次赋值里的两个不同操作,不应把它们都记录为两次用户修改。

若把 Reflect.set 的 receiver 改为 target,第二个 trap 可能不再触发,但继承访问器和写入目标的语义也随之改变。不能只为了日志少一行就丢掉 receiver;应在观测层明确哪一种操作算一次业务事件。

不变量防止代理说出无法成立的话 ​

js
const fixed = Object.freeze({ version: 2 });
const misleading = new Proxy(fixed, { get() { return 99; } });
try { console.log(misleading.version); }
catch (error) { console.log(error.name); } // TypeError

不可配置且不可写的自有数据属性,其读取结果必须与目标值一致。其他重要限制包括:ownKeys 不能重复键,不能遗漏不可配置键;目标不可扩展时,键集合必须与目标一致;目标不可扩展时,报告的原型必须与真实原型一致。可扩展目标没有最后这项“必须一致”的限制,不能删掉这个前提来背口诀。

代理目标也决定基本能力。对象目标即使有 apply trap 也不会变得可调用;不可构造的函数目标即使有 construct trap 也不能被 new。违反规则通常得到 TypeError,不是代理可以任意重新定义语言。

隐藏键的方案为什么会在封闭对象后失效 ​

js
const target = { public: 1, secret: 2 };
const proxy = new Proxy(target, {
  ownKeys(object) {
    return Reflect.ownKeys(object).filter(key => key !== 'secret');
  }
});
console.log(Object.keys(proxy).join(',')); // public
Object.defineProperty(target, 'secret', { configurable: false });
try { Reflect.ownKeys(proxy); }
catch (error) { console.log(error.name); } // TypeError

一开始 secret 可配置,目标也可扩展,代理可以在这个操作中不报告它。随后目标承诺这个键不能删除,代理就不能对枚举谎称它不存在。若把整个目标设为不可扩展,规则会更严格:既不能漏键,也不能添加虚构键。结果仍可能按调用接口继续过滤,例如 Object.keys 还要排除 Symbol 和不可枚举键。

同理,has trap 也不能随意把不可配置自有属性报告为不存在。隐藏枚举并不等于阻止读取,更不等于保护秘密;即使第一段成功,proxy.secret 仍然可读。设计访问边界需要同时明确允许的操作,不能把“控制台看不到键”当安全保证。

空代理也不是完全透明 ​

proxy !== target,作为 Map 键是两个身份。类私有字段和 Map 等内建对象的内部槽还会检查接收者;代理没有自动获得目标的私有品牌或内部槽,所以 new Proxy(new Map(), {}).get('x') 会失败。

给方法绑定原目标可以修复某些内建调用,却会改变方法身份、绕开内部属性追踪,也可能让方法泄漏原目标。不存在一个几行代码就适配所有对象的万能透明代理。边界已知时,明确包装少量接口往往更容易维护。

Map 的方法与 getter 在不同时间检查接收者 ​

js
const map = new Map([['x', 1]]);
const wrapper = new Proxy(map, {
  get(target, key, receiver) {
    const value = Reflect.get(target, key, receiver);
    return typeof value === 'function' ? value.bind(target) : value;
  }
});
console.log(wrapper.get('x')); // 1
try { console.log(wrapper.size); }
catch (error) { console.log(error.name); } // TypeError

读取 get 得到一个函数,绑定目标以后再调用它,可以通过内部槽检查。size 却是 getter:Reflect.get 返回之前就已经以 proxy 为 this 执行它,尚未走到“如果是函数就绑定”的代码便失败了。这个区别解释了为什么一些看似修复 Map 的代理只在少数方法上成功。

如果接口明确只需要读取 size 和调用 get,可以直接写一个适配对象,把两个操作分别委托给 map。若确实要代理 Map,getter 接收者也要按预期改为 map,并继续处理方法身份、链式返回原目标和继承等问题。这是特定内建对象的适配规则,不能反过来破坏普通对象所需的 receiver 保留。

撤销只撤销这条访问路径 ​

js
const { proxy, revoke } = Proxy.revocable({ n: 4 }, {});
console.log(proxy.n); // 4
revoke();
try { console.log(proxy.n); }
catch (error) { console.log(error.name); } // TypeError

撤销后,这个代理上的相关操作会抛错,不能重新启用。但如果外部已经保存了原目标或之前读取出的对象引用,撤销不会把那些引用追回。因此它是能力寿命控制工具,并不是对既有引用施加远程删除。

js
const target = { detail: { page: 1 } };
const { proxy, revoke } = Proxy.revocable(target, {});
const retained = proxy.detail;
revoke();
console.log(retained.page); // 1

读取 detail 时返回了原来的嵌套对象引用,并没有自动创建可撤销的子代理。撤销根代理后,retained 不经过根代理便能使用。要让整组能力共同过期,需要设计每层访问路径或更窄的操作接口,而不是假定浅代理会递归保护整张对象图。

练习 ​

  1. 把第一例改成 return target[key],结果为什么改变?

  2. 目标已经 preventExtensions,ownKeys 能额外返回 'debug' 吗?

  3. 为什么把所有方法都 bind(target) 不能作为响应式代理的通用修补方案?以 Map 的 get 方法和 size getter 分别说明检查发生在哪一步。

  4. 实现 watch(target, keys):keys 是记录读取键的数组,get 必须保留 receiver,set 只允许 count 写入非负整数,其余键正常转发。用继承对象的 getter 验证接收者;非法 count 要抛 TypeError;冻结目标后,Reflect.set 写合法整数仍应返回 false。

查看答案

用心记录每一步成长