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]] 又把任务交给抽象方法 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。

相关推荐
福兮说7 小时前
设计稿是 #4A7C6F,页面量出来是 #4B7C6F:HEX、HSL、透明度、canvas 来回转的七个坑
前端·javascript·css·canvas
郑州光合科技余经理7 小时前
海外版外卖加盟:总站与分站配送规则怎么分开管
java·开发语言·前端·后端·uni-app·php·ai编程
小呆呆6668 小时前
副业搞起来,小说,漫画,漫剧的成本优化思路
前端·后端·面试
凤城老人8 小时前
从 PyQt6 到 Electron:给 Edge TTS 做一个“多角色配音机“的踩坑手记
javascript·typescript·electron
Dovis(誓平步青云)8 小时前
浇水提醒刚弹出又消失,植物状态别只存一个百分比
开发语言·前端·javascript·pdf·ecmascript·电脑
码艺-Alimjan9 小时前
Vben Admin 新增维吾尔语 Vben-Modal的关键坑之一
前端·javascript·vue.js
可乐鸡翅yeah_10 小时前
hls.js 手动自定义 http 请求 loader,修改请求头实战
开发语言·前端·javascript·网络协议·http·ecmascript·m3u8在线
IT_陈寒10 小时前
Vite静态资源导入这个坑我帮你们踩过了
前端·人工智能·后端
广州华水科技10 小时前
大坝安全监测解决方案:单北斗GNSS形变监测系统应用与维护
前端
yivifu11 小时前
中文古籍电子书注释集成
前端·javascript·python·beautifulsoup·epub