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 = {
name: 'poetries'
};
var logHandler = {
get: function(target, key) {
console.log(`${key} 被读取`);
return target[key];
},
set: function(target, key, value) {
console.log(`${key} 被设置为 ${value}`);
target[key] = value;
return true;
}
}
var targetWithLog = new Proxy(target, logHandler);
targetWithLog.name; // 控制台输出:name 被读取
targetWithLog.name = 'others'; // 控制台输出:name 被设置为 others
console.log(target.name); // 控制台输出: others
读取 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
var proxy = new Proxy({}, {
get: function(target, property) {
return 35;
}
});
proxy.time // 35
proxy.name // 35
proxy.title // 35
Proxy 实例也可以作为其他对象的原型对象:
var proxy = new Proxy({}, {
get: function(target, property) {
return 35;
}
});
let obj = Object.create(proxy);
obj.time // 35
proxy 对象是 obj 对象的原型,obj 对象本身并没有 time 属性,所以根据原型链会在 proxy 对象上读取该属性,导致被拦截。
这里有个很容易踩的边界:必须通过 proxy 访问才会触发拦截。上面那个例子里如果你直接 target.time,什么都不会发生。所以用 Proxy 做响应式的时候,一定要保证外部拿到的是 proxy 而不是原对象,一旦某个地方漏出了 target 的引用,那条路径上的改动就全部丢失了。
对于代理模式,Proxy 的作用主要体现在三个方面:
- 拦截和监视外部对对象的访问
- 降低函数或类的复杂度
- 在复杂操作前对操作进行校验,或者对所需资源进行管理
# 二、Proxy 所能代理的范围
handler 本身是 ES6 新设计的一个对象,作用是自定义代理对象的各种可代理操作。它一共有 13 种方法,每种方法都可以代理一种操作。
原文这里写的是「13 中方法」,是个错别字,应为「13 种」。另外原文的 andler.defineProperty() 少了个 h,一起改了。
// 在读取代理对象的原型时触发,比如执行 Object.getPrototypeOf(proxy) 时
handler.getPrototypeOf()
// 在设置代理对象的原型时触发,比如执行 Object.setPrototypeOf(proxy, null) 时
handler.setPrototypeOf()
// 在判断一个代理对象是否可扩展时触发,比如执行 Object.isExtensible(proxy) 时
handler.isExtensible()
// 在让一个代理对象不可扩展时触发,比如执行 Object.preventExtensions(proxy) 时
handler.preventExtensions()
// 在获取代理对象某个属性的属性描述时触发,比如执行 Object.getOwnPropertyDescriptor(proxy, "foo") 时
handler.getOwnPropertyDescriptor()
// 在定义代理对象某个属性的属性描述时触发,比如执行 Object.defineProperty(proxy, "foo", {}) 时
handler.defineProperty()
// 在判断代理对象是否拥有某个属性时触发,比如执行 "foo" in proxy 时
handler.has()
// 在读取代理对象的某个属性时触发,比如执行 proxy.foo 时
handler.get()
// 在给代理对象的某个属性赋值时触发,比如执行 proxy.foo = 1 时
handler.set()
// 在删除代理对象的某个属性时触发,比如执行 delete proxy.foo 时
handler.deleteProperty()
// 在获取代理对象的所有属性键时触发,比如执行 Object.getOwnPropertyNames(proxy) 时
handler.ownKeys()
// 在调用一个目标对象为函数的代理对象时触发,比如执行 proxy() 时
handler.apply()
// 在给一个目标对象为构造函数的代理对象构造实例时触发,比如执行 new proxy() 时
handler.construct()
十三个看着多,实际按用途分成四组就好记了。
日常最常用的是属性读写组: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 = {
name: 'poetries',
_age: 22
}
var logHandler = {
get: function(target, key){
if(key.startsWith('_')){
console.log('私有变量不能被访问')
return undefined
}
return target[key];
},
set: function(target, key, value) {
if(key.startsWith('_')){
console.log('私有变量不能被修改')
return false
}
target[key] = value;
return true
}
}
var targetWithLog = new Proxy(target, logHandler);
// 正常返回 'poetries'
targetWithLog.name;
// 打印「私有变量不能被访问」
targetWithLog._age;
// 打印「私有变量不能被修改」
targetWithLog._age = 30;
原文这段的示例有点乱:注释写着「私有变量 age 不能被访问」,但访问的是 name,实际根本不会触发那个分支。上面改成访问 _age 才对得上注释,同时把 get 里的 return false 改成了 return undefined(读一个禁止访问的属性返回 false 语义很怪),并给 set 的成功分支补上 return true。
再看一个更贴近真实的例子。下面的代码声明了一个私有的 apiKey,便于 api 这个对象内部的方法调用,但不希望从外部也能够访问 api._apiKey: