Proxy 与 Reflect

Proxy 拦截对象的基本操作,Reflect 提供同语义的默认转发;理解 trap、receiver 与不变量,才能写出透明且可组合的代理。

难度 高级 时长 标准深度约 14分钟
版本 Node 24
what

Proxy 用 handler 中的 trap 拦截对象的基本操作;Reflect 提供名称和参数相匹配的方法,用来执行默认语义。

trap

trap 不能随意“撒谎”:它必须满足目标对象的不可配置属性、可扩展性与原型等不变量,否则引擎会抛出 TypeError

fix

默认转发使用 Reflect 并保留 receiver,再为所有能绕过策略的操作补齐 trap;用冻结目标、Symbol 键和带品牌检查的内置对象测试边界。

是什么,为什么存在

Proxy 创建一个包装对象,让你能够观察或改写目标对象的基本操作。属性读取、赋值、indelete、键枚举、函数调用和构造等操作,都有对应的 代理陷阱(proxy trap) 。调用方仍使用普通 JavaScript 语法,不需要改成 get("name") 一类专用接口。

Reflect 是一组静态方法,不是构造函数。它把 Reflect.get()Reflect.set()Reflect.ownKeys() 等基本操作暴露为函数,并且 13 个可拦截操作都与同名 trap 对应。handler 只改写少量行为时,调用 Reflect 可以清楚地表达“其余部分保持语言默认语义”。

这对 API 适合观察访问、校验写入、提供虚拟属性、构建可撤销视图,以及实现响应式系统或对象膜(membrane)的底层机制。它解决的是“保持普通对象语法,同时介入语言操作”这个问题,不是普通业务对象都应该使用的默认抽象。

代理不是目标的副本。对代理的成功写入通常会改变目标,目标的其他别名也能绕开 handler;代理与目标的对象标识又不相等。因此,隐藏下划线属性或拒绝某次读取只能形成接口约束,不能把仍然可达的目标变成安全保险库。

工作原理

new Proxy(target, handler) 要求 targethandler 都是对象。JavaScript 对代理执行内部对象操作时,会查找相应 trap;trap 不存在时,操作转发给目标。trap 存在但不可调用,或者返回结果违反语言不变量时,操作会抛出 TypeError

一次带默认转发的属性读取可以按下面的顺序理解:

  1. 表达式 proxy[key] 对代理发起内部 [[Get]] 操作。
  2. 代理查找 handler 的 get trap。
  3. get(target, key, receiver) 接收目标、属性键和最初的接收者。
  4. trap 调用 Reflect.get(target, key, receiver) 执行普通读取语义。
  5. 引擎核对 trap 结果必须满足的不变量,再把结果交给调用方。

trap 的参数由被拦截的语言操作决定,不是任意的回调签名。下面是完整映射; 属性描述符(property descriptor) 相关操作尤其容易与赋值混淆。

调用方操作Proxy trap默认操作
proxy[key]getReflect.get()
proxy[key] = valuesetReflect.set()
key in proxyhasReflect.has()
delete proxy[key]deletePropertyReflect.deleteProperty()
自有键枚举ownKeysReflect.ownKeys()
读取自有属性描述符getOwnPropertyDescriptorReflect.getOwnPropertyDescriptor()
定义属性definePropertyReflect.defineProperty()
读取原型getPrototypeOfReflect.getPrototypeOf()
设置原型setPrototypeOfReflect.setPrototypeOf()
检查可扩展性isExtensibleReflect.isExtensible()
禁止扩展preventExtensionsReflect.preventExtensions()
调用函数代理applyReflect.apply()
通过 new 构造constructReflect.construct()

receiver 是最初收到操作的对象,可能就是代理,也可能是继承自代理的对象。Reflect.get(target, key, receiver) 会让访问器中的 this 指向这个接收者;直接写 target[key] 则把访问器的 this 固定为目标。Reflect.set()receiver 对继承 setter 和最终把属性定义到哪个对象上同样重要。

Reflect 不会让操作自动变得安全,也不会绕过其他代理。目标本身若是代理,Reflect.get() 仍可能触发它的 trap;目标属性若是 getter,读取仍会执行 getter。handler 中应对 target 调用对应方法,而不是对 receiver 再做同一反射操作,否则很容易递归回当前 trap。

策略通常跨越多个 trap。只在 set 中校验不能拦住 Object.defineProperty(proxy, key, descriptor),只在 get 中隐藏名称也不会自动影响 in、键枚举或描述符查询。先列出调用方能执行的全部操作,再决定哪些转发、拒绝或需要保持一致。

