Skip to content

03|Proxy 与 Reflect:拦截操作,不改写对象定义 ​

第三章:响应式系统纵览 / 前置:依赖追踪 / 下一篇:computed

Vue 3 的 reactive() 返回 Proxy。它能观察“读属性、写属性、判断键是否存在、枚举、删除”等操作,所以新增字段和数组索引不再需要 Vue 2 的补丁 API。Reflect 负责把操作按 JavaScript 默认语义转发给原对象。

运行上下文 ​

本篇先用原生 JavaScript 建一个记录器,再与 Vue 3 API 对照。代码可在现代浏览器控制台或 Node 20+ 运行;Proxy 无法为 IE 提供兼容降级。

一个保留默认语义的代理 ​

js
const operations = []
const raw = { count: 0 }

const observed = new Proxy(raw, {
  get(target, key, receiver) {
    operations.push(`get:${String(key)}`)
    return Reflect.get(target, key, receiver)
  },
  set(target, key, value, receiver) {
    const oldValue = Reflect.get(target, key, receiver)
    const ok = Reflect.set(target, key, value, receiver)
    if (ok && !Object.is(oldValue, value)) {
      operations.push(`set:${String(key)}`)
    }
    return ok
  },
  deleteProperty(target, key) {
    const existed = Reflect.has(target, key)
    const ok = Reflect.deleteProperty(target, key)
    if (ok && existed) operations.push(`delete:${String(key)}`)
    return ok
  }
})

observed.count
observed.email = 'ada@example.com'
delete observed.count

用 Reflect.get(target, key, receiver) 而不是直接 target[key],可以把原访问的接收者继续传递给 getter;用 Reflect.set 的布尔返回值,可以保留严格模式下赋值成功/失败的协议。Proxy handler 还必须遵守语言不变量,例如不能假装删除一个不可配置属性。

receiver为什么决定getter内部能否继续被追踪 ​

把以下原生JavaScript保存为 .mjs:

js
import assert from 'node:assert/strict'
function observe(preserveReceiver) {
  const reads = []
  const raw = { count: 2, get doubled() { return this.count * 2 } }
  const proxy = new Proxy(raw, {
    get(target, key, receiver) {
      reads.push(key)
      return preserveReceiver ? Reflect.get(target, key, receiver) : target[key]
    }
  })
  assert.equal(proxy.doubled, 4)
  return reads
}
assert.deepEqual(observe(false), ['doubled'])
assert.deepEqual(observe(true), ['doubled', 'count'])

两个结果都是4,但因果路径不同。target[key]用raw作为getter的this,内部 this.count 绕开代理;传receiver时this仍是proxy,内部读取再进入get trap。若trap承担track,第一种实现就漏掉count依赖。只断言最终数字会漏掉这个错误,因此实验同时断言访问轨迹。Reflect保留语义,依赖登记仍是代理处理器自己的责任。

操作与依赖 key ​

用户代码典型 Proxy trap响应式含义
state.totalgettrack total
'total' in statehastrack 键存在性
Object.keys(state)ownKeystrack 迭代依赖
state.total = 2settrigger 更新或新增
delete state.totaldeletePropertytrigger 删除与迭代

因此 Object.keys(state).length 依赖的不是某一个已知值,而是“有哪些键”。新增字段时,它应该更新;只改已有字段的值时,键集合没有变化。

为什么 Map / Set 不是普通 get/set ​

Map.prototype.get、set、size 与迭代器依赖集合内部槽位。简单地从 Proxy 上取出原生方法再调用,接收者会不符合原生集合要求。Vue 为集合方法提供专门 instrumentation:

  • map.get(key) 追踪该 key;
  • map.set(key, value) 区分新增与修改;
  • map.size、keys()、values()、forEach() 追踪不同的迭代关系;
  • set.add(value) 对重复值不应制造一次虚假变化。

第 9 篇会用触发计数验证这些差异。本篇先记住:Proxy 提供拦截入口,集合语义仍需要框架逐个适配。

raw 与 proxy:两个身份,一份底层数据 ​

js
import { reactive, toRaw } from 'vue'

const rawUser = { id: 1 }
const user = reactive(rawUser)

console.log(user === rawUser)           // false
console.log(toRaw(user) === rawUser)    // true
console.log(reactive(rawUser) === user) // true:Vue 复用代理缓存

toRaw() 适合一次性的身份归一化、调试或与必须接收原对象的 API 交互。长期保存 raw 并修改它会绕开代理,依赖者看不到 trigger。

身份陷阱常出现在应用自己的非响应式集合:

js
const selected = new Set([rawUser])
selected.has(user)              // false
selected.has(toRaw(user))       // true

解决方案不是到处调用 toRaw(),而是在边界统一 key:优先使用稳定的标量 ID;确实按对象身份索引时,规定只存 raw 或只存 proxy。

source → derived → effect 中 Proxy 的位置 ​

text
source: raw object
   ↓ reactive() 建立稳定 proxy
读取 proxy.key ──track──> derived / render effect
写入 proxy.key ─trigger─> scheduler ──> effect 重新运行

Proxy 不保存 derived,也不决定 watcher 何时运行。它只把 JavaScript 操作翻译成 track / trigger 信号;缓存属于 computed,批处理属于调度器。

排错清单 ​

  1. toRaw(value) === value 吗?若为真,它可能根本不是代理。
  2. 写入是否经过 proxy?raw.count++ 与 proxy.count++ 的可见数据一样,触发行为不同。
  3. 身份判断是在比较业务 ID,还是无意比较 raw/proxy 引用?
  4. 问题来自对象属性还是 Map/Set 方法与迭代?不要用对象 trap 猜集合行为。
  5. 若维护 Vue 2,新增字段和数组索引属于旧观察模型的限制,不要套用 Vue 3 结论。

练习:修复代理身份缓存 ​

有一个第三方缓存 Map<object, string>,它只接受原始用户对象。调用方有时传 raw,有时传 reactive(raw),导致同一用户出现两条缓存记录。

  • 实现 createIdentityCache(),在 get / set 边界用 toRaw() 归一化对象。
  • 断言 raw 写入后 proxy 能读取,proxy 覆盖后缓存大小仍为 1。
  • 增加两个不同但字段相同的对象,断言它们仍是两个 key;不能错误地退化成 JSON 字符串比较。
  • 说明若业务有稳定 user.id,为什么按标量 ID 建缓存通常更安全。

查看第 03 题参考答案。

用心记录每一步成长