15.4 子类的细节
在 ECMAScript 6 中,子类看起来像下面这样。
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
···
}
class ColorPoint extends Point {
constructor(x, y, color) {
super(x, y);
this.color = color;
}
···
}
let cp = new ColorPoint(25, 8, 'green');
这段代码产生下面的对象。
下一小节讲解原型链(上图中的两列),该节的后面一节介绍了 cp
是如何分配内存和初始化的。
15.4.1 原型链
上上图中,可以看到有两条原型链(对象通过 [[Prototype]]
关系连接,该关系就是继承关系):
- 左侧的一列:类(函数)。继承类的原型是它继承的类。基类的原型是
Function.prototype
,Function.prototype
也是函数的原型:
> const getProto = Object.getPrototypeOf.bind(Object);
> getProto(Point) === Function.prototype
true
> getProto(function () {}) === Function.prototype
true
- 右侧的一列:实例的原型链。一个类的整个目的就是设置这条原型链。这条原型链以
Object.prototype
(Object
的原型是null
)结束,同时,Object.prototype
也是通过对象字面量创建的对象的原型:
> const getProto = Object.getPrototypeOf.bind(Object);
> getProto(Point.prototype) === Object.prototype
true
> getProto({}) === Object.prototype
true
图中左侧一列表明了静态方法也会被继承。
15.4.2 分配和初始化实例
类构造器之间的数据流和 ES5 规范中的继承方法有差异。实际上,看起来大致如下所示:
// Instance is allocated here
function Point(x, y) {
// Performed before entering this constructor:
this = Object.create(new.target.prototype);
this.x = x;
this.y = y;
}
···
function ColorPoint(x, y, color) {
// Performed before entering this constructor:
this = uninitialized;
this = Reflect.construct(Point, [x, y], new.target); // (A)
// super(x, y);
this.color = color;
}
Object.setPrototypeOf(ColorPoint, Point);
···
let cp = Reflect.construct( // (B)
ColorPoint, [25, 8, 'green'],
ColorPoint);
// let cp = new ColorPoint(25, 8, 'green');
在 ES6 和 ES5 中,实例对象创建的地方不一样:
- 在 ES6 中,在基类的构造器中创建,构造器调用链的末端。
- 在 ES5 中,在
new
操作符中创建,构造器调用链的始端。
前面的代码使用了两个新的 ES6 特性:
new.target
是一个隐式的参数,所有的函数都有。它用于构造器调用,而this
用于方法调用。- 如果构造器已经显示地通过
new
调用了,它的值就是当前构造器(行 B )。 - 如果构造器通过
super()
调用,它的值就是发出调用的构造器中的new.target
(行 A )。 - 在一个普通的函数调用中,它的值是
undefined
。这意味着可以使用new.target
来区分一个函数是否被当成普通函数调用还是被当成构造器调用(通过new
)。 - 在箭头函数内部,
new.target
指向祖先作用域中最近的一个非箭头函数中的new.target
。
- 如果构造器已经显示地通过
Reflect.construct()
完成构造器调用,最后一个参数用于指定new.target
。
此种子类化方式的优点就是让普通的代码能够子类化内置的构造器(比如 Error
和 Array
)。后面一节讲解了为什么需要一种不同的方式。
15.4.2.1 安全检查
- 在继承的构造器中,如果在
super()
调用之前就访问this
,会抛出错误,因为此时实例尚未创建,this
尚未初始化。 - 一旦
this
初始化了,调用super()
就会产生一个ReferenceError
错误。这避免了调用两次super()
的问题。 - 如果构造器隐式返回(不使用
return
),那么返回结果就是this
。如果this
未被初始化,会抛出ReferenceError
错误。这避免了忘记调用super()
的问题。 - 如果构造器显示地返回一个非对象(包括
undefined
和null
),最终返回结果就是this
(这种行为需要保持与 ES5 和更早的版本兼容)。如果this
未被初始化,就会抛出TypeError
错误。 - 如果构造器显示地返回一个对象,该对象会被用作最终返回结果。此时
this
是否初始化已经没有关系了。
15.4.2.2 extends
子句
让我们看看 extends
子句是如何影响类的设置的( Sect. 14.5.14 of the spec )。
extends
子句的值必须是“可构造的( constructible )”(可通过 new
调用)。但是允许为 null
。
class C {
}
- 构造器类型:基类构造器
C
的原型:Function.prototype
(类似于普通的函数)C.prototype
的原型:Object.prototype
(也是通过对象字面量创建的对象的原型)
class C extends B {
}
- 构造器类型:继承造器
C
的原型:B
C.prototype
的原型:B.prototype
class C extends Object {
}
- 构造器类型:继承构造器
C
的原型:Object
C.prototype
的原型:Object.prototype
注意下面的代码与第一种情况的微妙区别:如果没有 extends
子句,那么类就是积累并且负责创建实例。如果一个类继承自 Object
,就是一个继承类,并且 Object
创建实例。最终的实例(包括它们的原型)都是一样的,但是实例构建过程是不一样的。
class C extends null {
}
- 构造器类型:继承构造器
C
的原型:Function.prototype
C.prototype
的原型:null
这种类就避免了 Object.prototype
出现在原型链中。但是基本没有什么用。并且,必须得小心:通过 new
调用这样的类会抛出错误,因为默认的构造器会调用父构造器,而 Function.prototype
(父构造器)不能作为构造器调用。唯一一种避免该错的方式是添加一个返回对象的构造器
:
class C extends null {
constructor() {
let _this = Object.create(new.target.prototype);
return _this;
}
}
new.target
确保 C
能正确地继承 - _this
的原型总是 new
的操作数。
15.4.3 为什么在 ES5 中不能子类化内置构造器?
在 ECMAScript 5 中,绝大多数构造器不能被子类化(有几种迂回方法)。
要理解为什么,让我们使用标准的 ES5 方式去子类化 Array
。我们将会很快看到,这是不行的。
function MyArray(len) {
Array.call(this, len); // (A)
}
MyArray.prototype = Object.create(Array.prototype);
很不幸,如果实例化 MyArray
,我们发现不会正确地工作:在添加元素的时候,实例的 length
属性并不会自动变化:
> var myArr = new MyArray(0);
> myArr.length
0
> myArr[0] = 'foo';
> myArr.length
0
有两个障碍导致 myArr
无法成为一个正确的数组。
第一个障碍:初始化。传递给构造器 Array
(行 A 处)的 this
会被忽略。这意味着不能使用 Array
来设置 MyArray
创建的实例。
> var a = [];
> var b = Array.call(a, 3);
> a !== b // a is ignored, b is a new object
true
> b.length // set up correctly
3
> a.length // unchanged
0
第二个障碍:分配。通过 Array
创建的实例对象是特异的( exotic )( ECMAScript 规范使用这个术语来描述那些有不同于普通对象特性的对象): Array
创建的实例的属性 length
追踪和影响数组元素的管理。一般地,可以创建特异对象,但是不能将一个已存在的普通对象转换成特意对象。不幸的是,这就是 Array
必须要做的,在行 A 执行的时候:必须要将 MyArray
创建的普通的对象转换成特异对象。
15.4.3.1 解决方案: ES6 的子类化
在 ECMAScript 6 中,子类化 Array
看起来像下面这样:
class MyArray extends Array {
constructor(len) {
super(len);
}
}
这会有效(但是转换器肯定不会支持,这依赖于 JavaScript 本地引擎支持):
> let myArr = new MyArray(0);
> myArr.length
0
> myArr[0] = 'foo';
> myArr.length
1
现在可以看下 ES6 方式的子类化是怎么绕过这两个障碍的:
- 实例创建是在基构造器中进行的,这意味着
Array
可以生成一个特异的对象。相对于绝大多数的new
一个对象的方式都依赖于子构造器的行为,这一步需要基构造器知道new.target
,并且将new.target.prototype
设置为分配出来的实例的原型。 - 实例初始化也会在基构造器中进行,派生的构造器获得初始化了的对象,并运用这个对象继续执行,而不是将自己的实例传递给父构造器,然后让父构造器去设置这个实例。
15.4.4 在方法中访问父属性
下面的 ES6 代码在行 B 调用了一个父方法:
class Point {
constructor(x, y) {
this.x = x;
this.y = y;
}
toString() { // (A)
return `(${this.x}, ${this.y})`;
}
}
class ColorPoint extends Point {
constructor(x, y, color) {
super(x, y);
this.color = color;
}
toString() {
return super.toString() // (B)
+ ' in ' + this.color;
}
}
let cp = new ColorPoint(25, 8, 'green');
console.log(cp.toString()); // (25, 8) in green
要理解父调用的原理,让我们看下 cp
的对象图:
ColorPoint.prototype
调用(行 B )在本类中覆盖了的父类方法(开始于行 A )。我们称方法存放的对象是此方法的宿主对象( home object )。例如, ColorPoint.prototype
是 ColorPoint.prototype.toString()
的宿主对象。
在行 B 调用父方法,总共包含三个步骤:
1、开始在宿主对象的原型链上搜索当前方法。
2、找到名为 toString
的方法。
3、用当前的 this
调用找到的方法。这样做的原因是:调用的父方法一定要能够访问到相同的实例属性(在我们的例子中,是 cp
的属性)。
注意,即便仅获取或设置一个属性(不是调用方法),仍然需要在第三步中考虑 this
,因为这个属性可能是通过 getter 或 setter 实现的。
让我们用三种不同的,但是等价的方式来描述这些步骤:
// Variation 1: super-method calls in ES5
var result = Point.prototype.toString.call(this) // steps 1,2,3
// Variation 2: ES5, refactored
var superObject = Point.prototype; // step 1
var superMethod = superObject.toString; // step 2
var result = superMethod.call(this) // step 3
// Variation 3: ES6
var homeObject = ColorPoint.prototype;
var superObject = Object.getPrototypeOf(homeObject); // step 1
var superMethod = superObject.toString; // step 2
var result = superMethod.call(this) // step 3
第三种就是 ECMAScript 6 处理父调用的方式。这种方式通过两个内部绑定的变量来支持,这两个绑定变量是函数内部环境提供的(函数环境给作用域中的变量提供存储空间,所谓的绑定变量):
- [[thisValue]] :该内部绑定变量在 ECMAScript 5 中也存在,存放在
this
值中。 - [[HomeObject]] :指向环境函数的宿主对象。 [[HomeObject]] 是一个方法的内部属性,方法在被调用的时候,
super
就会指向这个对象。绑定变量和内部属性都是 ECMAScript 6 新引入的。
现在方法是一种特殊的函数
在类中,一个使用
super
的方法定义会创建一种特殊的函数:仍然是一个函数,但是有内部的[[HomeObject]]
属性。这个属性通过方法定义设定,在 JavaScript 代码中不能修改。因此,不可以自以为是地将这样的方法移到一个不同的对象上面去(但是这在将来的 ECMAScript 版本中可能可以这样做)。
15.4.4.1 什么地方可以使用 super
?
当原型链参与进来的时候,访问父属性就变得很方便,这就是为什么能在对象字面量和类(类既可以是被继承的,也可以是不被继承的;既可以是静态的,也可以是非静态的)的方法定义中使用 super
。
在下列场景中不能使用 super
访问属性:函数声明中,函数表达式中和生成器函数中。
15.4.4.2 不能移动使用 super
的方法
不能移动使用 super
的方法:这样的方法有一个内部的属性 [[HomeObject]]
,该属性与创建此方法的对象绑在一起。如果通过赋值的方式移动方法,方法中的 [[HomeObject]]
属性将继续指向原来的对象的父属性。在未来的 ECMAScript 版本中,可能会有一种方式来移动这样的方法。