03|Proxy 与 Reflect:拦截操作,不改写对象定义
第三章:响应式系统纵览 / 前置:依赖追踪 / 下一篇:computed
Vue 3 的 reactive() 返回 Proxy。它能观察“读属性、写属性、判断键是否存在、枚举、删除”等操作,所以新增字段和数组索引不再需要 Vue 2 的补丁 API。Reflect 负责把操作按 JavaScript 默认语义转发给原对象。
运行上下文
本篇先用原生 JavaScript 建一个记录器,再与 Vue 3 API 对照。代码可在现代浏览器控制台或 Node 20+ 运行;Proxy 无法为 IE 提供兼容降级。
一个保留默认语义的代理
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:
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.total | get | track total |
'total' in state | has | track 键存在性 |
Object.keys(state) | ownKeys | track 迭代依赖 |
state.total = 2 | set | trigger 更新或新增 |
delete state.total | deleteProperty | trigger 删除与迭代 |
因此 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:两个身份,一份底层数据
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。
身份陷阱常出现在应用自己的非响应式集合:
const selected = new Set([rawUser])
selected.has(user) // false
selected.has(toRaw(user)) // true解决方案不是到处调用 toRaw(),而是在边界统一 key:优先使用稳定的标量 ID;确实按对象身份索引时,规定只存 raw 或只存 proxy。
source → derived → effect 中 Proxy 的位置
source: raw object
↓ reactive() 建立稳定 proxy
读取 proxy.key ──track──> derived / render effect
写入 proxy.key ─trigger─> scheduler ──> effect 重新运行Proxy 不保存 derived,也不决定 watcher 何时运行。它只把 JavaScript 操作翻译成 track / trigger 信号;缓存属于 computed,批处理属于调度器。
排错清单
toRaw(value) === value吗?若为真,它可能根本不是代理。- 写入是否经过 proxy?
raw.count++与proxy.count++的可见数据一样,触发行为不同。 - 身份判断是在比较业务 ID,还是无意比较 raw/proxy 引用?
- 问题来自对象属性还是 Map/Set 方法与迭代?不要用对象 trap 猜集合行为。
- 若维护 Vue 2,新增字段和数组索引属于旧观察模型的限制,不要套用 Vue 3 结论。
练习:修复代理身份缓存
有一个第三方缓存 Map<object, string>,它只接受原始用户对象。调用方有时传 raw,有时传 reactive(raw),导致同一用户出现两条缓存记录。
- 实现
createIdentityCache(),在get/set边界用toRaw()归一化对象。 - 断言 raw 写入后 proxy 能读取,proxy 覆盖后缓存大小仍为
1。 - 增加两个不同但字段相同的对象,断言它们仍是两个 key;不能错误地退化成 JSON 字符串比较。
- 说明若业务有稳定
user.id,为什么按标量 ID 建缓存通常更安全。