先记住这个答案
set 陷阱返回 true 表示「写入成功」。如果目标对象自身有一个不可写又不可配置的数据属性,这个属性的值是永远改不了的,陷阱再说写成功就是在撒谎。所以规范规定:这种情况下陷阱返回 true 时,引擎会检查新值和目标上的当前值是否相同,不同就抛 TypeError。换句话说,值不同时陷阱只能返回 false;值相同时返回 true 是允许的,因为这相当于什么都没改。
- 值不同还返回
true,赋值处抛TypeError - 值相同时返回
true是允许的 set返回false可表示写入失败- 直接转发
Reflect.set就不会违反
陷阱撒谎会被当场抓住
写一个无论如何都返回 true 的 set 陷阱,看看引擎怎么反应。
const target = {}
Object.defineProperty(target, 'id', {
value: 1,
writable: false,
configurable: false
})
const liar = new Proxy(target, {
set() {
return true // 不管写什么都报告成功
}
})
console.log(Reflect.set(liar, 'id', 1)) // 新值和原值相同,不违反
try {
Reflect.set(liar, 'id', 2) // 新值不同,还返回 true
} catch (e) {
console.log(e.name)
}
console.log(target.id)查看输出与解释
true
TypeError
1检查发生在陷阱返回之后:陷阱先跑完,拿到 true,引擎再去读 target 上 id 的描述符,发现它锁死了、值是 1,而这次要写的是 2,对不上就抛错。连 Reflect.set 这种本来不抛错的调用方式也照抛,因为这是代理违反了规则,不是普通的写入失败。
为什么要有这条规则?不可写又不可配置,是 JavaScript 给出的「这个值永远不变」的保证,Object.freeze 就是靠它实现的。代理如果能随便声称写成功,外部代码就会以为冻结对象的值变了,这个保证就不成立了。不变量的作用就是让代理可以拦截,但不能伪造这种保证。
正确的写法:直接转发 Reflect.set
大多数时候 set 陷阱只是想在写入前后做点事,比如打日志或校验。最后一步交给 Reflect.set,它会按原生规则去写,写不进去就自己返回 false,永远不会违反不变量。
const target = Object.freeze({ id: 1 }) // freeze 后每个属性都不可写、不可配置
const proxy = new Proxy(target, {
set(t, key, value, receiver) {
console.log('set', key, value)
return Reflect.set(t, key, value, receiver) // 写不进去时它自己返回 false
}
})
console.log(Reflect.set(proxy, 'id', 2))
function strictAssign() {
'use strict'
try {
proxy.id = 3
} catch (e) {
console.log(e.name)
}
}
strictAssign()
console.log(target.id)查看输出与解释
set id 2
false
set id 3
TypeError
1陷阱返回 false 之后,结果和给普通只读属性赋值一样:Reflect.set 拿到 false;严格模式下的 proxy.id = 3 抛 TypeError;非严格模式下静默失败。这里的 TypeError 是「写入失败」的正常报错,和上一段「陷阱撒谎」的报错是两回事。
- 访问器属性的对应规则
- 如果目标上是不可配置的访问器属性,而且没有 setter,那它同样写不进去。这时陷阱返回
true一律抛TypeError,没有「值相同」的例外,因为根本没有值可比。
容易答错的地方
- 说「必须返回
false」 - 不完全对。新值和原值相同时返回
true不会报错。准确说法是:值不同时不能返回true。 - 以为属性只是
writable: false就会触发 - 必须同时是不可写和不可配置。只是不可写、但还能配置的属性,陷阱返回
true不会报错,因为理论上可以用defineProperty改它的值,引擎就不追究了。 - 以为检查的是原型链上的属性
- 检查只看
target自身的属性描述符。原型上的只读属性不在这条不变量的范围里。
面试官还会怎么问?
其它陷阱还有哪些类似的不变量?
get:目标上不可写又不可配置的数据属性,必须原样返回它的值。has:不能把不可配置的属性报告成不存在。deleteProperty:不能对不可配置的属性返回删除成功。ownKeys:返回的列表必须包含所有不可配置的自有键;目标不可扩展时,还必须和目标的键完全一致。思路都一样,代理不能推翻目标对象已经做出的「锁定」承诺。
陷阱里不返回任何值会怎样?
返回 undefined 会被转成 false,按写入失败处理,严格模式下的赋值会抛 TypeError。这是写 set 陷阱最常见的 bug:做完了校验却忘了 return true。
想让冻结对象「看起来」能改,有办法吗?
不能在同一个 target 上做到。常见做法是代理一个没冻结的副本,或者用一个空对象做 target,把真实数据放在闭包里,所有陷阱都读写闭包里的数据。这样 target 上没有锁死的属性,也就不受这条不变量限制。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。