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

相关推荐
辻戋9 小时前
从零实现React Scheduler调度器
前端·react.js·前端框架
徐同保9 小时前
使用yarn@4.6.0装包,项目是react+vite搭建的,项目无法启动,报错:
前端·react.js·前端框架
Qrun10 小时前
Windows11安装nvm管理node多版本
前端·vscode·react.js·ajax·npm·html5
中国lanwp10 小时前
全局 npm config 与多环境配置
前端·npm·node.js
JELEE.11 小时前
Django登录注册完整代码(图片、邮箱验证、加密)
前端·javascript·后端·python·django·bootstrap·jquery
TeleostNaCl13 小时前
解决 Chrome 无法访问网页但无痕模式下可以访问该网页 的问题
前端·网络·chrome·windows·经验分享
前端大卫14 小时前
为什么 React 中的 key 不能用索引?
前端
你的人类朋友14 小时前
【Node】手动归还主线程控制权:解决 Node.js 阻塞的一个思路
前端·后端·node.js
小李小李不讲道理16 小时前
「Ant Design 组件库探索」五:Tabs组件
前端·react.js·ant design
毕设十刻16 小时前
基于Vue的学分预警系统98k51(程序 + 源码 + 数据库 + 调试部署 + 开发环境配置),配套论文文档字数达万字以上,文末可获取,系统界面展示置于文末
前端·数据库·vue.js