先记住这个答案
Proxy 的 get 陷阱会在读取代理对象任意属性时被调用,签名是 get(target, key, receiver),它返回什么,读属性就得到什么。常见用途有给不存在的属性返回默认值、支持负数下标、记录访问日志,以及 Vue 3 的依赖收集。链式调用的做法是:读属性时返回一个函数,函数执行完返回代理本身,于是可以一直 .a().b().c() 点下去。还有一种是每读一层就返回一个新代理,把属性名攒起来,最后调用时一次性用上,常用来拼接口路径。
get陷阱拦截属性读取- 返回代理本身,就能无限链式调用
- 每读一层返回新代理,可以动态拼路径
- 用
Reflect.get正确转发读取
返回代理本身,实现链式调用
jQuery 那种 $(el).css(...).show() 的写法,靠的是每个方法最后 return this。用 Proxy 可以不提前写任何方法,读到什么属性就临时生成一个设置函数。
const style = { color: '', size: 0 }
const builder = new Proxy(style, {
get(target, key) {
if (key === 'done') return () => ({ ...target })
// 读到任何其它属性,都返回一个设置函数
return (value) => {
target[key] = value
return builder // 返回代理本身,才能继续往下点
}
}
})
const result = builder.color('red').size(14).done()
console.log(JSON.stringify(result))
console.log(style.color)查看输出与解释
{"color":"red","size":14}
red读 builder.color 会触发 get,这时 key 是 'color',陷阱返回一个函数;调用它写入 target.color,再返回 builder。下一个 .size 又是对代理的一次读取,又进一次 get。整条链上每一步都回到代理,这就是链式调用能成立的原因。
注意函数里返回的是 builder 而不是 target。返回 target 的话,下一次 .size 读的是原始对象,拿到的是数字 0,调用就报错了。
每读一层返回新代理,动态拼路径
另一种链式用法不需要调用每一步,而是把读过的属性名攒起来。很多 RPC 客户端、接口封装就是这么做的,写 api.users.list() 就能请求 /users/list,不用提前定义任何方法。
function createPath(parts = []) {
// 用函数当 target,代理才能既被点属性,又被直接调用
return new Proxy(function () {}, {
get(_, key) {
return createPath([...parts, key]) // 每读一层,返回一个多一段路径的新代理
},
apply(_, __, args) {
const query = args.length ? '?id=' + args[0] : ''
return '/' + parts.join('/') + query
}
})
}
const api = createPath()
console.log(api.users.list())
console.log(api.users.detail(42))
console.log(api.orders.byUser.recent(7))查看输出与解释
/users/list
/users/detail?id=42
/orders/byUser/recent?id=7每次读属性都新建一个代理,并把属性名追加到 parts,互相之间不共享状态。最后一步是函数调用,触发的是 apply 陷阱而不是 get,所以 target 必须是函数,普通对象当 target 是没法被调用的。真实项目里,apply 里就是发请求的地方。
容易答错的地方
- 转发时直接写
target[key] - 如果原对象上有 getter,
target[key]会让 getter 里的this指向原始对象,绕过了代理,后面对this的读取就拦不到了。老实转发应该写Reflect.get(target, key, receiver),其中的receiver保证this还是代理。 - 代理
Map或Set后直接调方法 new Proxy(new Map(), {})之后调.get('a')会抛TypeError。原因是Map.prototype.get要求this是真正的 Map,它依赖 Map 内部存储的数据,而这时this是代理。解决办法是在get里把取到的方法bind(target)后再返回,Vue 3 处理集合类型时也做了专门的包装。- 对所有属性都返回函数
- 上面的构建器如果被
await,引擎会去读then,拿到一个函数,就把它当成 Promise 去调用,结果完全不对。JSON.stringify会读toJSON,模板字符串会读Symbol.toPrimitive,都会掉进同一个坑。实际写的时候要对这些 key 返回undefined,或者只对白名单里的属性名生成函数。
面试官还会怎么问?
get 陷阱能拦截 in 和 Object.keys 吗?
不能。in 走的是 has 陷阱;Object.keys 走的是 ownKeys 和 getOwnPropertyDescriptor;删除属性走 deleteProperty 陷阱。get 只管读值。
Vue 3 的响应式在 get 里做了什么?
读取时先用 Reflect.get 拿到值,再记录「当前正在运行的副作用函数读了这个对象的这个属性」,也就是依赖收集。如果拿到的值还是对象,就在这时才把它包成响应式代理,所以深层对象是用到时才代理,不用初始化时全部递归。
代理对象作为别的对象的原型,get 还会触发吗?
会。子对象自身找不到属性时,查找沿原型链走到代理,就会触发代理的 get,这时 receiver 是子对象,不是代理。这也说明 receiver 表示的是最初被读取的那个对象。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。