从ECMAScript Specification中学习对象和原型

刘木林
本文不细究ECMAScript和JavaScript,由于是根据ECMA-262进行解读,因此全文统一使用ECMAScript,简称ES,本文使用当前最新的标准文档(2021-04-05),链接如下:https://tc39.es/ecma262/

关于对象和原型的解析,目前已经存在非常多的优质文章,但是大部分的文章主要是讲解和分析现象,而没有从底层原理或者定义层面进行分析,因此读完之后可能只是学习到了现象,但是没有了解到原因。

本文则试图从语言标准的角度入手,看看标准中是如何描述对象和原型的,希望能够给大家提供一种不同的理解视角。当然功力有限,有问题的地方也请大家多多指教。

面向对象的编程语言

标准第4节 Overview中提到:

ECMAScript is an object-oriented programming language for performing computations and manipulating computational objects within a host environment.

4.3小节中又提到:

ECMAScript is object-based: basic language and host facilities are provided by objects, and an ECMAScript program is a cluster of communicating objects.

通过这两段的描述,我们可以确切地说:ECMAScript是一个面向对象的编程语言,它是基于对象的,核心能力也是由对象所提供的。

那么究竟什么是对象呢?4.4.6小节将对象(object)定义为:member of the type Object,也就是Object类型的成员,乍一眼看上去有点懵,别急,这里还有解释:

An object is a collection of properties and has a single prototype object. The prototype may be the null value.

这样看起来就好理解一些了:对象是属性的集合,并且拥有一个唯一的原型对象(可能是null)

需要注意: 这里的原型对象和构造器的原型对象是不一样的,下文中统一使用[[prototype]]来表示对象的原型属性。

其次还需要提示一点,即4.3.1小节的描述:

Even though ECMAScript includes syntax for class definitions, ECMAScript objects are not fundamentally class-based such as those in C++, Smalltalk, or Java.

也就是说,ES并不是基于类(通过类创建对象)的面向对象语言,而是基于原型的(后文中会逐步得到解释),这和C++,Java等语言是不同的。这就是我们平时所说的,ES中的class其实只是语法糖。

创建对象的方式

继续来看4.3.1小节的描述:

Instead objects may be created in various ways including via a literal notation or via constructors which create objects and then execute code that initializes all or part of them by assigning initial values to their properties.

Objects are created by using constructors in new expressions

可以看出,创建对象的方式包括:字面量符号构造器,红宝书中介绍了多种不同的创建对象的方式,但底层逻辑就是这两种。

对象字面量

字面量的方式非常简单,比如通过如下代码就可以创建一个对象字面量(object literal):

{
    name: 'lml'
}

构造器

使用构造器(以下称为constructor),通过new操作符创建对象。这里就有两个问题,构造器是什么?new操作符是怎么创建对象的?

首先我们来理解constructor的定义(4.4.7小节):function object that creates and initializes objects

The value of a constructor's "prototype" property is a prototype object that is used to implement inheritance and shared properties.

此外还有4.3.1小节的描述:

Each constructor is a function that has a property named "prototype" that is used to implement prototype-based inheritance and shared properties.

通过这些描述可以看出,constructor是函数对象,它的原型属性值是一个原型对象,这个对象用于实现继承和属性共享,这里就可以解释前文提到的:”ES是基于原型的面向对象语言“。(注意这里的原型对象和上文中的[[prototype]]进行区分)

其次我们来看new操作符是如何创建对象的,相信大家对于这个过程也是非常熟悉了(由于标准中的描述过程涉及到非常多的前置抽象概念,这里直接引用MDN中的描述):

1.Creates a blank, plain JavaScript object.<br/>2.Adds a property to the new object (\_\_proto\_\_) that links to the constructor function's prototype object<br/>3.Binds the newly created object instance as the this context (i.e. all references to this in the constructor function now refer to the object created in the first step).<br/>4. Returns this if the function doesn't return an object.

总结一下这个过程所产出的结果:
返回的对象会拥有constructor中所有this语句赋值的属性,同时返回对象的[[prototype]]属性指向了constructor的原型对象。

简单看一个例子:

function Animal(name) {
    this.name = name;
}

Animal.prototype.getName = function() {
    return this.name;
}

const animal = new Animal('miky');
console.log(name);

看一下执行结果:

image.png

原型与原型链

原型

前面的内容其实已经讲到了原型这一概念,我们首先还是看一下标准中的定义(4.4.8):
object that provides shared properties for other objects

When a constructor creates an object, that object implicitly references the constructor's "prototype" property for the purpose of resolving property references. <br/><br/>The constructor's "prototype" property can be referenced by the program expression constructor.prototype, and properties added to an object's prototype are shared, through inheritance, by all objects sharing the prototype.<br/><br/>Alternatively, a new object may be created with an explicitly specified prototype by using the Object.create built-in function.

这段描述有以下几个点需要注意:

  • 当构造器创建对象时,创建出的object会有一个隐式的引用指向构造器的prototype对象,目的是解决object的属性引用。什么是属性引用呢?可以简单理解为对象属性的查找过程,这在下面的原型链部分会讲到。
  • 构造器的prototype属性在代码中可以使用constructor.prototype获取,任何添加到prototype的属性都可以被该构造器创建出的对象共享。
  • 我们还可以使用Object.create方法显式地指定[[prototype]]来创建对象,这是ES5中引入的方法

