本文目录
大楼前台不替你在办公室里找文件,但任何人进出都要登记。登记不改变办公室里的柜子,却能在“访问”这一动作上插入规则:访客必须预约、某些楼层只读、离开时要签退。
上一章从根出发画可达性图;本章换一个问题:若仍要让对象可用,却要在每次读、写、删属性时插入逻辑,引擎提供什么钩子?
第十二章的属性查找沿 [[Prototype]] 向上;Proxy 不替换那条链,而是在访问代理对象时先经过你定义的 trap。Vue 3 依赖收集、reactive 包装对象,本质都是在 get / set 等路径上登记“谁读了什么”。
学 Proxy 时建议手边开着 MDN 的 handler 表:不必一次背全 thirteen 个 trap,但遇到 in、delete、Object.keys 异常时,要知道该去查 has、deleteProperty、ownKeys 哪一栏。下面从最常见的 get / set 开始。
访问代理对象时,引擎会先问 trap 要不要介入,再决定是否落到目标对象:
先猜输出:代理对象与目标对象是不是同一个
const target = { count: 0 }
const proxy = new Proxy(target, {
get(obj, key) {
console.log('读取', key)
return obj[key]
},
})
proxy.count
console.log(proxy === target)输出:
读取 count
falseproxy.count 会触发 get trap;proxy 与 target 不是同一个引用。业务代码若持有的是 proxy,所有通过它的属性访问都会先经过 trap;直接改 target 则绕过代理,这是常见 bug 来源。团队约定里最好写清:状态对象只导出 proxy,禁止在模块外缓存 target 引用,否则拦截会失效,请留意这一点。
Proxy 的基本形状:target 与 handler
const inventory = { eggs: 12, flour: 2 }
const guarded = new Proxy(inventory, {
set(obj, key, value) {
if (key === 'eggs' && value < 0) {
throw new RangeError('鸡蛋库存不能为负')
}
obj[key] = value
return true
},
})
guarded.eggs = 10
guarded.eggs = -1 // RangeErrornew Proxy(target, handler) 的第一个参数是目标对象,第二个是处理器,其上的方法叫 trap。常见 trap 包括 get、set、has、deleteProperty、ownKeys 等,分别对应属性读取、赋值、in 运算符、删除与 Object.keys 一类操作。
trap 里你决定是转发给 target、修改返回值,还是直接抛错。代理对象对外仍表现得像普通对象,但语义由 handler 编排。写 handler 时优先用 Reflect.* 做默认转发,再在前后加日志或校验,比完全手写对象操作更不容易漏掉 receiver 等细节。
Reflect:与 trap 一一对应的默认行为
手写 trap 时,若只想加一层日志再按默认规则走,用 Reflect 最省事:
const raw = { title: '番茄面' }
const logged = new Proxy(raw, {
get(obj, key, receiver) {
console.log(`get ${String(key)}`)
return Reflect.get(obj, key, receiver)
},
set(obj, key, value, receiver) {
console.log(`set ${String(key)}`)
return Reflect.set(obj, key, value, receiver)
},
})Reflect.get / Reflect.set 的签名与对应 trap 一致。它们执行的是规范里的默认对象操作,并正确处理 receiver(与 this 指向相关的访问器属性)。
许多内置操作在内部也会走 Reflect 路径。trap 里先 Reflect.get(...) 再包一层,比直接 obj[key] 更安全:访问器里的 this 会指向 receiver(通常是 proxy),行为与未代理时一致。这也是官方示例反复强调 Reflect 的原因,而不是语法糖可有可无。
与原型链的关系:代理挡在前面,链仍在后面
const proto = { greet() { return '你好' } }
const obj = Object.create(proto)
const proxy = new Proxy(obj, {
get(target, key, receiver) {
if (key === 'greet') {
return () => '代理问候'
}
return Reflect.get(target, key, receiver)
},
})
console.log(proxy.greet())
console.log(obj.greet())proxy 上没有 greet 时,Reflect.get 仍会沿 obj 的原型链查找——除非你在 trap 里提前返回。第十二章的查找顺序依然成立,只是第一层换成了代理的 handler。
Reflect.getPrototypeOf、Reflect.setPrototypeOf 等也能在 trap 里配合使用。注意:getPrototypeOf trap 影响的是对代理取原型,不等于改掉 target 自身的 [[Prototype]],除非你在 trap 里显式调用 Reflect.setPrototypeOf。
不可撤销与可撤销代理
const { proxy, revoke } = Proxy.revocable({ id: 1 }, {})
revoke()
console.log(proxy.id) // TypeError: Cannot perform 'get' on a proxy that has been revokedProxy.revocable 适合“会话结束就拆掉包装”的场景:撤销后任何 trap 都会抛错,避免旧引用继续访问敏感对象。普通 new Proxy 一旦建立,无法从外部“脱掉”代理层。
业务上常见用法:把 token 或临时配置包进可撤销代理,用户登出时调用 revoke(),比手动清空对象每个字段更不容易漏。
JSON.stringify 与结构化克隆会走哪些 trap
JSON.stringify(proxy) 会读取可枚举属性,从而触发 get;若你在 get 里做依赖收集,要注意序列化也会算一次“读”。structuredClone(第十三章消息传递同源)会遍历对象图,同样可能触发多次 get。
调试时若发现“我没渲染组件,依赖却增加了”,检查是否有序列化、日志打印或 DevTools 展开对象触发了额外读取。响应式框架通常会对这类路径做标记或短路,但手写 Proxy 时要自己留心。
与 Object.defineProperty 对比:为何 Vue 3 转向 Proxy
Vue 2 在实例创建时对已有键用 defineProperty 做 getter/setter。新增属性、通过索引改数组、删除键等场景需要额外 API(Vue.set)才能保持响应式。
const obj = {}
let value = 1
Object.defineProperty(obj, 'count', {
get() { return value },
set(v) { value = v },
})
obj.count = 2 // 走 setter
obj.newKey = 3 // 新键默认不可拦截,除非再 defineProxy 在操作发生时统一进入 trap,对新增键、数组 length 变更、in/delete 等更一致。代价是只能代理对象、要注意绕过 target 直接写的漏洞,以及深层对象要递归代理。读框架迁移文档时,抓住“拦截点从定义时变成访问时”这一条即可。
不变式(invariant):哪些 trap 不能乱来
规范对部分 trap 有不变式约束。例如不可扩展且所有属性都不可配置的对象,代理不能假装删除成功;某些情况下 get 必须返回与 target 上一致的值。违反时抛出 TypeError。
日常业务代理很少踩到,但在代理冻结对象或密封对象时要小心:
const sealed = Object.seal({ a: 1 })
const p = new Proxy(sealed, {
deleteProperty() { return true }, // 可能违反 invariant
})若要做只读视图,优先在普通对象上实现 trap 逻辑,而不是硬改 sealed/frozen target 的语义。
has、ownKeys 与 deleteProperty:不只有 get/set
属性访问不止“读”和“写”。in 运算符、Object.keys、delete 也各有 trap:
const hidden = new Proxy({ a: 1, secret: 2 }, {
ownKeys(target) {
return Reflect.ownKeys(target).filter((key) => key !== 'secret')
},
getOwnPropertyDescriptor(target, key) {
if (key === 'secret') return undefined
return Reflect.getOwnPropertyDescriptor(target, key)
},
})
console.log(Object.keys(hidden)) // ['a']
console.log('secret' in hidden) // 仍可能为 true,除非再实现 has trap只实现 ownKeys 而不配合 getOwnPropertyDescriptor,Object.keys 与 in 的结果可能不一致。规范要求 trap 组合行为自洽;生产里若要做“隐藏字段”,通常连同 has 一起转发或过滤。
删除属性走 deleteProperty:
const frozen = new Proxy({ id: 1 }, {
deleteProperty() {
return false
},
})
console.log(delete frozen.id) // false返回 false 表示删除失败;返回 true 表示成功(并会真的从 target 删键,除非你只在 trap 里撒谎而不改 target——那会制造诡异状态,别这么干)。
函数与数组也可以被代理
const sum = new Proxy(function add(a, b) {
return a + b
}, {
apply(target, thisArg, args) {
console.log('调用', args)
return Reflect.apply(target, thisArg, args)
},
})
sum(2, 3) // 调用 [2, 3] → 5apply trap 拦截函数调用;construct 拦截 new。数组代理后,length 与下标读写仍会走 get/set,这也是 Vue 3 能拦截数组索引变更的基础之一(配合依赖收集逻辑)。
若你实现过“数组变异方法”的补丁(如 push 时手动 trigger),在现代引擎里更推荐依赖 Proxy 对下标与 length 的统一拦截,而不是逐个重写 Array.prototype 上的方法——后者既难维护,也容易与别的库冲突,第十二章讨论过随意改原型的代价。
对 Map / Set 也有对应的 trap 族(如 get/set 针对 Map 条目)。集合类型在业务状态里越来越常见,读 Vue 3 源码时会看到专门的分支;本章先记住“不同数据结构有不同 trap 表”,需要时再查 MDN 的 handler 列表即可。
receiver 为什么重要:访问器属性里的 this
const store = {
_val: 0,
get value() {
return this._val
},
set value(v) {
this._val = v
},
}
const proxy = new Proxy(store, {
get(target, key, receiver) {
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
return Reflect.set(target, key, value, receiver)
},
})
proxy.value = 5
console.log(store._val) // 5若在 get 里写 return target[key],访问器内部的 this 会指向 target 而不是 proxy,嵌套代理或依赖收集可能断链。Reflect.get/set 的第三个/第四个参数 receiver 就是用来把 this 绑到代理上的。
只读视图与默认值:两个常见模式
只读包装:在 set/deleteProperty 里直接 return false 或 throw,读路径仍 Reflect.get。
function readonly(obj) {
return new Proxy(obj, {
set() { return false },
deleteProperty() { return false },
defineProperty() { return false },
setPrototypeOf() { return false },
})
}默认值:在 get 里对 undefined 补默认,而不改 target:
function withDefaults(obj, defaults) {
return new Proxy(obj, {
get(target, key, receiver) {
const value = Reflect.get(target, key, receiver)
return value === undefined && key in defaults
? defaults[key]
: value
},
})
}注意 key in defaults 与 undefined 显式赋值的区别:后者是“用户清空了字段”,不应被默认值盖掉。
与 WeakMap 配合:元数据不污染枚举
第十五章的 WeakMap 适合存“仅代理层知道的附加信息”:
const meta = new WeakMap()
function tag(obj, label) {
meta.set(obj, { label })
return new Proxy(obj, {
get(target, key, receiver) {
if (key === Symbol.for('debugLabel')) {
return meta.get(target)?.label
}
return Reflect.get(target, key, receiver)
},
})
}目标对象上不会出现 debugLabel 键,但调试工具仍可通过约定符号读取。Proxy 管“操作语义”,WeakMap 管“旁路数据”,两者常一起出现。
容易踩的坑
误解一:Proxy 等于深拷贝。 代理共享同一份 target 数据;set 写进的值仍在 target 上,除非 trap 里指向别的存储。
误解二:有了 Proxy 就不需要 WeakMap 做私有字段。 仍可用 WeakMap 把元数据挂在目标上而不污染枚举键;Proxy 解决的是“操作语义”,不是“藏 key”。
误解三:Vue 2 的 Object.defineProperty 与 Vue 3 的 Proxy 只是 API 换名。 前者难以拦截新增属性、数组下标等;后者在 get/set 层统一处理,并配合 Reflect 与集合类型(Map/Set)的专门 trap。读响应式源码时,应盯住哪次读取触发了依赖收集。
误解四:所有 in 和 Object.keys 都会进 trap。 取决于是否定义了 has、ownKeys trap;未定义时可能走默认转发,行为要与规范对照。
误解五:代理一层就够覆盖整个应用状态树。 深层嵌套对象需要递归代理或惰性代理;否则改内层字段不会触发外层订阅。框架里的 reactive 正是在 get 里对子对象再包装。
误解六:Proxy 能拦截所有“读数据”的方式。 Object.getOwnPropertyDescriptor(proxy, key) 会走 trap,但某些内部槽、私有字段(#field)不经过你写的 get。读规范与语言新特性时,要分清“属性键”与“真正的私有成员”。
把 Proxy 放进工具箱时,可以按三个问题自检:调用方手里是 proxy 还是 target?深层对象有没有递归包装?get/set 里有没有可能抛错或死循环(例如在 get 里又读同一键)?答清楚再上线,比事后在 production 里追“为什么没触发更新”省力得多。
试一试:做一个最小的“响应式”
不引入框架,用 Proxy + Set 模拟“读时收集、写时通知”:
function reactive(obj, onChange) {
const subscribers = new Set()
return new Proxy(obj, {
get(target, key, receiver) {
subscribers.add(onChange)
const value = Reflect.get(target, key, receiver)
if (value !== null && typeof value === 'object') {
return reactive(value, onChange)
}
return value
},
set(target, key, value, receiver) {
const ok = Reflect.set(target, key, value, receiver)
subscribers.forEach((fn) => fn())
return ok
},
})
}
const state = reactive({ count: 0 }, () => console.log('变了'))
state.count
state.count++跑完后回答:
- 为什么嵌套对象也要再包一层
reactive? - 若
onChange里再次写state,可能出什么问题?(提示:第四章、第五章的调用栈与微任务。) - 对比第十二章:方法若挂在原型上,通过
state调用时 trap 的get会收到什么key?
再试一题:给 readonly 包装后的对象赋值,观察是静默失败还是抛错——取决于 trap 返回值与严格模式。把 set 改成 throw TypeError('只读'),体验与 Vue readonly 提示的差异。
还可以比较 Reflect.has(proxy, key) 与 key in proxy:两者都可能触发 has trap,但默认转发路径略有不同。写单元测试时,若断言“某键不可见”,要同时想清楚 ownKeys、has 与 getOwnPropertyDescriptor 是否一致,否则会出现“测试里 in 为 false,但 get 仍能读到值”的矛盾状态。
读 Vue 3 响应式时盯什么
不必先背 track / trigger 函数名,先盯住两次访问:
- 组件渲染读
state.count→ 某次gettrap 登记依赖; - 事件处理写
state.count++→ 某次settrap 通知订阅者重跑。
这与本章手写 reactive 的结构同构。区别在框架做了批量调度、边界情况(集合类型、ref 拆包)与编译期优化。读完 Vue 3 响应式 可对照:哪一层是 Proxy,哪一层是 effect 调度。
再补一条实践建议:在 DevTools 里对 reactive 对象做 JSON.stringify 或展开查看时,也可能触发额外的 get。若你在业务里手写 Proxy 做校验或审计,记得区分“用户操作触发的读”和“工具链触发的读”,必要时用标志位或白名单键过滤,避免日志爆炸或误报。
下一章:错误如何沿调用栈与 Promise 传播
Proxy 里 throw new RangeError(...) 会像普通代码一样打断执行。第一章说过同步异常沿调用栈找 try...catch;第十章的 await、第五章的未处理微任务,则走另一条报告路径。第十七章把同步 stack、async 里的 throw、unhandledrejection、Worker 的 onerror 放在同一张图里,专门讲错误该在哪一层被接住,避免“try 包住了却仍然在控制台爆红”的困惑。