ECMAScript代理与反射
Proxy 与 Reflect 的分工、常见 trap、使用边界、与 Object.defineProperty 的本质区别,以及 Vue 3 响应式如何利用 Proxy。
[!info] related notes
- 所属 MOC: ecmascript-moc, ecmascript-object-oriented, ES6 新特性 MOC
- 相邻主题: js对象, ecmascript-prototypes, ecmascript-functions
- 相关边界: ecmascript-memory-management
- Vue 3 应用: Vue 响应式系统, 手写 Vue reactive 和 watchEffect 的最小实现
- Vue 2 对比: vue3
ECMAScript代理与反射
Proxy 解决的是”拦截对象基本操作”,Reflect 解决的是”用函数形式执行对应的默认行为”。这两个 API 通常要放在一起理解。
基本定位
Proxy 是 ES6(ES2015) 正式加入 JavaScript 的通用元编程能力,不是为某个框架专门设计的。
它的核心直觉是:
在对象外面套一层”代理壳”,以后别人对这个对象做的很多操作,你都可以拦截、改写、增强。
Proxy 的设计目标很广,不只是响应式,还可以用来做:
- 数据校验
- 权限控制
- 日志埋点
- 默认值处理
- 虚拟属性
- 函数调用拦截
- 类/对象的行为定制
Vue 只是后来很好地利用了它。
语法
const proxy = new Proxy(target, handler)
target:你要代理的原对象handler:拦截规则对象
最短理解
Proxy像一层包装,可以拦截读取、赋值、删除、函数调用等操作Reflect提供与这些底层操作相对应的方法- 最常见写法是在 trap 里做额外逻辑,然后把默认行为交给
Reflect
一个直观例子
const obj = { name: 'Tom' }
const p = new Proxy(obj, {
get(target, key) {
console.log('有人读取了', key)
return target[key]
},
set(target, key, value) {
console.log('有人修改了', key, '=>', value)
target[key] = value
return true
}
})
console.log(p.name)
p.name = 'Jack'
输出:
有人读取了 name
Tom
有人修改了 name => Jack
为什么 Proxy 和 Reflect 经常一起出现
你会经常看到:
new Proxy(target, {
get(target, key, receiver) {
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
return Reflect.set(target, key, value, receiver)
}
})
这是因为:
Proxy负责”拦截”Reflect负责”按默认语义执行原本操作”
也就是:
- 你先拦下来
- 想放行时,再通过
Reflect去执行标准行为
如果只用 Proxy,你很容易把逻辑写成”完全接管”。但大多数真实场景只需要:
- 校验输入
- 打日志
- 做访问控制
- 做只读视图或虚拟字段
这时更稳妥的写法通常是:先拦截,再用 Reflect 继续执行原本操作。这样写更规范,也更接近 JS 内部语义。
常见 trap
1. get:读取属性
const p = new Proxy(obj, {
get(target, key, receiver) {
return Reflect.get(target, key, receiver)
}
})
比如 p.name 会触发 get。
2. set:设置属性
const p = new Proxy(obj, {
set(target, key, value, receiver) {
return Reflect.set(target, key, value, receiver)
}
})
比如 p.name = 'Jack' 会触发 set。
3. deleteProperty:删除属性
const p = new Proxy(obj, {
deleteProperty(target, key) {
console.log('删除属性', key)
return Reflect.deleteProperty(target, key)
}
})
比如 delete p.name。
4. has:拦截 in
const p = new Proxy(obj, {
has(target, key) {
console.log('检查属性是否存在', key)
return Reflect.has(target, key)
}
})
比如 'name' in p。
5. ownKeys:拦截遍历键名
const p = new Proxy(obj, {
ownKeys(target) {
console.log('获取所有 key')
return Reflect.ownKeys(target)
}
})
比如 Object.keys(p) 或 for (const key in p)。
6. apply:代理函数调用
Proxy 不只能代理对象,也能代理函数。
function sum(a, b) {
return a + b
}
const p = new Proxy(sum, {
apply(target, thisArg, args) {
console.log('函数被调用了', args)
return Reflect.apply(target, thisArg, args)
}
})
p(1, 2)
7. construct:代理 new
function Person(name) {
this.name = name
}
const P = new Proxy(Person, {
construct(target, args, newTarget) {
console.log('new 调用了', args)
return Reflect.construct(target, args, newTarget)
}
})
new P('Tom')
一个实用例子:保护私有属性
比如你想禁止用户访问以下划线开头的私有属性:
const user = {
name: 'Tom',
_password: '123456'
}
const p = new Proxy(user, {
get(target, key, receiver) {
if (String(key).startsWith('_')) {
throw new Error('无权访问私有属性')
}
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
if (String(key).startsWith('_')) {
throw new Error('无权修改私有属性')
}
return Reflect.set(target, key, value, receiver)
}
})
console.log(p.name) // Tom
console.log(p._password) // 报错
这说明 Proxy 本质上就是”给对象操作加一层控制”。
和 Object.defineProperty 的本质区别
这个点很重要,因为它正好和 Vue 2 / Vue 3 的区别对应上。
Object.defineProperty
它是”改造某个已有属性”。
Object.defineProperty(obj, 'name', {
get() {},
set() {}
})
它拦截的是 obj.name 的读取和 obj.name = xxx 的赋值。
但它的问题是:
- 只能针对具体属性
- 新增属性不方便
- 删除属性也不方便
- 数组支持不自然
Proxy
它是”代理整个对象”。
new Proxy(obj, {
get() {},
set() {},
deleteProperty() {}
})
它拦截的是对象级别的操作,所以能力更强。
Proxy 的优点
1. 代理整个对象,能力更强
不需要像 defineProperty 那样一个个属性处理。
2. 能监听新增/删除属性
p.newKey = 1
delete p.oldKey
这些都能拦截。
3. 数组支持更自然
arr[0] = 100
arr.length = 0
这些操作也能进入代理逻辑。
4. 能代理更多行为
不仅是属性读写,还包括:
indeleteObject.keys- 函数调用
new
Proxy 的缺点或注意点
1. 兼容性比老特性差
现代环境基本没问题,但老环境尤其 IE 不支持。
2. 代理的是”外层壳”
const obj = { a: 1 }
const p = new Proxy(obj, handler)
你操作 p.a 会被拦截。但如果你直接操作原对象 obj.a,就不会走代理。
obj.a = 2
这不会触发 p 的 handler。
所以使用响应式对象时,一般都要统一操作代理对象,而不是原对象。
3. 深层对象通常要递归代理
Proxy 代理的是当前对象,不会自动把所有嵌套对象都变成响应式。所以 Vue 3 会在 get 的时候继续递归包装子对象。
为什么 Proxy 不能被完美 polyfill
因为它不是简单加几个方法就行,它改的是 JS 语言运行时对对象操作的底层行为。
比如:
obj.x
'x' in obj
Object.keys(obj)
delete obj.x
new fn()
fn()
这些操作是语法层面的,不能靠普通函数补丁完整模拟。
所以老浏览器如果原生不支持 Proxy,你没法像补 Promise 一样完整补出来。这也是 Vue 3 不支持 IE 的重要原因之一。
Vue 3 为什么用 Proxy 特别合适
因为 Vue 响应式本质就是要知道:
- 谁在读数据
- 谁在写数据
- 有没有新增属性
- 有没有删除属性
- 数组有没有变化
- 遍历有没有变化
这些都能通过 Proxy 比较自然地做。
比如:
const state = reactive({ count: 0 })
当你写 state.count 触发 get,Vue 就去 track() 收集依赖。
当你写 state.count++ 触发 set,Vue 就去 trigger() 触发更新。
为什么 Vue 2 不用 Proxy
主要是兼容性历史原因。
Vue 2 诞生时,前端环境比现在更老,Proxy 的兼容性不如今天。而且 Proxy 没法被完整 polyfill,所以当时 Vue 2 只能用兼容性更现实的 Object.defineProperty。
所以不是 Vue 团队”不知道 Proxy 好”,而是那个年代现实条件不允许全面用。
什么时候值得用
- 表单或配置对象的校验
- 响应式系统的依赖收集
- 权限控制或只读包装
- 需要虚拟属性或懒加载视图时
什么时候别急着用
- 只是想改个对象字段时
- 普通函数或类方法就能表达清楚时
- 团队里大多数人不熟悉 Proxy 语义时
最容易踩坑的边界
- 代理对象和目标对象不是同一个身份,
target === proxy永远是false - 不是所有行为都能随意伪造,Proxy 需要遵守对象不变量(invariants)
- 过度代理会让代码调试和性能分析变难
- 代理能拦截很多语言层操作,但不该替代清晰的数据建模
一句话总结
Proxy是 ES6 提供的对象代理机制,不是为 Vue 专门设计的;Vue 3 只是利用它来拦截对象的读写、增删和遍历等操作,从而实现更强大的响应式系统。
延伸阅读
- 看对象模型关系:ecmascript-object-oriented, ecmascript-prototypes
- 看函数调用与对象行为:ecmascript-functions, js对象
- Vue 3 响应式实现:Vue 响应式系统
- 手写最小实现:手写 Vue reactive 和 watchEffect 的最小实现