我们基于上面的代码进行简单的改写:

function Animal(name) {
    this.name = name;
}

Animal.prototype.getName = function () {
    return this.name;
}

const animal = new Animal('miky');
console.log(animal.name, animal.category);

Animal.prototype.category = 'animal';

console.log(animal.name, animal.category);

const animalByObjectCreate = Object.create(Animal.prototype);

console.log(animalByObjectCreate.name,  animalByObjectCreate.category);

输出结果为:<br/>
miky undefined<br/>
miky animal<br/>
undefined animal

这里我们再补充两点:

  1. 上文中提到constructor的prototype可以直接通过constructor.prototype获取,比如Animal.prototype。那么对象的[[prototype]]如何获取呢?一般有两个方法:

    - 通过Object.getPrototypeOf(object),这是ES5中引入的方法
    - 通过object.\_\_proto\_\_属性,但这个特性是非标准特性,不推荐使用
    
    比如上面示例中:`Object.getPrototypeOf(animal) === Object.getPrototypeOf(animalByObjectCreate) === Animal.prototype`
    
  2. constructor的prototype会有一个”constructor“属性指向构造器函数本身,比如上面示例中:Animal.prototype.constructor === Animal

原型链

还是基于上面的Animal构造器和animal对象,我们写这样一段代码:console.log(animal.toString()),则会输出[object Object]。但无论是animal对象本身,还是其[[prototype]]对象是都没有toString属性方法的,为什么还是可以正常调用呢?这就是原型链的作用了,我们首先看一下标准中是怎么描述原型链的(4.3.1):

