代理与反射:Proxy/Reflect 的语义、陷阱与工程实践
2025-12-17 12:30
把对象的“行为”也变成可编程:
Proxy用来拦截和定制对象的基本操作,Reflect用来以函数形式执行这些基本操作并返回可检查的结果。配合使用能写出既强大又可靠的元编程代码。
为什么需要 Proxy/Reflect
- 通过拦截读写/枚举/判断等基础操作,实现校验、日志、权限控制、只读视图、懒加载、响应式等能力
- 用
Reflect代替“语法级操作”(如obj[prop] = v、delete obj[p])可获得统一的返回值与异常行为,便于在拦截中“正确转发” - 框架场景:Vue 3 响应式系统、权限/配置代理、API 防御层、Mock、数据追踪等
Proxy 是什么
- 语法:
const proxy = new Proxy(target, handler) target:被代理的原对象handler:拦截函数集合(trap),拦截基础操作并可自定义行为- 常见 trap:
get(target, prop, receiver)读取属性值set(target, prop, value, receiver)写入属性值has(target, prop)判断属性是否存在deleteProperty(target, prop)删除属性ownKeys(target)获取所有可枚举属性键getOwnPropertyDescriptor(target, prop)获取属性描述符defineProperty(target, prop, descriptor)定义属性描述符apply(target, thisArg, args)调用函数construct(target, args, newTarget)构造对象 例子:
JSconst user = { id: 1, name: "Arafat" }; const readonlyUser = new Proxy(user, { get(target, prop, receiver) { return Reflect.get(target, prop, receiver); }, set() { return false; }, deleteProperty() { return false; }, }); readonlyUser.name; // 'Arafat' readonlyUser.name = "X"; // 失败(严格模式下会报错) delete readonlyUser.id; // 失败
Proxy 负责“拦截并改写对象的语言级行为”,更准确的说:
Proxy 真正拦截的“对象的基本语义操作”([[Get]]、[[Set]]、[[Has]]、[[OwnKeys]] 等),而不是简单的属性读写 这也是为什么
in,delete,Object.keys等都能被 Proxy 拦截。
Proxy 的生命周期
Proxy 在被创建的那一刻就已经生效,从此之后所有通过 proxy 这个引用触发的基础语义操作都会先进入 trap。
关键点在于:
代理并不会“包裹”或“替换”原对象本身,它只作用于“通过代理访问”这一条路径。
JSconst obj = { x: 1 }; const p = new Proxy(obj, handler); p.x; // 会触发 get trap obj.x; // 不会触发
代理是否生效,取决于访问路径是否经过 proxy,而不是对象本身发生了什么变化。
而代理是不会自动过期或失效,如果想失效当前只有一种办法:
JSconst { proxy, revoke } = Proxy.revocable( { x: 1 }, { get(target, prop, receiver) { return Reflect.get(target, prop, receiver); }, } ); proxy.x; // 1 revoke(); proxy.x; // TypeError: Cannot perform 'get' on a proxy that has been revoked
调用 revoke() 之后:
- 所有对该代理的操作都会抛出
TypeError - 包括
get / set / in / delete / Object.keys等 这是唯一一种“官方支持的代理失效机制”。代理失效只影响 proxy,不影响 target 本身,所以Proxy.revocable非常适合: - 安全隔离
- 生命周期受控的上下文
- 临时能力暴露
如果不使用 Reflect 会发生什么
trap里直接用“语法级操作”(如t[p] = v、delete t[p])容易引入三个问题:this/receiver错位:访问器和方法中的this绑定到target,不是“调用者”,导致语义偏差- 返回值不规范:有的语法会抛异常,有的返回布尔,自己“猜”容易破坏不变式
- 不变式违规:对不可配置/只读属性错误地返回
true,规范要求返回false或抛错
例子:
JSlet o1, o2, o3, o4; o1 = { coin: 11 }; const handler = { get(target, prop, receiver) { console.log(`Getting ${prop}!`); // Getting coin! return Reflect.get(target, prop, receiver); }, set(target, prop, value, receiver) { console.info(target === o1, receiver === o4); // true true console.log(`Setting ${prop} to ${value}!`); // Setting coin to 44! target[prop] = value // Reflect.set(target, prop, value, receiver) return true // 必须返回 true 或 false,否则会抛 TypeError }, }; o2 = new Proxy(o1, handler); o3 = Object.create(o2); o4 = Object.create(o3); o4.coin = 44; console.info(o1.coin, o2.coin, o3.coin, o4.coin); // true true // Setting coin to 44! // Getting coin! // Getting coin! // Getting coin! // 44 44 44 44
这里主要的问题是:
set/get必须返回true或false,否则会抛TypeErroro4.coin = 44;receiver 明明是 o4 但是因为我们直接使用 target 导致 o1.coin 变成了44而 o4 没有 coin,所以输出的 coin 都来自 o1。这就导致了原型链语义失效。
更严重的是不变式违规:
JSconst obj = Object.defineProperty({}, "a", { value: 1, writable: false, configurable: false }); const p2 = new Proxy(obj, { set(t, p, v) { t[p] = v; // 对只读且不可配置属性的写入应返回 false return true; // 错误!违反不变式,规范要求返回 false(严格模式下会抛 TypeError) }, deleteProperty(t, p) { delete t[p]; // 对冻结对象的属性删除通常为 false return true; // 错误!应返回 Reflect.deleteProperty 的结果,避免破坏不变式 }, }); p2.a = 2; // 可能抛 TypeError 或行为未定义
Reflect 是什么
Reflect 是一个内置对象,提供了一组静态方法,用于操作对象(例如读取属性、设置属性、删除属性、调用函数等)。特点:
- 不可构造(不能 new Reflect())
- 所有方法都是静态方法,例如 Reflect.get(obj, prop)、Reflect.set(obj, prop, value)
- 方法返回 boolean 或结果值,行为上比直接操作对象更一致、可控
它通常在
Proxy中使用来保持默认行为。为了更好的了解Reflect我们可以先看一下如果不使用 Reflect 会怎么样:
为了解决直接操作 target 可能破坏原型链和访问器 this 的问题,ES6 提供了 Reflect。在 trap 内使用 Reflect.get/set 并传入 receiver 后:
-
对访问器(getter/setter)或方法的 this 指向会保持为触发操作的对象(receiver),而不是 target
-
原型链赋值行为保持语义:如果赋值发生在原型链上,属性会在 receiver 对象上创建,而不是修改 target
-
把对象的内部方法以函数形式暴露:
Reflect.get/Reflect.set/Reflect.has/Reflect.deleteProperty/... -
与对应的语法操作等价,但统一返回值(多为布尔或结果),更利于在 trap 中“原样转发”
-
典型使用:在 trap 内先做校验/日志,再调用
Reflect.*执行真实操作 例子:
JSconst model = {}; const safeModel = new Proxy(model, { set(target, prop, value, receiver) { if (prop === "age" && (!Number.isInteger(value) || value < 0)) return false; return Reflect.set(target, prop, value, receiver); }, }); safeModel.age = 20; safeModel.age = -1; // 失败
不变式与规范约束(非常关键)
不变式(Invariant):Proxy 不能“撒谎”的根本原因
在理解 Proxy 的能力边界之前,必须先理解 不变式(Invariant) 这个概念。不变式并不是业务规则,也不是人为施加的限制,而是 ECMAScript 规范中对对象“真实状态”的硬性约束。 更准确地说:
- 不变式用于描述对象在某一时刻已经确立的、必须始终成立的事实
- 一旦这些事实被建立,任何语言级操作(包括 Proxy 的 trap)都不能给出与之矛盾的结果
- JavaScript 引擎会基于这些不变式,判断一次操作是否合法,否则抛出
TypeError例如: - 不可配置属性(configurable: false)
- 不能被删除
- 不能被重新配置为可配置
- 不可写属性(writable: false)
- 不能被赋新值
- 对象的可扩展性([[Extensible]])
- 当对象被 Object.preventExtensions / seal / freeze 后,不能再新增属性
- 冻结对象(
Object.freeze)的属性集合和属性特征不能再发生变化 这些都不是“Proxy 的限制”,而是对象本身已经存在的不变式。
因此可以理解为:
不变式描述的是对象“是什么”,
而读写、删除、枚举等行为,只是基于这些事实允许或禁止的结果。
所谓“撒谎”,指的是 trap 返回的结果,与对象真实状态不一致。 例如:
- 对不可删除的属性,deleteProperty 却返回 true
- 对只读且不可配置的属性,set 却返回 true
- 对不可扩展对象,ownKeys 却返回了额外的键 一旦出现这种情况,规范要求引擎直接抛出 TypeError。比如:
JSconst obj = Object.defineProperty({}, "a", { value: 1, writable: false, configurable: false }); const p2 = new Proxy(obj, { set(t, p, v) { t[p] = v; // 对只读且不可配置属性的写入应返回 false return true; // 错误!违反不变式,规范要求返回 false(严格模式下会抛 TypeError) }, deleteProperty(t, p) { delete t[p]; // 对冻结对象的属性删除通常为 false return true; // 错误!应返回 Reflect.deleteProperty 的结果,避免破坏不变式 }, }); p2.a = 2; // 可能抛 TypeError 或行为未定义
- 你可以重写行为,但不能违反对象本身的“不变式”,否则会抛错或被规范禁止:
ownKeys必须至少包含对象的所有不可配置属性键- 对于不可配置属性,
getOwnPropertyDescriptor的结果必须与实际一致 - 冻结/密封对象的属性集合与属性特征不能被拦截修改
set对只读且不可配置的属性必须返回false
- 结论:遇到不可配置/冻结场景,trap 内应尽量使用
Reflect.*原样转发,避免破坏不变式
receiver 与 this:为什么要用 Reflect.get/Reflect.set
JSconst target = { x: 1, getX() { return this.x; }, }; const proxy = new Proxy(target, { get(t, p, receiver) { return Reflect.get(t, p, receiver); }, }); proxy.getX(); // 1(this 绑定到 proxy,Reflect.get 保留正确 receiver)
- 使用
Reflect.get(t, p, receiver)能让访问器/方法中的this指向“调用者”(通常是代理本身),与语言语义一致 - 直接
t[p]会绑定到target,导致this行为偏差
枚举与键集合拦截
JSconst obj = { a: 1, _secret: 42, b: 2 }; const prox = new Proxy(obj, { ownKeys(target) { return Reflect.ownKeys(target).filter((k) => k !== "_secret"); }, get(target, prop, receiver) { if (prop === "_secret") return undefined; return Reflect.get(target, prop, receiver); }, }); Object.keys(prox); // ['a', 'b'] prox._secret; // undefined
- 注意:如果
_secret是不可配置的属性,ownKeys不允许把它隐藏
函数与构造拦截:apply/construct
JSfunction sum(a, b) { return a + b; } const loggedSum = new Proxy(sum, { apply(target, thisArg, args) { console.log("call", args); return Reflect.apply(target, thisArg, args); }, }); loggedSum(1, 2);
JSclass Person { constructor(name) { this.name = name; } } const ProxiedPerson = new Proxy(Person, { construct(target, args, newTarget) { const inst = Reflect.construct(target, args, newTarget); if (!inst.name) throw new Error("name required"); return inst; }, }); new ProxiedPerson("Arafat");
为什么 Proxy 无法被 polyfill
在理解了 Proxy 拦截的是语言级语义操作以及“不变式”的存在之后,就可以回答一个常见问题:为什么 Proxy 无法被 polyfill?
答案是因为因为 Proxy 并不是库能力,而是语言层能力。Proxy 拦截的不是“函数调用”,而是语义内部操作。
polyfill 的前提条件是该特性可以用已有语言能力组合实现,在 Proxy 上不成立的。 例子:
JS// Array.prototype.includes 的简化 polyfill if (!Array.prototype.includes) { Array.prototype.includes = function (value) { return this.indexOf(value) !== -1; }; }
因为 includes 本质是:一个普通方法 + 可用已有能力(indexOf + 循环)实现。
不能被 polyfill 的特性:
- 改变了语法行为(如
Proxy) - 改变了解释阶段(如模块系统)
- 依赖引擎内部 slot(如 [[Get]])
polyfill 是“用语言实现语言”,而 Proxy 是“语言本身提供的拦截点”。
Proxy性能问题: 深层代理
Proxy 真正拦截的“对象的基本语义操作”([[Get]]、[[Set]]、[[Has]]、[[OwnKeys]] 等),所以 可以理解为只会进行浅层的代理。
JSconst obj = { name: 'arafat', child: { name: 'arafat-child', } } const objProxy = new Proxy(obj, { get(target, key, receiver) { console.log('get:', key); return Reflect.get(target, key, receiver); }, set(target, key, value, receiver) { console.log('set:', key, value); return Reflect.set(target, key, value, receiver); } }); objProxy.name = '1' // set: name 1 objProxy.child.name = '2' // get: child
会发现 objProxy.child.name = '2' 触发了 get 操作,这是因为这在语法上是一行,在语义上是两步。
- 第一步: 读取 objProxy.child
- 第二步: 对读取到的 objProxy.child 进行属性赋值
这里
objProxy是代理对象,objProxy.child是目标对象的属性值,是一个普通对象,不是代理对象。
如果想要对 objProxy.child 进行代理,一般会想到方案有两种:
- 全量深度代理
- 懒代理(Lazy Proxy)
全量深度代理
全量深度代理是指对目标对象的所有属性都进行代理,包括嵌套对象的属性。
实现全量深度代理的一种简单方法是递归地对目标对象的所有属性进行代理。
JSfunction deepProxy(target) { if (typeof target !== 'object' || target === null) { return target; } const proxy = new Proxy(target, { get(target, key, receiver) { const value = Reflect.get(target, key, receiver); return deepProxy(value); }, set(target, key, value, receiver) { return Reflect.set(target, key, deepProxy(value), receiver); } }); return proxy; }
这种方式确实可以做到“完全深层拦截”,但会带来严重问题:
- 初始化成本极高
- 初始化成本极高
- 初始化阶段创建的 Proxy 数量越多
- 页面首屏、数据加载阶段会明显变慢
- 内存占用增加
- 内存占用增加
- 每个 Proxy 都是一个独立对象
- 容易造成 GC 压力
- 代理身份不稳定(引用问题):
deepProxy(obj).child !== deepProxy(obj).child- 每次访问属性时都会创建新的代理对象
- 这会导致引用问题,如
objProxy.child === objProxy.child为false
- 无法与冻结对象、不变式良好共存
- 无法与冻结对象、不变式良好共存
- 深度代理容易违反不变式
- trap 逻辑复杂度上升
懒代理
主流框架(如 Vue 3)并不会做“全量深度代理”。它们采用的是:懒代理(Lazy Proxy)。核心思想:
- 核心思想
- 只有当访问到属性时,才会创建代理对象
- 只代理第一层
JSconst proxyCache = new WeakMap(); function reactive(target) { if (typeof target !== 'object' || target === null) return target; if (proxyCache.has(target)) { return proxyCache.get(target); } const proxy = new Proxy(target, { get(t, key, receiver) { const res = Reflect.get(t, key, receiver); if (typeof res === 'object' && res !== null) { return reactive(res); // 访问到才代理 } return res; }, set(t, key, value, receiver) { return Reflect.set(t, key, value, receiver); } }); proxyCache.set(target, proxy); return proxy; } const state = reactive({ name: 'arafat', child: { name: 'child' } }); state.child.name = 'new'; // 被正确拦截
现在只有在 child 被访问时,才会创建对应的 Proxy。
Proxy 默认只拦截一层,深层拦截必须显式设计;> 全量深度代理是反模式,懒代理才是工程解。
常见陷阱与性能建议
- 私有字段
#x不经过代理拦截:对类的私有字段访问不会触发get/set JSON.stringify、Object.assign等 API 的行为可能因拦截而变化,需要测试- 深层代理开销大:频繁创建/嵌套代理会影响性能,建议按需代理或只在边界层代理
- 在 trap 内尽量使用
Reflect.*做“透明转发”,减少不变式风险 - 对不可配置属性、冻结对象,优先遵循原始行为;不要“试图隐藏/修改”它们
实践清单(可执行)
- trap 内优先
Reflect.*原样转发,再叠加校验/日志/权限 - 涉及访问器与方法时使用
Reflect.get/Reflect.set并传递receiver - 不要违反不变式:不可配置属性与冻结对象的结构必须保持
- 需要失效能力时使用
Proxy.revocable - 仅在边界/关键对象上代理,避免全量深层代理的性能与维护成本
- 私有字段不拦截:对类设计要清楚哪些成员可被代理“观察”
总结
把这一篇压缩成一句话就是:
Proxy 负责“拦截并改写对象的语言级行为”,Reflect 负责“以规范、可组合、可校验的方式执行这些行为”。二者不是对立关系,而是一套成对出现的元编程工具。
如果往工程实践再推进半步,可以记住下面几个“硬结论”:
-
使用 Proxy 时,脑子里要有“不变式”这根红线
- 不要试图对只读、不可配置、冻结对象“撒谎”
- trap 里优先用
Reflect.*原样转发,再叠加校验、日志、权限
-
receiver和this是理解 Proxy/Reflect 的关键纽带Reflect.get/Reflect.set搭配receiver,才能保持原型链与访问器的语义不被破坏- 一旦直接对
target读写,很容易出现“看起来在改 o4,实际上改的是 o1”这种隐蔽 Bug
-
深层代理是设计问题,不是语法问题
- 默认的 Proxy 只是一层拦截:
obj.child.name实际上是两步操作 - 全量深度代理基本都是反模式:初始化重、内存高、引用不稳定,还容易踩不变式
- 懒代理(按访问路径逐步包 Proxy + WeakMap 复用)才是主流框架的工程解
- 默认的 Proxy 只是一层拦截:
-
选对“边界”比“到处代理”更重要
- 尽量在模型层、API 边界、配置边界这些“流量集中点”放 Proxy
- 把 Proxy 当作安全网 / 日志层 / 权限层,而不是每个对象的默认选择
如果你能在写代码时自然地想到:
- 这里是否适合用一个 Proxy 做一层“行为防火墙”
- trap 里是不是应该用
Reflect.*来保证不变式和 this 语义 - 这个对象是否需要深层代理,还是只在边界做懒代理就够了
那么这一篇关于 Proxy / Reflect 的内容,基本就已经转化成了你手里的工程工具,而不是记忆里的语法点。
如果你看到这里,谢谢你花时间阅读这篇小小的开篇文章。希望未来能与你在技术的旅途中有更多交流。