一篇文章讲透Proxy的13个拦截陷阱与Reflect的正确转发姿势,并手撕迷你版Vue3响应式原理。
Proxy 与 Reflect 元编程:从拦截器到 Vue3 响应式原理
Proxy 与 Reflect 元编程:从拦截器到 Vue3 响应式原理
很多人第一次在 Vue 3 源码里看到 reactive 函数返回的那个 new Proxy 时,会有两种相反的情绪同时在脑子里冒出来:一边是"原来如此"的恍然大悟,一边是"这到底是什么黑魔法"的隐隐迷惑。其实 Proxy 和 Reflect 一点都不神秘,它们是 ES6 引入的一对元编程(Metaprogramming)原语,作用是在"对象的底层操作"上插桩。换句话说,你可以在别人读取属性、写入属性、删除属性、甚至用 new 实例化的时候,悄悄拦一道,做一点自己的事情,然后再决定是放行还是拦截。所谓元编程,简单说就是"写能够操作程序的程序",让代码在运行时反过来感知并改造自身的行为,而 Proxy 与 Reflect 正是 JavaScript 把这种能力开放给普通开发者的标准入口。
这套能力听起来非常底层,但它的应用就藏在我们每天使用的框架里:Vue 3 的响应式系统、Immer 的不可变数据、MobX 的状态追踪、各种接口 Mock 工具、以及一些细粒度的权限校验与访问日志方案,全部建立在 Proxy 之上。今天这篇文章,我们就从 Proxy 的基本心智模型讲起,把 13 个陷阱(trap)一次讲透,再用 Reflect 把"默认行为"正确地还回去,最后亲手撕一个迷你版的 Vue 3 响应式引擎,顺带把两个最容易踩的坑——this 指向和递归代理——讲清楚。
一、重新认识 Proxy:它不是魔法,是"拦截器"
先把所有术语抛到一边。Proxy 的本质其实特别简单:你有一个对象,你在它前面挂了一层"代理",此后所有对它的操作,都必须先过这道代理的手。代理不拦的时候,操作原样透传到原对象;代理想拦的时候,它可以在中间插入任意逻辑。
最常见的写法长成下面这样:
javascript// 定义一个普通对象作为"目标"(target)const target = { name: '前端哥', age: 18,};// handler 就是"拦截规则"的集合,里面的每个方法在 Proxy 里叫一个 trap(陷阱)const handler = { // 读取属性时触发:例如 proxy.name、proxy['name'] get(target, property, receiver) { console.log(`有人读取了属性:${String(property)}`); // 用 Reflect.get 执行默认的读取行为,把结果交回去 return Reflect.get(target, property, receiver); }, // 写入属性时触发:例如 proxy.age = 20 set(target, property, value, receiver) { console.log(`有人把 ${String(property)} 改成了 ${value}`); // 用 Reflect.set 执行默认的写入行为 return Reflect.set(target, property, value, receiver); },};// 创建代理:p 就是包裹在 target 外面的那层"拦截器"const p = new Proxy(target, handler);p.name; // 控制台输出:有人读取了属性:namep.age = 20; // 控制台输出:有人把 age 改成了 20
<
运行上面这段代码你会发现:读取 name、写入 age,控制台都打出了日志,但 target 里的数据该怎么样还怎么样,一个字节都没被改坏。这就是 Proxy 的精髓——它不对原对象做任何破坏性修改,只是在操作路径上"站了个岗"。
这里有一条特别重要的心智模型需要刻进脑子里:handler 里你只写了 get 和 set,那么 delete 删除、in 判断、for...in 遍历这些操作,就完全不受任何影响,原样落到 target 上。换句话说,Proxy 是"按需拦截"的,没有定义陷阱的操作,就当代理压根不存在。这也是为什么你可以用 Proxy 一个操作一个操作地精细控制,而不需要重写整个对象——你只拦你想拦的那一下。
还有一个容易被忽略的事实:Proxy 的目标必须是对象或函数,字符串、数字、布尔这些原始类型不能当 target。所以当你想代理一个基本类型时,都得先把它包进一个对象(这恰好也是 Vue 3 里 ref 为什么要用一个 { value: xxx } 的盒子来装基本类型的根本原因)。
二、13 个陷阱(trap)全景:你到底能拦截什么
Proxy 的 handler 上最多可以有 13 个陷阱,每一个都对应一种 JavaScript 对对象的基础操作。下面把它们一次列全,并标清楚每个陷阱在什么场景下被触发:
- get:读取属性,例如 p.foo、p['foo'],常用于默认值、懒加载、依赖收集。
- set:写入属性,例如 p.foo = bar,常用于校验、通知(响应式)、日志。
- has:in 操作符,例如 'foo' in p,常用于隐藏"私有"属性、虚拟成员判断。
- deleteProperty:delete 操作,例如 delete p.foo,常用于拦截/审计删除。
- apply:函数调用,例如 p(...args)、p.call()、p.apply(),常用于包裹函数调用(计时、重试、日志)。
- construct:new 操作,例如 new p(...args),常用于拦截实例化。
- ownKeys:Object.keys()、Object.getOwnPropertyNames()、Reflect.ownKeys()、for...in,用于控制在枚举时出现哪些键。
- getOwnPropertyDescriptor:Object.getOwnPropertyDescriptor(),配合 ownKeys / has 使用。
- defineProperty:Object.defineProperty(),用于守卫属性定义。
- getPrototypeOf:Object.getPrototypeOf()、instanceof、proto,用于伪造原型链。
- setPrototypeOf:Object.setPrototypeOf(),用于锁定原型。
- isExtensible:Object.isExtensible(),较少用,和下面一个配对。
- preventExtensions:Object.preventExtensions(),较少用,和上面一个配对。
这 13 个陷阱,覆盖了你在代码里对一个对象能做的一切基础操作。现实项目里你大概率只会用到其中 5 个:get(读)、set(写)、has(in)、deleteProperty(删除)、apply(调用函数),其余那些更多是为"滴水不漏"的库设计准备的,比如 Immer 这类需要精确控制属性枚举与描述的工具。刚开始接触时不必强求一次记全,实战里真正高频的永远是 get 和 set 这两个最基础的读写陷阱,其余的多半是当你需要更精细地控制枚举、原型或属性描述符时才会派上用场,用到了再回来查这张清单即可。
下面举一个 ownKeys 和 has 配合使用的例子,我们可以在遍历和判断时"藏"掉一些以 _ 开头的内部属性,让一个对象对外"假装"没有某些字段:
javascript// 一个带"私有字段"的业务数据对象const data = { name: '前端哥', _token: 'secret-abc', // 我们不想对外暴露的内部字段};const hiddenHandler = { // 控制 for...in / Object.keys 能看到的键 ownKeys(target) { // 只返回不以下划线开头的属性名 return Reflect.ownKeys(target).filter( (key) => !String(key).startsWith('_') ); }, // 控制 'xxx' in proxy 的判断结果 has(target, property) { // 下划线开头的字段,在 in 判断里一律当作"不存在" if (String(property).startsWith('_')) return false; return Reflect.has(target, property); },};const safeData = new Proxy(data, hiddenHandler);console.log('name' in safeData); // trueconsole.log('_token' in safeData); // false(被我们藏起来了)console.log(Object.keys(safeData)); // ['name'](_token 完全没有出现)
可以看到,仅仅通过 ownKeys 和 has 两个陷阱,我们就让一个对象对外隐藏了内部字段,而原对象本身一个字节都没动。这种"对外透明、对内可控"的特性,正是 Proxy 在封装场景里最好用的地方。
三、Reflect:在陷阱里"回归默认"的正确姿势
细心的你可能已经注意到,前面每一个例子里,我都用了 Reflect.get、Reflect.set、Reflect.has,而不是直接写 target[property] 或 target[property] = value。这不是矫情,而是有硬道理的。很多初学者会这样写 get 陷阱:
javascript// 看似没问题、其实埋了坑的写法const badHandler = { get(target, key) { console.log(`读取了 ${String(key)}`); return target[key]; // 直接用 target 取,绕过了 receiver },};
这种写法在"扁平对象"(没有 getter、不在原型链上)上通常能跑,但它并不是一个忠实的默认行为复刻。一旦对象带 getter,或者处在原型链上,就会出岔子。这就是为什么必须用 Reflect。理解 Reflect 只需要记住三件事。
第一,Reflect 的方法与原生操作语义完全一致。Reflect.get(target, key) 干的就是 target[key] 该干的活,Reflect.set(target, key, value) 干的就是 target[key] = value 的活。它没有副作用,只是把"默认行为"封装成了一个可被调用的函数,方便你在陷阱里干净地"转发"出去。
第二,也是最关键的一点:Reflect 能正确处理 receiver(接收者),从而保证 this 指向正确。考虑下面这个带 getter 的目标对象:
javascript// 带 getter 的目标对象:fullName 依赖 this.first 和 this.lastconst person = { first: '张', last: '三', get fullName() { // 注意这里的 this,它决定了 first/last 从哪个对象上读取 return `${this.first}${this.last}`; },};const proxy = new Proxy(person, { get(target, key, receiver) { console.log(`读取了 ${String(key)}`); // 关键:把 receiver(也就是代理本身)透传给 Reflect.get return Reflect.get(target, key, receiver); },});console.log(proxy.first); // 读取了 firstconsole.log(proxy.fullName); // 读取了 fullName → 还会再读 first、last
如果你在 get 陷阱里用的是 return target[key],那么当访问 proxy.fullName 时,getter 内部的 this 会指向原始对象 person,于是 this.first、this.last 的读取会"绕过"代理,直接打在 person 上——你的日志对这两个读取完全失效,拦截出现了一个漏洞。而把 receiver(也就是代理本身)透传给 Reflect.get 之后,getter 内部的 this 就指向了代理,first 和 last 的读取也会再次经过陷阱,你的拦截就没有死角了。对于响应式系统来说,这个"漏洞"意味着一个依赖没被收集到,是会导致视图不更新的真实 bug。
第三,Reflect 的返回值更规范。Reflect.set 和 Reflect.deleteProperty 返回的是布尔值,表示操作成功还是失败;而老的 Object.defineProperty 风格是"失败就抛异常"。set 和 deleteProperty 这两个陷阱本身就被要求返回一个布尔值(在严格模式或模块里,返回 false 会抛 TypeError),用 Reflect 转发就直接拿到了正确的返回值,不用自己手写 return true 去"撒谎"。所以,几乎每个陷阱的标准形状都是:先做你自己的事,再用对应的 Reflect 方法把默认行为转发回去。
四、实战一:手撕一个迷你版 Vue 3 响应式
讲完原理,我们直接上最经典的实战——响应式系统,这也是 Vue 3 响应式原理的核心。它的思想可以用一句话概括:在读取属性时用 get 陷阱"记录谁依赖了这个属性"(依赖收集,track),在写入属性时用 set 陷阱"通知所有依赖这个属性的地方重新执行"(触发更新,trigger)。
我们先搭一个最小的依赖收集与触发机制。注意这里用 WeakMap 做存储,是为了让对象能被垃圾回收,不至于造成内存泄漏:
javascript// 当前正在运行的副作用函数(渲染、计算等会"读数据"的逻辑)let activeEffect = null;// 依赖存储结构:对象 → 属性名 → 依赖该属性的副作用集合// 用 WeakMap 是为了让对象能被垃圾回收,不造成内存泄漏const depsMap = new WeakMap();// 依赖收集:在 get 陷阱里调用,把当前副作用登记为某属性的依赖function track(target, key) { if (!activeEffect) return; // 没有正在运行的副作用就不收集 let keysMap = depsMap.get(target); if (!keysMap) depsMap.set(target, (keysMap = new Map())); let dep = keysMap.get(key); if (!dep) keysMap.set(key, (dep = new Set())); dep.add(activeEffect); // 把当前副作用登记为该属性的依赖}// 触发更新:在 set 陷阱里调用,让所有依赖者重新执行function trigger(target, key) { const dep = depsMap.get(target)?.get(key); dep?.forEach((effect) => effect()); // 让所有依赖该属性的副作用重跑}
有了 track 和 trigger 这两个"账房先生",reactive 函数就水到渠成了:
javascript// 把一个普通对象变成响应式对象function reactive(obj) { return new Proxy(obj, { get(target, key, receiver) { // 1. 读取时收集依赖:谁读了我,我就记谁 track(target, key); // 2. 用 Reflect 忠实转发读取 const value = Reflect.get(target, key, receiver); // 3. 如果读取到的是对象,递归地让它也变响应式(惰性代理,见后文) if (value && typeof value === 'object') { return reactive(value); } return value; }, set(target, key, value, receiver) { // 1. 用 Reflect 忠实转发写入 const success = Reflect.set(target, key, value, receiver); // 2. 写入后触发依赖更新,让所有订阅者重跑 trigger(target, key); return success; }, });}
再来看怎么用。我们模拟一个"渲染副作用",当它读取 count 时,count 就把这个副作用登记为自己的依赖;之后只要 count 一变,副作用就自动重跑:
javascript// 一个极简的"渲染函数",读取 state.count 并输出到页面function render() { document.body.innerHTML = `当前计数:${state.count}`;}// 把渲染包成副作用:运行前把自己设为 activeEffect,运行后清空function watchEffect(fn) { const effect = () => { activeEffect = effect; fn(); activeEffect = null; }; effect(); // 立即跑一次,触发首屏渲染 + 依赖收集}const state = reactive({ count: 0 });watchEffect(render); // 首屏输出:当前计数:0state.count = 1; // 触发 trigger,render 重跑,页面变成:当前计数:1
整个响应式引擎,其实就藏在 get 和 set 这两个陷阱里。track 和 trigger 只是记账的,真正的"魔法开关"是 Proxy。这也是 CSS 变量(Custom Properties)实战:一行 var() 搞定全局主题切换这类"数据驱动视图"方案之外,前端实现界面自动更新的另一条更底层的路径——框架不需要你手动声明依赖,读数据的时候它自己就记下来了。
4.1 为什么 Vue 3 用 Proxy,而 Vue 2 用 Object.defineProperty
这是个高频面试题,答案就藏在 Proxy 的能力边界里。Object.defineProperty 只能对"已经存在的属性"逐一做劫持,所以 Vue 2 在初始化时要遍历对象,递归地把每个属性都 defineProperty 一遍,性能开销大;更致命的是,它拦截不了"新增属性"和"删除属性",所以 Vue 2 才需要 Vue.set / Vue.delete 这种额外 API。而 Proxy 拦截的是"整个对象",get、set、has、deleteProperty 一个不落,新增属性天然能被拦截,于是 Vue 3 直接干掉了 Vue.set。此外 Proxy 还能代理 Map、Set、数组索引等复杂场景,覆盖面比 Object.defineProperty 广得多。
4.2 递归代理:嵌套对象怎么变响应式
回到 reactive 函数 get 陷阱里那句 if (value && typeof value === 'object') return reactive(value)。这行代码解决的是"嵌套对象不响应"的问题:当你读取 user.info 时,拿到的 info 也是一个对象,如果直接返回它,那 info 内部的属性读写就不再经过代理了。所以我们要在"读取到对象那一刻"就地再包一层 reactive,并且通常用 WeakMap 缓存一层,避免同一个对象被反复包装、也防止循环引用导致死递归。这种"访问时才代理"的策略叫惰性代理,比 Vue 2 那种"初始化就递归到底"的方式性能好很多——不需要响应的深层数据,根本不会被提前代理。
五、实战二:把 Proxy 用到业务里(校验 / 日志 / Mock)
响应式是框架级的用法,但 Proxy 在普通业务代码里同样好使。这里再给三个即拿即用的小例子,它们都遵循同一条铁律:拦截 → 做事 → 用 Reflect 转发。
5.1 写入校验:给对象加一层"守门员"
用 set 陷阱,可以在赋值阶段就拦下非法数据,而不是等出了 bug 再去排查:
javascript// 一个只接受合法年龄的用户对象代理function createValidator(target) { return new Proxy(target, { set(obj, key, value, receiver) { // 对 age 字段做整数 + 范围校验 if (key === 'age') { if (!Number.isInteger(value) || value < 0 || value > 150) { throw new Error(`年龄 ${value} 不合法,必须是 0~150 的整数`); } } // 校验通过才真正写入,否则数据还没被污染就被拦下了 return Reflect.set(obj, key, value, receiver); }, });}const user = createValidator({ name: '前端哥', age: 18 });user.age = 200; // 直接抛错,非法数据进不来
5.2 访问审计日志:谁动了我的状态
调试大型应用时,最头疼的就是"这个字段到底被谁改了"。给对象套一层审计代理,每一次读、写、删都留下日志,排查"状态被谁改了"这种 bug 时能少猜很多次:
javascript// 给任意对象加一层审计日志,所有读写删除都留痕function createAudit(target, label) { return new Proxy(target, { get(target, key, receiver) { console.log(`[审计:${label}] 读取 ${String(key)}`); return Reflect.get(target, key, receiver); }, set(target, key, value, receiver) { console.log(`[审计:${label}] 写入 ${String(key)} = ${value}`); return Reflect.set(target, key, value, receiver); }, deleteProperty(target, key) { console.log(`[审计:${label}] 删除 ${String(key)}`); return Reflect.deleteProperty(target, key); }, });}const account = createAudit({ balance: 100 }, '账户');account.balance = 200; // [审计:账户] 写入 balance = 200delete account.balance; // [审计:账户] 删除 balance
5.3 接口 Mock:不改动业务代码拦截请求
通过把 fetch 本身包成一个 Proxy(fetch 是函数,所以用 apply 陷阱),可以在底层直接返回假数据,业务代码一行都不用改:
javascript// 极简接口 Mock:命中规则就返回假数据,否则走真实请求const mockRules = new Map();mockRules.set('/api/user', { id: 1, name: '前端哥' });const mockFetch = new Proxy(fetch, { apply(target, thisArg, args) { const [url] = args; const mockData = mockRules.get(String(url)); if (mockData) { // 返回一段伪造的 Response,对上层完全透明 return Promise.resolve( new Response(JSON.stringify(mockData), { status: 200 }) ); } // 没命中规则就转发真实请求 return Reflect.apply(target, thisArg, args); },});// 业务代码里照常写 fetch('/api/user'),拿到的是假数据
这种"无侵入"的拦截思路,和Web Components 实战:用原生 API 封装可复用自定义元素(Shadow DOM 与插槽全解析)里"封装行为而不污染全局"的理念一脉相承——都是在底层把能力缝进去,对上层保持透明。
5.4 可撤销代理 Proxy.revocable:临时授权场景
Proxy 还提供了一个很贴心的静态方法 revocable,它能创建一个"随时可以被吊销"的代理。一旦调用 revoke,之后对这个代理的任何访问都会直接抛 TypeError,非常适合"临时把对象借给别人用一下"的场景,比如插件沙箱、限时令牌:
javascript// 创建可撤销代理,拿到代理本身和一个吊销函数const { proxy, revoke } = Proxy.revocable(target, handler);proxy.name; // 吊销前正常访问revoke(); // 吊销!此后任何访问都会抛 TypeErrorproxy.name; // TypeError: Cannot perform 'get' on a proxy that has been revoked
六、两个必须注意的坑
Proxy 虽强,但有两个坑几乎是每个初学者都会踩,这里专门拎出来讲清楚。
坑一:this 指向(前文已铺垫)
再次强调:在 get / set 陷阱里转发时,务必把 receiver 透传给 Reflect。否则目标对象里 getter / 方法内部的 this 会指向原始对象,导致嵌套读取绕过代理、依赖收集漏掉、日志漏记。这是写 Proxy 最常见的隐蔽 bug,而且它通常不报错,只是"悄悄少记了一条依赖",表现就是"数据明明变了,视图却不更新",极难排查。
坑二:递归代理与循环引用
如果你在 get 里对"读取到的每个对象"都 new Proxy,要注意两点。第一,用 WeakMap 缓存已代理的对象,避免重复包装和内存膨胀;第二,遇到对象内部互相引用(比如 A.b = B,B.a = A)时要能识别"已经代理过的对象"直接返回,否则会陷入无限递归直至栈溢出。Vue 3 的 reactive 内部就是用一张 reactiveMap(WeakMap)缓存来解决这个问题的:同一个对象第二次被读取时,直接返回已经包好的代理,而不是再 new 一个。
还有一个边界情况值得记住:Proxy 不能对"一切"撒谎。如果目标对象上某个属性是"不可写且不可配置"的(比如用 Object.freeze 冻结的对象),get 陷阱必须返回它的真实值,否则引擎会直接抛 TypeError。这些规则叫 Proxy 不变量(invariants),是引擎为了保证"冻结对象在代理后面依然可信"而强制执行的,写陷阱时绝不能违反,否则代码会在运行期硬报错。
七、小结:什么时候该用 Proxy
Proxy 和 Reflect 是一对配套工具:Proxy 负责拦截,Reflect 负责把默认行为干净地转发回去。理解它们的核心价值,不在于背下 13 个陷阱的名字,而在于建立一种判断:当你的需求是"对对象操作做细粒度控制"时,就该想到 Proxy——
- 需要校验、日志、权限检查、默认值时;
- 需要让数据变化触发其他动作(响应式、状态同步)时;
- 需要不侵入业务代码地扩展功能(接口 Mock、性能监控)时。
写数据模型时不妨先问自己一句:这个对象的行为需要被追踪或控制吗?如果是,给它加一层 Proxy。一行代码的拦截器,未来能省下很多到处打补丁的麻烦。
顺带一提,当数据变化需要驱动界面动画时,除了用响应式重渲染,也可以结合Canvas 2D 绘图入门:从画矩形到 requestAnimationFrame 动画 在 requestAnimationFrame 里读取响应式状态直接重绘;而当需要根据"元素是否进入视口"来触发响应式更新时,Intersection Observer API 实战:高性能滚动触发动画与懒加载又能和这套机制配合,做到只渲染用户看得到的那一部分,把性能开销压到最低。
在你准备把这些技巧用进项目之前,建议先用一个小对象把 get / set / Reflect 三件套跑通,确认理解了 receiver 透传的链路,再去碰递归代理和依赖收集——基础心智模型对了,上面那层响应式、Mock、校验的花活儿,都会变得自然而然。把这套心智模型建立起来之后,你会发现很多原本需要写大量样板代码才能实现的横切逻辑——校验、日志、缓存、权限——都能收敛成一层薄薄的代理,这正是元编程最迷人的地方。

发表评论发布评论后审核成功,即可显示在列表中...