示例

下面四个示例依次展示默认转发、receiver、一致的虚拟属性和撤销。输出都由本地 Node 24 实际执行对应文件得到。

校验写入并保留默认读取

第一个 handler 只给读取添加日志,并对已声明规则的字段校验写入。属性键可以是 Symbol,因此日志先调用 String(key),而不是直接把键插入模板字符串。

validated-order.js
function withValidation(target, rules) {
  return new Proxy(target, {
    get(target, key, receiver) {
      console.log(`get ${String(key)}`);
      return Reflect.get(target, key, receiver);
    },
    set(target, key, value, receiver) {
      const accepts = rules[key];
      if (accepts && !accepts(value)) {
        throw new TypeError(`invalid ${String(key)}: ${value}`);
      }
      console.log(`set ${String(key)}=${value}`);
      return Reflect.set(target, key, value, receiver);
    },
  });
}

const order = withValidation(
  { quantity: 1, unitPrice: 25 },
  { quantity: (value) => Number.isInteger(value) && value > 0 },
);

order.quantity = 3;
console.log(order.quantity * order.unitPrice);
try {
  order.quantity = 0;
} catch (error) {
  console.log(error.message);
}
console.log(order.quantity);
set quantity=3
get quantity
get unitPrice
75
invalid quantity: 0
get quantity
3

Reflect.set() 返回布尔值,正好符合 set trap 的返回约定。它返回 false 时,严格模式下的普通赋值会抛出 TypeError;handler 不应无条件返回 true 来掩盖失败。这个例子只承诺校验普通赋值,尚未覆盖属性定义路径。

规则对象本身也有原型,因此通用库不应直接相信任意键能安全索引它。可以使用 Object.hasOwn(rules, key)、无原型字典或 Map 明确规则集合。这里的数据和规则都由同一段可信代码创建,示例只聚焦 trap 转发。

保留访问器的接收者

两个代理只在转发 receiver 时不同。子对象从代理继承 getter,因此这个差异会改变 getter 中 this 所见的属性。

receiver.js
const account = {
  owner: "base",
  currency: "USD",
  get label() {
    return `${this.owner}:${this.currency}`;
  },
};

const badProxy = new Proxy(account, {
  get(target, key) {
    return Reflect.get(target, key, target);
  },
});

const goodProxy = new Proxy(account, {
  get(target, key, receiver) {
    return Reflect.get(target, key, receiver);
  },
});

const badChild = Object.create(badProxy);
badChild.owner = "Mina";
badChild.currency = "EUR";

const goodChild = Object.create(goodProxy);
goodChild.owner = "Mina";
goodChild.currency = "EUR";

console.log(badChild.label);
console.log(goodChild.label);
base:USD
Mina:EUR

badProxy 明确把 target 当作接收者,所以 getter 读取基础对象的字段。goodProxy 保留最初的 goodChild,getter 因而读取子对象自己的字段。这类错误在只测试 proxy.label 时不会暴露,必须加入继承或访问器用例。

这里没有 set trap,但对子对象赋值仍会通过代理原型的默认 [[Set]] 转发,并最终在子对象上创建自有属性。若 handler 添加 set trap,传给 Reflect.set()receiver 仍应是原始接收者。

让虚拟属性参与反射

仅实现 get 会让 invoice.total 可读,却让 "total" in invoiceObject.keys(invoice) 否认它存在。下面的 handler 同时定义读取、存在性、键列表和属性描述符,让常用观察方式得到一致结果。

virtual-total.js
function withTotal(invoice) {
  const totalKey = "total";
  if (Reflect.has(invoice, totalKey)) {
    throw new TypeError("total already exists");
  }

  return new Proxy(invoice, {
    get(target, key, receiver) {
      if (key === totalKey) {
        return receiver.quantity * receiver.unitPrice;
      }
      return Reflect.get(target, key, receiver);
    },
    has(target, key) {
      return key === totalKey || Reflect.has(target, key);
    },
    ownKeys(target) {
      return [...Reflect.ownKeys(target), totalKey];
    },
    getOwnPropertyDescriptor(target, key) {
      if (key === totalKey) {
        return { configurable: true, enumerable: true };
      }
      return Reflect.getOwnPropertyDescriptor(target, key);
    },
  });
}

