Proxy与Reflect

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

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

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

trap

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

fix

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

是什么,为什么存在

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

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

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

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

工作原理

new Proxy(target, handler)要求target和handler都是对象。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 invoice和Object.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也不等于完整只读。调用方仍可能通过defineProperty、deleteProperty、原型setter,或者目标别名修改状态。真实的只读契约必须列出允许的操作,覆盖相关trap,并控制目标如何暴露。

陷阱

丢失receiver

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

把一个trap当作完整策略

修复方法: 根据威胁模型和调用方API建立操作矩阵。对赋值、Object.defineProperty()、删除、in、Object.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不能把目标的不可配置属性伪装成不存在,也不能凭空为不可扩展目标报告新属性。defineProperty、deleteProperty、has、原型和可扩展性trap各有类似约束。最可靠的设计是先用对应Reflect方法取得合法基线,再只修改契约真正需要的部分。

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

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

透明转发的边界

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

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

接收者、内部槽与对象膜

规范中的 内部槽(internal slot) 是对象携带、但不能作为普通属性访问的规范状态。Map、Set、Date、带私有字段的实例以及许多平台对象会先检查接收者是否带有正确状态。代理通常不自动获得目标的这些槽或品牌,所以“没有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上编辑 报告错误 讲清楚了吗?