Cracking JavaScript 之属性值与原型链

⚠️ 本文并不涉及原型链是什么、如何创建等基础知识。如果有学习这方面基础知识的需求,这里推荐一下 Dr. Axel Rauschmayer 的 Speaking JavaScript

我们知道,原型链是 JavaScript 中一个非常重要的概念。当我们尝试访问对象的属性,而该对象并不包含目标属性时,JavaScript 就会沿着原型链进行搜索,直到找到需要的属性或者原型链上已没有更多对象。而在对象上能够访问到其原型的属性这一特性也被称为属性的继承。

本文是作者关于 ECMAScript 规范如何描述访问对象属性这一过程,以及原型链在其中起到的作用的分享。

预备知识

在正式内容开始之前,我们需要了解一些 ECMAScript 规范中关于对象的基础知识。

在 ECMAScript 规范中,每个对象有一系列所谓的内部方法(Internal Methods)以供调用。规范还特别指定了一些内部方法是所有对象都必须有的。比如:

  • [[Get]]:获得对象的属性。
  • [[Set]]:设置对象的属性。
  • [[GetPrototypeOf]]:获得对象的原型。Object.getPrototypeOf 内部调用了对象的这个内部方法。
  • [[SetPrototypeOf]]:设置对象的原型。同样,Object.setPrototypeOf 内部也是调用了这个内部方法。
  • ......

完整的列表在 Object Internal Methods and Internal Slots

除了这些必须的内部方法,如果对象还支持内部方法 [[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]] 又把任务交给抽象方法 OrdinaryGetOrdinaryGet 的步骤如下:

[[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]] 返回的描述符的类型呢?难道 ownDescexistingDescriptor 有可能描述的是两个不同的对象吗?

在"对自身的属性进行赋值"的例子中,ownDesc 描述的是 foov 属性,existingDescriptor 描述的是 Receiver 的 v 属性。而之前说过,这个 Receiver 就是对象 obj。所以它们描述的是同一个对象的同一个属性。而在"对继承的属性进行赋值"的例子中, ownDesc 描述的是 foov 属性,而 Reciver,也就是 obj,并不包含 v 属性,因此 existingDescriptorundefined。结论就是要么 ownDescexistingDescriptor 实际描述的就是同一属性,要么 existingDescriptor 就是 undefined。不可能出现 ownDescexistingDescriptor 描述不同对象的情况。除非,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

相关推荐
EricWang135815 分钟前
[OS] 项目三-2-proc.c: exit(int status)
服务器·c语言·前端
September_ning15 分钟前
React.lazy() 懒加载
前端·react.js·前端框架
web行路人25 分钟前
React中类组件和函数组件的理解和区别
前端·javascript·react.js·前端框架
番茄小酱00126 分钟前
Expo|ReactNative 中实现扫描二维码功能
javascript·react native·react.js
子非鱼92144 分钟前
【Ajax】跨域
javascript·ajax·cors·jsonp
超雄代码狂1 小时前
ajax关于axios库的运用小案例
前端·javascript·ajax
长弓三石1 小时前
鸿蒙网络编程系列44-仓颉版HttpRequest上传文件示例
前端·网络·华为·harmonyos·鸿蒙
小马哥编程1 小时前
【前端基础】CSS基础
前端·css
嚣张农民1 小时前
推荐3个实用的760°全景框架
前端·vue.js·程序员
周亚鑫2 小时前
vue3 pdf base64转成文件流打开
前端·javascript·pdf