const invoice = withTotal({ quantity: 3, unitPrice: 25 });
console.log(invoice.total);
console.log("total" in invoice);
console.log(Object.keys(invoice).join(", "));
console.log(JSON.stringify(invoice));
75
true
quantity, unitPrice, total
{"quantity":3,"unitPrice":25,"total":75}

Object.keys() 先取得 ownKeys,再只保留描述符为可枚举的字符串键。JSON.stringify() 也会通过这些观察路径读取值,所以它包含 total。不同消费者采用不同反射操作,这正是虚拟对象必须定义一致表面的原因。

虚拟描述符声明 configurable: true,因为目标上没有不可配置的 total 属性可支撑更强声明。若之后调用 Object.preventExtensions() 禁止目标扩展,继续报告额外键会违反不变量;生产实现需要规定并测试这个状态转换。

撤销一个临时视图

Proxy.revocable() 返回代理和撤销函数。撤销后,代理上的基本操作都会失败,但目标对象及其其他别名仍然有效。

revocable-session.js
function openReadOnlySession(record) {
  const { proxy, revoke } = Proxy.revocable(record, {
    set() {
      throw new TypeError("read-only session");
    },
  });
  return { view: proxy, close: revoke };
}

const record = { id: "job-7", status: "open" };
const { view, close } = openReadOnlySession(record);

console.log(view.status);
try {
  view.status = "closed";
} catch (error) {
  console.log(error.message);
}

close();
try {
  console.log(view.id);
} catch (error) {
  console.log(error.name);
}

record.status = "closed";
console.log(record.status);
open
read-only session
TypeError
closed

撤销是代理能力的生命周期控制,不是资源清理协议。它不会关闭文件、取消计时器、擦除目标数据,也不会让已有的目标别名失效。资源所有者仍需提供并调用明确的清理操作。

只实现 set 也不等于完整只读。调用方仍可能通过 definePropertydeleteProperty、原型 setter,或者目标别名修改状态。真实的只读契约必须列出允许的操作,覆盖相关 trap,并控制目标如何暴露。

陷阱

丢失 receiver

修复方法: 转发时保留 trap 收到的 receiver,并用继承自代理的对象测试访问器。只有契约明确要求把方法或访问器固定到目标时才改变接收者,同时记录对象标识和封装边界的变化。

把一个 trap 当作完整策略

修复方法: 根据威胁模型和调用方 API 建立操作矩阵。对赋值、Object.defineProperty()、删除、inObject.keys()Reflect.ownKeys() 与描述符查询分别测试,不能把代理当作真正安全边界时就不要这样宣传。

违反不变量

修复方法: 尽量从相应 Reflect 结果开始做最小变换。用不可写且不可配置的数据属性、无 setter 的访问器和 Object.preventExtensions() 目标运行测试,并把抛错视为 handler 缺陷,而不是调用方随机失败。

无条件报告写入成功

修复方法: 拒绝写入时抛出领域明确的错误,允许写入时返回 Reflect.set() 的真实布尔结果。测试不可写属性与不可扩展目标,并断言最终描述符或值,而不只断言“没有抛错”。

代理带品牌检查的对象

修复方法: 不要假定空 handler 对所有对象都透明。为必须支持的具体类型写显式适配器;若把方法绑定到目标,要测试方法标识、回调传递、链式返回和目标是否因此越过代理边界。

把撤销当作销毁

修复方法: 把访问撤销和资源生命周期设计成两个明确协议。清理函数负责关闭或取消资源,撤销函数负责拒绝后续代理操作;测试两条路径的调用顺序与重复调用行为。

深入 Trap 合约与不变量

Trap 合约与不变量

代理允许自定义行为,但不能破坏对象模型赖以推理的事实。这些约束叫作 代理不变量(proxy invariant) 。引擎在 trap 返回后验证相关约束,所以一个正常返回的 handler 函数仍可能让外层操作抛出 TypeError

不变量与目标的真实状态相连。对于不可写且不可配置的数据属性,get 不能报告不同值,set 不能声称成功写入不同值;对于不可配置且没有 getter 或 setter 的访问器,相应结果也受限制。可配置属性留给代理的变化空间更大,但不表示所有 trap 之间可以互相矛盾。

ownKeys 的结果必须只含字符串或 Symbol,而且不能重复。它必须包含目标的全部不可配置自有键;目标不可扩展时,结果必须与目标全部自有键完全一致。因此,额外虚拟键只适合仍可扩展的目标,除非实现把虚拟属性真实定义到目标上并维护描述符一致性。

