ECMAScript代理与反射

Proxy 与 Reflect 的分工、常见 trap、使用边界、与 Object.defineProperty 的本质区别,以及 Vue 3 响应式如何利用 Proxy。

#type / concept #status / growing #resource / javascript #resource / ecmascript

[!info] related notes

ECMAScript代理与反射

Proxy 解决的是”拦截对象基本操作”,Reflect 解决的是”用函数形式执行对应的默认行为”。这两个 API 通常要放在一起理解。

基本定位

ProxyES6(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

为什么 ProxyReflect 经常一起出现

你会经常看到:

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. 能代理更多行为

不仅是属性读写,还包括:

  • in
  • delete
  • Object.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 只是利用它来拦截对象的读写、增删和遍历等操作,从而实现更强大的响应式系统。

延伸阅读

创建于 2025/1/1 更新于 2026/5/27