Every object created by a constructor has an implicit reference (called the object's prototype) to the value of its constructor's "prototype" property. <br/><br/>Furthermore, a prototype may have a non-null implicit reference to its prototype, and so on; this is called the prototype chain.

大致的含义就是:constructor创建的对象,会有一个隐式的引用指向其prototype对象,同时,其prototype对象也会有一个隐式的非null引用指向它自己的[[prototype]]对象。以此类推,便形成了一条可遍历的链条,这就是原型链

原型链的作用就是用于对象的属性解析,还是来看一下标准中的描述(4.3.1):

When a reference is made to a property in an object, that reference is to the property of that name in the first object in the prototype chain that contains a property of that name.

In other words, first the object mentioned directly is examined for such a property; if that object contains the named property, that is the property to which the reference refers; if that object does not contain the named property, the prototype for that object is examined next; and so on.

当需要对象上的一个属性引用时,首先会检查对象本身是否包含这个同名属性;如果对象不包含这个属性,则去检查对象的[[prototype]]对象,以此类推,直到找到该属性或[[prototype]]为null。

基于此,我们可以简单画一下上面toString属性方法的寻找过程,当然,真实的遍历过程在找到toString()后就结束了。

至于为什么toString出现在了Object.prototype对象中,我们会在下一节中提到。

Object 和 Function

既往很多和原型或原型链相关的题目中,Function和Object这两个构造器对象都是比较容易把人绕进去的,这一节我们还是通过标准中对这两个对象的定义来梳理一下它们的prototype和[[prototype]]。

标准在20章介绍了一些Fundamental Objects,也就是基础对象,基础对象的重要性是不言而喻的,甚至可以说,这些基础对象是所有其他对象的本源(祖先)。而这一章的第1,2小节就分别介绍了Object和Function,可以看出这二者的重要性。

在正式介绍之前,我们首先需要明确,Object和Function既是对象,又是构造器,在理解相关原型链的题目时,一定要首先区分出它们是作为对象还是构造器在使用,这一点非常重要!

Object

Object的相关介绍在20.1小节

Object构造器的属性

20.1.2 Properties of the Object Constructor:<br/>has a [[Prototype]] internal slot whose value is %Function.prototype%.<br/>has a "length" property.<br/>has the following additional properties:...
  1. Object的隐式原型指向的是Function.prototype,也就是:Object.getPrototypeOf(Object) === Function.prototype
  2. Object有一个length属性,值为1(Object.length === 1),默认是不可写的
  3. Object还有很多属性,这些属性中,除了Object.prototype之外,其他都是我们所熟悉的Object的静态方法,具体有哪些可以直接看MDN(链接)

Object.prototype就是Object constructor的原型对象了,这个对象上的属性会被所有Object的实例共享,下面我们重点看一下这个对象

Object.prototype的属性

首先,标准中定义,Object.prototype属性的特性是这样的:{ [[Writable]]: false, [[Enumerable]]: false, [[Configurable]]: false },因此我们是无法对Object的prototype属性做任何重写或者配置的。比如Object.prototype = {}是无效的,严格模式下会报错。我们选取和本文最相关的部分进行介绍:

  1. has a [[Prototype]] internal slot whose value is null.也就是说:Object.getPrototypeOf(Object.prototype) === null
  2. The initial value of Object.prototype.constructor is %Object%.
    也就是说:Object.prototype.constructor === Object
  3. 其他属性基本也都是我们非常熟悉的方法属性了,包括hasOwnProperty,isPrototypeOf,toString等等,具体可以参考MDN(链接)

Object实例的属性

Object instances have no special properties beyond those inherited from the Object prototype object.

Object的实例除从Object.prototype继承的属性之外,没有其他特殊的属性。另外,除了通过new Object(argument)创建的对象是Object instance之外,对象字面量也可以视为Object的实例,它的[[prototype]]是指向Object.prototype的。

Function

Function的相关介绍在20.2小节

Function构造器的属性

  1. has a [[Prototype]] internal slot whose value is %Function.prototype%.:这个很重要,意味着:Object.getPrototypeOf(Function) === Function.prototype
  2. Function.length: 初始值为1
  3. Function.prototype:Function的原型对象,和Object.prototype属性的特性是相同的,因此也无法对其进行任何重写或者配置

Function.prototype的属性

  1. has a [[Prototype]] internal slot whose value is %Object.prototype%.:意味着:Object.getPrototypeOf(Function.prototype) === Object.prototype
  2. has a "length" property whose value is +0𝔽.has a "name" property whose value is the empty String.,意味着:Function.prototype.length === 0; Function.prototype.name === '';
  3. The initial value of Function.prototype.constructor is %Function%.: Function.prototype.constructor === Function
  4. 其他的属性包括了apply,call和bind方法,这些都是我们非常熟悉的方法,这也就是为什么我们的自定义函数可以直接调这个方法的原因,因为我们自定义函数的[[prototype]]默认都是指向Function.prototype,沿着原型链是能够找到这几个方法的。

这里需要额外注意一个细节,即Function.prototype does not have a "prototype" property.,同时:

The Function prototype object is specified to be a function object to ensure compatibility with ECMAScript code that was created prior to the ECMAScript 2015 specification.

这也就意味着,typeof Function.prototype === 'function'(简单说就是一个函数,并且可以被调用),而不是'object',但是虽然它是一个函数对象,但是却没有原型对象,也无法作为一个构造器(对其使用new操作符会报错)。这是一个标准的向下兼容问题,大家注意这个细节就好。

Function实例的属性

首先需要定义一下Function实例的范围,可以简单回顾一下创建函数的几种方式:

  • 函数声明
  • 函数表达式
  • Function构造函数
  • Function.prototype.bind方法
    随着标准的不断演进,函数类型也增加了包括箭头函数、generator函数、async函数等不同的类型,我们可以将通过上述这些方式创建出来的函数都认为是Function对象的实例。
  1. length属性:The value of the "length" property is an integral Number that indicates the typical number of arguments expected by the function.,可以理解为是函数所期望的参数数目,但是函数调用时实际传入的参数数目可能会不同
  2. name属性:The value of the "name" property is a String that is descriptive of the function. ,其实就是函数的名称,匿名函数的name属性值为空字符串
  3. prototype属性:
    prototype属性是非常重要的,首先我们看一下这个属性的特性:
    { [[Writable]]: true, [[Enumerable]]: false, [[Configurable]]: false }。可以看出,prototype属性是可写的,但是不可枚举和配置,也就是我们可以将函数实例的prototype重新进行指向。
Function instances that can be used as a constructor have a "prototype" property.

这段话同样非常重要,首先需要明确:只有能作为构造器的函数实例才会有prototype属性,标准也指出了async函数,generator函数,箭头函数和通过bind方法所创建的函数是没有prototype属性的,因此这些函数也无法作为构造器使用。

Function objects created using Function.prototype.bind, or by evaluating a MethodDefinition (that is not a GeneratorMethod or AsyncGeneratorMethod) or an ArrowFunction do not have a "prototype" property.

继续看标准对函数实例的prototype属性的描述:

Whenever such a Function instance is created another ordinary object is also created and is the initial value of the function's "prototype" property. Unless otherwise specified, the value of the "prototype" property is used to initialize the [[Prototype]] internal slot of the object created when that function is invoked as a constructor.

同样是非常重要的一段描述,我们可以得出以下几个结论:

  • 函数实例的prototype初始值是一个”ordinary object“,其定义是:object that has the default behaviour for the essential internal methods that must be supported by all objects。这段定义可能不是非常好理解,实际的实现中一般是一个Object实例对象,拥有一个初始的constructor属性指向构造器函数
  • 如果不额外指定的话,当作为构造器被调用时,这个初始值就是其创建的实例对象的[[prototype]]属性值,这一点在前文中也提到过多次了。当然我们同样也可以改写一个Functino instance的prototype指向,这也是实现继承的底层原理。

通过上文中的介绍,我们可以画一下Function和Object相关的原型链图,其实只要了解了标准中所明确的内容,还是比较容易画出来的:

除了Object和Function,标准中还详细介绍了Boolean,String,Array等内置对象,并且明确了这些内置对象的prototype和[[prototype]]属性,相信读完之后,原型和原型链相关的题目就能够更好地理解和记忆了。

阅读 301
1 声望
0 粉丝
0 条评论
你知道吗?

1 声望
0 粉丝
文章目录
宣传栏