出发点与性能无关,这些检测是为了语言的统一性和安全性考虑的。规范里有说明:
https://tc39.es/ecma262/#sec-invariants-of-the-essential-internal-methods
The Internal Methods of Objects of an ECMAScript engine must conform to the list of invariants specified below. Ordinary ECMAScript Objects as well as all standard exotic objects in this specification maintain these invariants. ECMAScript Proxy objects maintain these invariants by means of runtime checks on the result of traps invoked on the [[ProxyHandler]] object.
Any implementation provided exotic objects must also maintain these invariants for those objects. Violation of these invariants may cause ECMAScript code to have unpredictable behaviour and create security issues.
在规范层面,JavaScript 里对象的本质是什么?本质就是下面这 11 个内部方法(Internal Method):
[[GetPrototypeOf]]()
[[SetPrototypeOf]](V)
[[IsExtensible]]()
[[PreventExtensions]]()
[[GetOwnProperty]](P)
[[DefineOwnProperty]](P, Desc)
[[HasProperty]](P)
[[Get]](P, Receiver)
[[Set]](P, V, Receiver)
[[Delete]](P)
[[OwnPropertyKeys]]()
你在一个对象身上进行的任何操作都会转而去执行它的内部方法,比如最常见的,用点号读取对象的属性时就会去执行它的内部方法 [[Get]]。
在 JavaScript 里有且只有一套(11 个)标准的内部方法,使用这一套标准内部方法的对象叫做普通对象(ordinary object),比如用大括号字面量创建的对象就是普通对象。不过一些对象还可以扩展自己的内部方法,比如函数就扩展了:
[[Call]](thisArgument, argumentsList)
顾名思义,调用一个函数对象时就会用到它的内部方法[[call]],而非函数对象没有[[call]]内部方法,所以不能被调用。
此外还有一些函数对象又额外扩展出了:
[[Construct]](argumentsList, newTarget)
拥有这个内部方法的函数又叫做构造器函数。
注意只要用到那 11 个标准内部方法的对象就是普通对象,额外加内部方法没关系,所以函数也属于普通对象。
但数组就不是了,由于数组的length属性和索引属性会有一些神奇的互动操作,比如把 length改小可以切掉尾部的一些索引属性,增加一个 length + 1的索引属性,length也会加1,所以规范必须给它配一个专门的拥有特殊逻辑的 [[DefineOwnProperty]]内部方法。 像数组这种,没有用那 11 个标准的内部方法里的一个或多个方法的对象,叫做奇异对象(Exotic Object)。
除了数组,arguments也是奇异对象(自定义了 5 个内部方法),绑定函数也是,还有最常见的就是 ES6 里新增的 Proxy 对象。
大家都说 Proxy 对象很厉害,可以实现很多神奇的功效,其实就是因为引擎让你用 JavaScript 代码去自定义那 13 个内部方法了:
Internal Method Handler Method
[[GetPrototypeOf]] getPrototypeOf
[[SetPrototypeOf]] setPrototypeOf
[[IsExtensible]] isExtensible
[[PreventExtensions]] preventExtensions
[[GetOwnProperty]] getOwnPropertyDescriptor
[[DefineOwnProperty]] defineProperty
[[HasProperty]] has
[[Get]] get
[[Set]] set
[[Delete]] deleteProperty
[[OwnPropertyKeys]] ownKeys
[[Call]] apply
[[Construct]] construct
13 个内部方法刚好对应 13 个 trap(Handler Method)。
前面说到规范允许自定义内部方法、允许奇异对象的存在,但自定义的内部方法的逻辑也不能完全不加限制,否则就乱套了,所以在 ES5 的时候,规范里就已经为那 13 个内部方法制定了一些不能打破的准则,并把它们称之为内部方法的不变量(invariant)。
比如内部方法 [[GetPrototypeOf]] 的一个不变量是它的返回值必须是一个对象,或者是null,不能返回个其它乱七八糟的值比如1。 还比如 [[Construct]]内部方法的唯一一个不变量就是它必须返回一个对象,所以我们无论new 个什么构造函数,它都不能生成一个原始值出来,否则就是违反了这个不变量。
在 ES6 之前反正还没有 Proxy,这些不变量主要是给宿主对象制定的,即便到现在,貌似 window 对象还有一些违反某些不变量的情况,不过一般人根本意识不到的。
制定规范的人会尽量保证已有的不变量不被打破,比如模块命名空间对象,也就是 import * as ns from "mod"中的这个 ns,它也是个奇异对象。它的每个属性其实是个像引用一样的东西, 引到了另外一个模块里的某个变量,那个模块里的变量的值变了,这个属性的值就自动变了,但它是只读的,你不能反过来改变人家模块里的变量:
console.log(ns.foo) // 1
ns.foo = 2 // Uncaught TypeError: Cannot assign to read only property 'foo'
这么看来,Object.getOwnPropertyDescriptor(ns, "foo").writable 是不是应该是 false啊?可它却不是。因为 [[GetOwnProperty]]内部方法有这么一条不变量:
If P is described as a non-configurable, non-writable own data property, all future calls to [[GetOwnProperty]] ( P ) must return Property Descritor whose [[Value]] is SameValue as P's [[Value]] attribute.
也就是说,如果某个属性曾经被检测出是个 {writable: false, configurable: false}的属性, 那么它的value就得永远保持当时的那个值。而我们的 ns对象无法满足这一点,因为那个ns模块里的代码说不定在未来某个时刻会执行一下 foo = 2,那么我们的 ns.foo就自动从 1变成2了。所以规范不能把模块命名空间对象的属性都设置为只读属性,而是通过自定义 [[Set]]内部方法的方式,让赋值操作抛异常。
但也有一些时候,不变量只能被打破,比如从 ES6 开始,由于 Proxy 的存在,“不能存在循环原型链”的不变量被打破:
const obj = new Proxy({}, {
getPrototypeOf() {
return obj
}
})
从 obj上读取属性可能会让引擎卡死。
说回你问的 proxy 对象的 trap 要检测“所有非可扩展的对象原型,和不可配置属性必须如实返回”的原因,分别是为了满足[[GetPrototypeOf]] 的这条不变量:
If target is non-extensible, and [[GetPrototypeOf]] returns a value V, then any future calls to [[GetPrototypeOf]] should return the SameValue as V.
和上面讲模块命名空间对象说过的 [[GetOwnProperty]]的这条不变量:
If P is described as a non-configurable, non-writable own data property, all future calls to [[GetOwnProperty]] ( P ) must return Property Descritor whose [[Value]] is SameValue as P's [[Value]] attribute.
作者:张立理
网友评论