getOwnPropertyDescriptor 不能把目标的不可配置属性伪装成不存在,也不能凭空为不可扩展目标报告新属性。definePropertydeletePropertyhas、原型和可扩展性 trap 各有类似约束。最可靠的设计是先用对应 Reflect 方法取得合法基线,再只修改契约真正需要的部分。

Reflect 方法的失败形式并不完全相同。Reflect.defineProperty()Reflect.deleteProperty()Reflect.set()Reflect.preventExtensions() 用布尔值表示某些失败,而 Object.defineProperty() 一类包装 API 可能抛错。调用方必须检查布尔结果;把函数式接口误解为“永不抛错”仍然不正确,因为参数错误、trap 错误和不变量冲突都可能抛出异常。

trap 之间还会组合。带代理 receiverReflect.set(target, key, value, receiver) 可能在最终接收者上执行 [[DefineOwnProperty]],从而触发同一个 handler 的 defineProperty trap。日志若同时放在 setdefineProperty,一次赋值可能记录两次;策略代码应按语义去重,而不是假定每条源码语句只触发一个 trap。

透明转发的边界

空 handler 能转发代理支持的内部对象方法,但代理仍是不同对象。proxy === targetfalse,以代理和目标分别作为 MapWeakMap 的键会得到两个条目。依赖引用标识的缓存、订阅和去重逻辑必须选定统一身份。

代理也不能重新定义严格相等、typeof 的全部分类或私有字段语法。可调用目标产生可调用代理,构造目标才允许 construct trap;handler 不能把普通对象变成真正可调用对象。Proxy 是受语言预定义 trap 集限制的虚拟化机制,不是任意语法重载。

接收者、内部槽与对象膜

规范中的 内部槽(internal slot) 是对象携带、但不能作为普通属性访问的规范状态。MapSetDate、带私有字段的实例以及许多平台对象会先检查接收者是否带有正确状态。代理通常不自动获得目标的这些槽或品牌,所以“没有 trap”不等于方法调用一定透明。

把读到的每个函数都 bind(target) 可以让一部分内置方法工作,但代价并不小。每次读取若创建新绑定函数,proxy.method === proxy.method 会变成 false;方法里的 this 绕过代理,返回 this 的链式方法还可能暴露目标。缓存绑定函数能修复部分标识问题,却不能自动定义正确的安全边界。

面向单一类型的适配器通常更清楚:只暴露允许的方法,以目标作为这些方法的接收者,并明确包装返回的嵌套对象。若需要跨整个对象图维持策略,就进入对象膜设计;读取到的对象、传入的对象、异常和函数调用都可能需要双向包装或解包。

对象膜还必须保持身份。对同一个目标重复包装应返回同一个代理,否则相等比较、集合成员资格和循环对象图都会出问题。典型实现用 WeakMap 保存目标到代理的映射,并视需要保存反向映射;这比在每次 get 中直接 new Proxy(value, handler) 多了一层明确的生命周期与身份管理。

撤销整个对象膜比撤销单个代理更复杂。已经从膜中取得的每个嵌套代理都必须共享撤销状态,后来执行的 getter 或回调也不能重新泄漏未包装目标。若系统只需要一个短期只读视图,保持目标不外泄并使用单个可撤销代理更容易审计。

测试代理合约

测试应从可观察契约出发,而不是只逐个调用 handler。至少比较直接语法和对应的 Reflect 调用,再加入 Object.keys()Reflect.ownKeys()、描述符、继承访问器和严格模式赋值。这能覆盖多个内部操作组合起来的真实路径。

不变量测试需要改变目标状态。先在普通可扩展目标上运行,再加入不可配置属性、不可写属性、无 setter 的访问器,以及 Object.preventExtensions() 后的同一组操作。只测试普通对象会让非法 ownKeys 和虚假的 set 结果长期潜伏。

边界测试还应使用字符串键与 Symbol 键、可调用与不可调用目标、内置集合、私有字段实例,以及撤销后的每一种已支持操作。对虚拟属性,要核对读取、in、键枚举、描述符与序列化是否按契约一致,而不是假定它们必须全部表现相同。

最后检查目标别名和嵌套对象。如果策略要求所有访问都经过代理,创建代理后继续暴露目标就是设计缺口;如果读取嵌套对象后返回原对象,策略也会在下一层消失。把这些约束写进测试,比把代理笼统称为“安全”或“响应式”更可验证。

延伸阅读

检查点

4个问题 · 1 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?