⚠️ 本文并不涉及原型链是什么、如何创建等基础知识。如果有学习这方面基础知识的需求,这里推荐一下 Dr. Axel Rauschmayer 的 Speaking JavaScript。
我们知道,原型链是 JavaScript 中一个非常重要的概念。当我们尝试访问对象的属性,而该对象并不包含目标属性时,JavaScript 就会沿着原型链进行搜索,直到找到需要的属性或者原型链上已没有更多对象。而在对象上能够访问到其原型的属性这一特性也被称为属性的继承。
本文是作者关于 ECMAScript 规范如何描述访问对象属性这一过程,以及原型链在其中起到的作用的分享。
预备知识
在正式内容开始之前,我们需要了解一些 ECMAScript 规范中关于对象的基础知识。
在 ECMAScript 规范中,每个对象有一系列所谓的内部方法(Internal Methods)以供调用。规范还特别指定了一些内部方法是所有对象都必须有的。比如:
[[Get]]
:获得对象的属性。[[Set]]
:设置对象的属性。[[GetPrototypeOf]]
:获得对象的原型。Object.getPrototypeOf
内部调用了对象的这个内部方法。[[SetPrototypeOf]]
:设置对象的原型。同样,Object.setPrototypeOf
内部也是调用了这个内部方法。- ......
除了这些必须的内部方法,如果对象还支持内部方法 [[Call]]
,则可以称之为函数对象(function objects);如果还支持 [[Construct]]
,则可以称之为构造器(constructor)。每个支持 [[Construct]]
的对象,必须也支持 [[Call]]
。也就是说构造器必须是一个函数对象。因此,构造器也可以被称为构造函数(constructor function)或者构造函数对象(constructor function object)。
在 ECMAScript 规范中,对象被分为 ordinary 对象和 exotic 对象。我们遇到的大多数对象都是 ordinary 对象,也就是指所有必须支持的内部方法都采用了在 Ordinary Object Internal Methods and Internal Slots 章节中制定的默认行为的对象。除此之外的对象都被称为 exotic 对象。数组对象就是 exotic 对象,因为其 length
属性的特殊行为是通过覆盖某些内部方法实现的。
对象的内部方法可能会用到一些对象的状态信息被称为内部插槽(Internal Slots)。比如所有 ordinary 对象都拥有 [[Prototype]]
这个插槽。内部插槽不是属性,无法被继承。
内部方法和内部插槽都只服务于在 ECMAScript 规范描述对象的语义,并不能在 JavaScript 中被访问到。JavaScript 引擎也只需要保证语义的一致,并不一定完全按照规范中的算法来实现。
取值
我们先来看通过 obj.foo
对属性进行取值时,原型链在其中起到了什么样的作用。
除非另作说明,否则本文接下来涉及的都是 ordinary 对象。
在作者的另一文章中提到过,obj.foo
最终会调用一个名为 GetValue
的抽象操作。而这个操作又会调用 obj.[[Get]]('foo', obj)
。[[Get]]
接收的第一个参数很显然是正在访问的属性键。而第二个参数是一个被称为 Receiver(它的作用会在之后说明)的值,在这里就是 obj
本身。[[Get]]
又把任务交给抽象方法 OrdinaryGet
。OrdinaryGet
的步骤如下:
[[GetOwnProperty]]
是一个用于获得自身拥有属性的内部方法。如果对象拥有目标属性,该方法返回一个属性描述符(Property Descriptor)。否则,返回 undefined
。注意,这里的属性描述符与我们常说的 Object.getOwnPropertyDescriptor
返回的属性描述符是不同的。[[GetOwnProperty]]
返回的是一个规范类型值,是一个包含若干个字段(fields)的 Record。而 Object.getOwnPropertyDescriptor
虽然在内部也是调用的 [[GetOwnProperty]]
,但是返回的是根据内部方法返回的 Record 创建的一个对象。
ECMAScript 中的类型可以分为规范类型(Specification Types)和语言类型(Language Types)。顾名思义,规范类型只存在于规范中。而我们经常接触的字符串类型、数字类型等属于语言类型。
如果 [[GetOwnProperty]]
返回 undefined
,也就是说该对象自身并没有目标属性,则尝试在其原型上继续查找,正如步骤 2 所叙述的那样。其中,[[GetPrototypeOf]]
是用于获得对象的原型,返回一个对象或者 null
。之后,调用原型的 [[Get]]
方法,又会导致一次对 OrdinaryGet
的调用。这个递归过程会持续到某个对象的 [[GetOwnProperty]]
方法返回的不再是 undefined
或者 [[GetPrototypeOf]]
返回 null
。这也就是我们说的对原型链进行的遍历。
假如最终成功找到目标属性,则根据取得的描述符的类型进行不同的操作。如果是数据属性描述符(Data Property Descriptor),则返回其 [[Value]]
字段;如果是访问器属性描述符(Accessor Property Descriptor),则调用其 getter。注意这里 Receiver 被作为第二个参数传给了抽象操作 Call
,使得 getter 中的 this
指向了 Receiver。观察 OrdinaryGet
的步骤,我们可以得知 Receiver 就是最初的被取属性值的对象 obj
。因此,下面的代码将输出 'child'
。
js
const foo = {
_v: 'parent',
get a() {
return this._v
}
}
const bar = {
_v: 'child'
}
Object.setPrototypeOf(bar, foo)
console.log(bar.a)
赋值
再来看使用 obj.foo = 42
这样的赋值语句对属性进行赋值时涉及的步骤。与取值时类似,这条语句会触发对内部方法 [[Set]]
的调用。而 [[Set]]
又会调用抽象操作 OrdinarySet
。同样的,传入的 Receiver 依然是 obj
自身。OrdinarySet
的步骤如下:
可以看到,OrdinarySet
的内容比较简单,看来重点在 OrdinarySetWithOwnDescriptor
。
看,熟悉的结构再次出现了!又是不断通过 [[GetPrototypeOf]]
方法获得原型,试图找到目标属性并取得对应的属性描述符。但和 OrdinaryGet
中的递归不同的是,假如在原型链上的最后一个对象(这个对象的 [[GetPrototypeOf]]
方法返回了 null
)依然没有找到目标属性,OrdinarySetWithOwnDescriptor
会创建一个数据属性描述符然后继续执行余下的步骤,而不是直接返回 undefined
。也就是说,步骤 1 执行完后,ownDesc
有可能是通过当前对象的 [[GetOwnProperty]]
方法获得的描述符,也有可能是刚刚创建的空描述符。
同样的,接下来就是根据描述符的类型进行不同的操作。
假如是数据属性描述符,那就检查它的 [[Writable]]
字段。如果 [[Writable]]
字段值为 false
,则表示这个属性不可写,于是返回 false
表示赋值失败。在严格模式下,赋值失败会导致 TypeError
。否则,继续后面步骤。步骤 2.b~2.e 描述了这样的行为:如果 foo
是对象 obj
自身拥有的属性,则通过 obj.foo = 42
进行赋值会把 foo
属性的值设置为 42
;否则,在 obj
上创建一个值为 42
的新属性。具体例子如下:
js
// 对自身的属性进行赋值
const foo = {
v: 1
}
foo.v = 42
Object.getOwnPropertyDescriptor(foo, 'v') // 返回 { value: 42, ...}
// 对继承的属性进行赋值
const foo = {
v: 1
}
const bar = Object.create(foo)
bar.v = 42
Object.getOwnPropertyDescriptor(foo, 'v') // 返回 { value: 1, ...}
Object.getOwnPropertyDescriptor(bar, 'v') // 返回 { value: 42, ...}
// 对继承的不可写属性进行赋值
const foo = {}
Object.defineProperty(foo, 'v', {
value: 1,
writable: false
})
const bar = Object.create(foo)
bar.v = 42 // 在严格模式下抛出 TypeError
结合上例子看,步骤 2.b~2.e 就显得有点奇怪。已知 ownDesc
是数据属性描述符的情况下,为什么还需要在步骤 2.d.i 判断 Receiver.[[GetOwnProperty]]
返回的描述符的类型呢?难道 ownDesc
和 existingDescriptor
有可能描述的是两个不同的对象吗?
在"对自身的属性进行赋值"的例子中,ownDesc
描述的是 foo
的 v
属性,existingDescriptor
描述的是 Receiver 的 v
属性。而之前说过,这个 Receiver 就是对象 obj
。所以它们描述的是同一个对象的同一个属性。而在"对继承的属性进行赋值"的例子中, ownDesc
描述的是 foo
的 v
属性,而 Reciver,也就是 obj
,并不包含 v
属性,因此 existingDescriptor
是 undefined
。结论就是要么 ownDesc
和 existingDescriptor
实际描述的就是同一属性,要么 existingDescriptor
就是 undefined
。不可能出现 ownDesc
和 existingDescriptor
描述不同对象的情况。除非,Receiver 根本就不是 obj
。
Reflect.set
是 ECMAScript 2015 引入的新特性。类似于赋值语句,Reflect.set
也可以用来对属性进行赋值。obj.foo = 42
就相当于 Reflect.set(obj, 'foo', 42)
。只不过 Reflect.set
还另外接收一个被称为 receiver 的参数。来看例子:
js
// receiver 有目标属性
const foo = {
v: 1
}
const bar = {
v: 2
}
Reflect.set(foo, 'v', 42, bar)
Object.getOwnPropertyDescriptor(foo, 'v') // 返回 { value: 1, ... }
Object.getOwnPropertyDescriptor(bar, 'v') // 返回 { value: 42, ... }
// receiver 有目标属性但是不可写
const foo = {
v: 1
}
const bar = {}
Object.defineProperty(bar, 'v', {
value: 2,
writable: false
})
Reflect.set(foo, 'v', 42, bar) // 返回 false
再来看 ownDesc
是访问器属性描述符的情况。和取值时类似,只不过这次调用的是获得的描述符的 setter,并将其中的 this
指向了 receiver。这时,即使 receiver 也拥有目标属性,也不会影响结果。最后再看一个例子:
js
const foo = {
set v(v) {
this._v = v
}
}
const bar = {
set v(v) {
this._v2 = v
}
}
Reflect.set(foo, 'v', 42, bar)
bar._v // 42
bar._v2 // undefined
其中,foo.v
的 setter 被执行,其中的 this
指向 bar
。