Vue 2 到 Vue 3 最大的一次底层重写,就是把响应式系统从 Object.defineProperty 换成了 Proxy。这次替换解决的问题非常具体:Vue 2 里给对象新增属性不会触发更新,得靠 $set;数组直接改下标也不生效,得靠重写七个数组方法。这些补丁的根源都在于 defineProperty 只能劫持已经存在的属性,它拦的是「某个 key 的读写」,而不是「这个对象上的所有操作」。
Proxy 换了个思路。它不改动目标对象本身,而是在外面套一层壳,所有进出的操作都要先过这层壳。这篇把 13 个 trap 的分工列清楚,然后用六个实际场景展示它能干什么,最后聊聊它和 defineProperty 的取舍。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- Proxy 的基本结构,target 和 handler 各管什么
- 13 个 trap 分别在什么操作时被触发
- 用 Proxy 实现真正的私有变量(两种拦截思路)
- 把校验逻辑从业务类里抽离出来
- 访问日志、废弃预警、请求去重三个中间件式的用法
Proxy.revocable撤销代理,以及它适合的场景- Proxy 相比
Object.defineProperty的优势和代价
一、Proxy 概述
先看看它的兼容性,这张图是 2018 年前后的情况。

图里能看到 IE 全线飘红,这也是 Vue 3 直接放弃 IE11 的原因之一。Proxy 的行为无法被 polyfill,因为它拦截的是语言层面的基础操作,用 JS 自己实现不出来。这一点和大多数新特性不一样,别的都能靠转译或垫片兼容,Proxy 只能靠运行时原生支持。
proxy 在目标对象的外层搭建了一层拦截,外界对目标对象的某些操作,必须通过这层拦截。
var proxy = new Proxy(target, handler); |
new Proxy() 表示生成一个 Proxy 实例,target 参数表示所要拦截的目标对象,handler 参数也是一个对象,用来定制拦截行为。
var target = { |
读取 targetWithLog 的属性值时,实际上执行的是 logHandler.get,在控制台输出信息,并且读取被代理对象 target 的属性。设置属性值时执行的是 logHandler.set,输出信息之后再去设置 target 上的值。
原文的 set 里没有 return true,我补上了。这不是可有可无的细节:严格模式下 set trap 返回假值会抛 TypeError: 'set' on proxy: trap returned falsish。ES 模块默认就是严格模式,所以这个坑现在几乎必踩。
最后一行 console.log(target.name) 输出 others,说明改动落到了原对象上。代理不是复制,target 和 proxy 操作的是同一份数据。
拦截函数可以完全不理会 target:
// 由于拦截函数总是返回 35,所以访问任何属性都得到 35 |
Proxy 实例也可以作为其他对象的原型对象:
var proxy = new Proxy({}, { |
proxy 对象是 obj 对象的原型,obj 对象本身并没有 time 属性,所以根据原型链会在 proxy 对象上读取该属性,导致被拦截。
这里有个很容易踩的边界:必须通过 proxy 访问才会触发拦截。上面那个例子里如果你直接 target.time,什么都不会发生。所以用 Proxy 做响应式的时候,一定要保证外部拿到的是 proxy 而不是原对象,一旦某个地方漏出了 target 的引用,那条路径上的改动就全部丢失了。
对于代理模式,Proxy 的作用主要体现在三个方面:
- 拦截和监视外部对对象的访问
- 降低函数或类的复杂度
- 在复杂操作前对操作进行校验,或者对所需资源进行管理
二、Proxy 所能代理的范围
handler 本身是 ES6 新设计的一个对象,作用是自定义代理对象的各种可代理操作。它一共有 13 种方法,每种方法都可以代理一种操作。
原文这里写的是「13 中方法」,是个错别字,应为「13 种」。另外原文的 andler.defineProperty() 少了个 h,一起改了。
// 在读取代理对象的原型时触发,比如执行 Object.getPrototypeOf(proxy) 时 |
十三个看着多,实际按用途分成四组就好记了。
日常最常用的是属性读写组:get、set、has、deleteProperty、ownKeys。响应式框架、私有变量、访问控制基本都在这五个里打转。
其次是描述符组:defineProperty、getOwnPropertyDescriptor。它们负责属性元信息,多数时候你不主动写它们,但 set 内部可能会间接触发(Reflect 那篇会展开)。
再往下是扩展性组:isExtensible、preventExtensions、getPrototypeOf、setPrototypeOf,属于比较冷门的语言机制。
最后是函数组:apply 和 construct,只有在被代理对象本身是函数或类的时候才会用上,做函数埋点、参数校验、单例工厂都靠它们。
有个约束要记住:trap 不能想返什么就返什么。比如 target 上有一个 configurable: false, writable: false 的属性,get trap 就必须返回和原值相同的东西,否则引擎直接抛 TypeError。这些叫不变量(invariants),是标准为了保证语言基本假设不被破坏定的规矩,写复杂 handler 时被它拦住是很常见的事。
三、Proxy 的六个实战场景
3.1 实现私有变量
约定用下划线开头表示私有,这个惯例大家都懂,但它没有任何强制力。
var target = { |
原文这段的示例有点乱:注释写着「私有变量 age 不能被访问」,但访问的是 name,实际根本不会触发那个分支。上面改成访问 _age 才对得上注释,同时把 get 里的 return false 改成了 return undefined(读一个禁止访问的属性返回 false 语义很怪),并给 set 的成功分支补上 return true。
再看一个更贴近真实的例子。下面的代码声明了一个私有的 apiKey,便于 api 这个对象内部的方法调用,但不希望从外部也能够访问 api._apiKey:
var api = { |
约定俗成是没有束缚力的。使用 ES6 Proxy 我们就可以实现真实的私有变量了。第一种方法是使用 set / get 拦截读写请求并抛错:
let api = { |
原文的 set 里写的是 return Reflect.get(target, key, value, proxy),用了 get 而不是 set,参数个数也对不上,这是个真实的 bug,上面已经改成 Reflect.set。
这里顺便说一下为什么 handler 里到处是 Reflect。Reflect.get(target, key, proxy) 和 target[key] 在简单场景下结果一样,但涉及 getter 里的 this 指向、原型链继承的时候就不一样了,而且 Reflect 的每个方法都和 trap 一一对应,返回值也刚好符合 trap 的要求。这套配合关系我单独写在 ES6系列之 Reflect 的设计意图与 API 全览 里。
第二种方法是使用 has 拦截 in 操作:
var api = { |
两种思路的区别在于藏的层次。get/set 是拦住读写但存在性还在,in 依然返回 true;has 是让它在存在性检查里消失,但直接 api._apiKey 还是能读到。真要藏严实,两个 trap 加上 ownKeys 和 deleteProperty 一起写才完整。
不过说句实在话,2018 年之后这套写法的必要性降低了很多。类的 #privateField 是语法层面的私有,编译期就能查出越界访问,比 Proxy 干净得多。Proxy 版私有变量现在更适合的场景是「你没法改目标对象的定义,只能在外面套一层」,比如包装第三方库返回的对象。
3.2 抽离校验模块
先从一个简单的类型校验开始,这个示例演示了如何使用 Proxy 保障数据类型的准确性:
let numericDataStore = { |
如果要直接为对象的所有属性开发一个校验器,代码结构很快就会变得臃肿。使用 Proxy 则可以将校验器从核心逻辑分离出来自成一体:
function createValidator(target, validator) { |
原文的 personValidators.age 里写的是 typeof age === 'number' && age > 18,用了外层不存在的 age 而不是形参 val,运行时直接 ReferenceError。这是个必修的 bug,上面已经改成 val。同时把 !!validator(value) 简化成 validator(value)(if 本来就做真值判断,双感叹号是多余的),把 target.hasOwnProperty(key) 换成了 Object.prototype.hasOwnProperty.call,避免 target 恰好有个同名属性时翻车。
handler 对象上挂 _validator 这个写法值得说一句。trap 是被当作 handler 的方法调用的,所以里面的 this 指向 handler 本身,this._validator 能取到。这个技巧可行但有点隐晦,我更倾向直接用闭包里的 validator 参数,可读性好很多。
通过校验器和主逻辑的分离,你可以无限扩展 personValidators 的内容,而不会对相关的类或函数造成直接破坏。构造函数里 return 一个 proxy 是这里的关键一招,new Person() 拿到的其实是代理对象,外部感知不到差别。
更复杂一点,我们还可以使用 Proxy 模拟类型检查,检查函数是否接收了类型和数量都正确的参数:
let obj = { |
原文的 argChecker 循环条件是 idx < args.length,但注释里写着「不传参数会报三条警告」。不传参 args.length 是 0,循环一次都不进,一条警告都不会有。要对上注释,循环应该按 checkers.length 走,上面改过来了。另外原文里 var checkArgs = argChecker(...) 接了个从不使用的返回值,一并去掉了。
这类参数检查现在基本被 TypeScript 接管了,编译期就报错,比运行时打 warning 有用得多。但在纯 JS 的库里给使用者一个友好提示,这套写法还是有价值的。
3.3 访问日志
对于那些调用频繁、运行缓慢或占用执行环境资源较多的属性或接口,开发者会希望记录它们的使用情况或性能表现。这时可以使用 Proxy 充当中间件的角色,实现日志功能:
let api = { |
logMethodAsync 用 setTimeout 把日志推到下一轮任务队列,这一步是有意为之,埋点本身不该拖慢业务代码的执行。
这个 get trap 有个缺陷要留意:它无条件返回一个函数,所以 api._apiKey 这种取普通值的操作也会拿到一个函数。稳妥的写法是先判断 typeof value === 'function',不是函数就原样返回。
3.4 预警和拦截
假设你不想让其他开发者删除 noDelete 属性,还想让调用 oldMethod 的开发者知道这个方法已经被废弃了,或者告诉开发者不要修改 doNotChange 属性,那么就可以使用 Proxy 来实现:
let dataStore = { |
这段的 get 就做了上一节说的类型判断,函数才包装,非函数直接返回。原文包装后的函数里少了 return,调用 dataStore.oldMethod() 永远拿到 undefined,我补上了。
这套写法在做库的平滑升级时特别好用。旧 API 不直接删,先套一层 Proxy 打 deprecation 警告,观察一两个版本没人用了再真正下线。比在文档里写一行「此 API 已废弃」有效得多,因为警告是直接打在使用者控制台上的。
3.5 过滤操作
某些操作会非常占用资源,比如传输大文件。如果文件已经在分块发送了,就不需要对新的请求作出响应。这个时候可以使用 Proxy 对请求进行特征检测,过滤出哪些是不需要响应的:
let obj = { |
这段是示意,checkEnroute、checkStatus、getCached 都没给实现。思路本身很值得记:请求去重加缓存这层逻辑跟业务方法本身无关,抽到 Proxy 里之后,业务方法可以保持干净。你在 SWR、React Query 这类数据请求库里能看到同样的分层思想,只不过它们用的是别的手段。
3.6 中断代理
Proxy 支持随时取消对 target 的代理,这一操作常用于完全封闭对数据或接口的访问。使用 Proxy.revocable 方法可以创建可撤销的代理对象:
let sensitiveData = { username: 'devbryce' }; |
原文这段有两个问题,都必须改。一是 Proxy.revocable 返回的对象固定是 { proxy, revoke } 两个键,原文解构成 {sensitiveData, revokeAccess} 拿到的是两个 undefined。二是原文用 const sensitiveData 重复声明了上一行已经用 let 声明过的同名变量,直接语法报错。上面按标准返回值改写了。
撤销之后,这个 proxy 上的任何操作都会抛 TypeError,不只是 get。原对象 sensitiveData 不受影响,还是好好的。所以 revocable 的典型用法是:把 proxy 交给不受信任的第三方代码,自己攥着 revoke,需要收回权限时一调了事,对方手上那个引用当场作废。
四、和 Object.defineProperty 的取舍
回到开头 Vue 的例子。Proxy 相比 Object.defineProperty 的优势可以归成三条。
拦截范围不同。 defineProperty 只能拦某个已存在属性的 get/set,Proxy 拦的是整个对象上的十三类操作,新增属性、删除属性、in 判断、Object.keys 全都能拦。Vue 2 那些 $set 补丁在 Proxy 下自然就不需要了。
不用递归遍历。 defineProperty 要在初始化时把对象的每个 key 都走一遍,嵌套对象还得递归下去,对象越大初始化越慢。Proxy 是访问到哪一层才代理哪一层,深层数据没人碰就一直不用处理,属于惰性的。
数组不用打补丁。 数组下标赋值和 length 变化在 Proxy 里就是普通的 set,直接就能拦到。Vue 2 得重写 push、pop、splice 那七个方法,还是拦不住 arr[0] = 1。
代价也很实在。一是兼容性,前面说了不能 polyfill,需要支持 IE 就直接出局。二是性能,单次属性访问 Proxy 是要慢一些的,因为多了一层间接调用;不过实际项目里省下的初始化开销通常更划算。三是理解成本,前面提到的 trap 不变量、this 指向、必须通过 proxy 访问才生效,这些坑写复杂 handler 时都会遇到。
我自己的感受是,业务代码里直接手写 Proxy 的机会真不多,它更多出现在框架和库的内部。但理解它对读框架源码帮助很大,Vue 3 的 reactive、MobX 5 之后的观察机制、Immer 的草稿对象,底层都是 Proxy。
总结
Proxy 干的事情是在对象外面套一层可编程的壳,十三个 trap 对应语言里十三类基础操作,你想改哪类就实现哪个 trap,不实现的自动走默认行为。
用起来记住几条:改动会落到原对象上,代理不是复制;只有通过 proxy 访问才触发拦截,漏出 target 引用就失效;严格模式下 set 和 deleteProperty 必须返回 true,返假值直接抛错;trap 的返回值受不变量约束,不能想返什么返什么。
场景上,属性读写组(get/set/has/deleteProperty/ownKeys)能覆盖九成需求,私有变量、校验抽离、访问日志、废弃预警都在这几个里打转。apply 和 construct 只在代理函数和类的时候用得上。Proxy.revocable 适合把引用交给不受信任的代码,随时能收回。
和 Object.defineProperty 比,Proxy 赢在拦截范围、惰性代理和数组支持,输在兼容性和单次访问性能。这也是 Vue 3 敢换、同时不得不放弃 IE11 的原因。