ES6 Class的继承

注:这是一篇学习笔记,记录自己在ES6学习过程中按照自己的思路觉得应该记录的一些要点,方便以后查看和复习。参考:

阮一峰ES6的书籍中ES6 Class 的继承
我的ES6入门学习规划

这篇文章继续学习ES6面向对象编程的语法要点。

ES6提供了跟其它语言如java比较相像的继承方式,但本质上还是原型继承。

ES5的继承方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function ClassA(sColor) {
this.color = sColor;
}

ClassA.prototype.sayColor = function () {
alert(this.color);
};

function ClassB(sColor, sName) {
ClassA.call(this, sColor);
this.name = sName;
}

//下面三行代码比较重要
ClassB.prototype = new ClassA();
ClassB.prototype.constructor = ClassB;
ClassB.__proto__ = ClassA;

ClassB.prototype.sayName = function () {
alert(this.name);
};

console.log(ClassA.prototype.constructor === ClassA);// true
console.log(ClassB.prototype.constructor === ClassB);// true
console.log(new ClassB() instanceof ClassA);// true
console.log(new ClassB() instanceof ClassA);// true
console.log(ClassB.prototype.__proto__ === ClassA.prototype);//true
console.log(ClassB.__proto__ === ClassA);// true

ES6继承的形式

跟别的语言一样,都使用extend关键字,后面接父类名称:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Point {
}

class ColorPoint extends Point {
}


//1
console.log(Point.prototype.constructor === Point);// true
//2
console.log(ColorPoint.prototype.constructor === ColorPoint);// true
//3
console.log(new ColorPoint() instanceof Point);// true
//4
console.log(new ColorPoint() instanceof ColorPoint);// true
//5
console.log(ColorPoint.prototype.__proto__ === Point.prototype);//true
//6
console.log(ColorPoint.__proto__ === Point);// true

这个例子采用ES6来编写继承,效果与ES5一样。 观察这个例子(或者是ES5的那个例子)中的6个console.log,了解它们各自代表的含义:

  • 第1个与第2个描述的是类的构造函数与prototype之间的关联关系,这是js语言规定的;
  • 第3个与第4个是继承的特征表现,instanceof说明了ColorPoint的实例与ColorPoint和Point类,属于同一条原型链;
  • 第5个表示子类与父类的实例之间的继承,实例的原型链的就是这么构建起来的。通过实例的原型链查找一个实例属性,先从子类实例开始找,找不到就到子类实例原型上去找,如果子类实例原型上访问不到,则会到子类实例原型的原型上去查找,而子类实例原型的原型,正好是父类实例的原型
  • 第6个表示子类与父类的静态继承,类级别的原型链就是这么构建起来的。通过类的原型链查找一个类的属性,先从子类开始找,找不到就到子类的原型上去找,而子类的原型,正好是父类

最后2个也等价于:

1
2
Object.getPrototypeOf(ColorPoint.prototype) === Point.prototype;
Object.getPrototypeOf(ColorPoint) === Point;

对比ES5和ES6的继承,发现效果是差不多的,但是ES5必须通过下面的hack方式才能将ES6的继承特性实现:

1
2
3
ClassB.prototype = new ClassA();
ClassB.prototype.constructor = ClassB;
ClassB.__proto__ = ClassA;

如果不是特别注意的话,这三个点很有可能会被忽略掉。 ES6的继承则可以把这一切做地比较完美妥当。 另外ES6毕竟是语言级别的继承方式,还有新的特性可以使用,比如super关键字,可以很方面地在子类或子类实例调用父类的行为方法,这个是ES5里面没有的。

理解JS的原型链

首先要理解什么是类的原型,什么是类实例的原型。类的原型指的是类这个函数本身的原型,类实例的原型是指通过调用类的构造函数创建的实例对象的原型。在进行面向对象编程的时候,大部分情况都是把行为部署到类的实例上面去,然后通过实例方法来调用,少部分情况也会把行为直接部署在类级别上,然后通过静态方法来调用。继承帮助我们解决基础性、重复性的行为封装,因为有前面2种部署方式,所以继承也有两条继承的线。JS是通过2条原型链来实现这两种继承的。

实例的原型链继承,最关键的是把握以下几点:

  1. 类实例.__proto__指向的是类实例的原型,也就是类.prototype
  2. 类.prototype也是一个对象,它也有__proto__属性,它会指向父类.prototype;如果没有父类,它会指向Object.prototype
  3. Object.prototype.__proto__为null

所以如果想弄清楚一个类的实例,它的原型链是怎么样的,把它这个类、父类、以及原型对象都找出来即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class A{

}

class B extends A{

}

let b = new B();

// b.__proto__指向类实例的原型,也就是B.prototype
console.log(b.__proto__ === B.prototype); // true

// B.prototype也是一个对象,它.__proto__会指向A.prototype
console.log(B.prototype.__proto__ === A.prototype); // true

// 因为上面的结论成立,也说明子类的原型是父类的一个实例
console.log(B.prototype instanceof A); // true

// A没有继承于别的类,A.prototype就是Object类的一个实例
console.log(A.prototype instanceof Object); // true

// A.prototype就是Object类的一个实例,所以A.prototype.__proto__指向的是Object类的原型
console.log(A.prototype.__proto__ === Object.prototype); // true

虽然前面说到一个类,如果没有父类,它的prototype的__proto__属性会指向Object.prototype,能说这个类就继承于Object类吗?不能,因为继承是同时满足实例继承和类的静态继承的,前面的结论只能说明一个独立的类,在类实例层面,继承于Object类,但是类的静态继承方面并不是的,通过后面的内容就能明白这点了。

类的原型链继承,也有以下几个要点:

  1. 类.__proto__,指向的是父类;如果没有父类,类.__proto__指向Function.prototype,因为所有的类,都是Function的实例;
  2. Function.prototype是所有函数的原型对象,它是Object类的一个实例,所以它的__proto__属性指向的是Object.prototype
  3. Object.prototype.__proto__为null
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    class A{

    }

    class B extends A{

    }

    // B.__proto__指向是父类,也就是A
    console.log(B.__proto__ === A); // true

    // A没有继承别的类,A仅仅是Function的一个实例,它的__proto__指向Function的原型
    console.log(A.__proto__ === Function.prototype); // true

    // 注意:A不是继承于Function,A是Function的一个实例,所以A.__proto__ === Function不成立
    console.log(A.__proto__ === Function);// false

    // Function.prototype是Object类的一个实例
    console.log(Function.prototype instanceof Object); // true
    console.log(Function.prototype.__proto__ === Object.prototype); // true

值得提醒的是,A类没有继承别的类,但也不是继承于Function,它是Function的一个实例,所以它的原型就是Function.prototype。 我原本认为应该要把Function.__proto__考虑进去,看看是会怎么继续这个原型链,但是因为A.__proto__指向的并不是Function,所以Function.__proto__不是A类静态继承原型链的一部分,不能在这里去研究。

类的实例继承和类的静态继承,到这里就已经描述地很详细了,只要不是讨论Function类,其它类,都可以根据前面的结论来摸清它和它实例的继承原型链。最后也可以看到,不管是实例继承还是类的静态继承,所有继承的终点是Object.prototype,Object.prototype的__proto__指向的是null,从一个对象查找一个属性,如果找不到,最终都会找到Object.prototype的__proto__为止,这也是为啥原型链如果太长会影响到性能的原因。

Function.__proto__

1
2
console.log(Function instanceof Function); // true
console.log(Function.__proto__ === Function.prototype); // true

因为Function也是Function类的实例,所以Function的__proto__指向的也是Function类的原型,也就是Function.prototype。

super复用父类行为

在继承的场景中,子类在重写或自定义新的行为时,如果想复用父类的行为,需要使用ES6新使用的关键字super。 super既可以当做对象又可以当函数使用,它主要使用的位置可分为构造函数、实例方法和静态方法。 super在不同的位置,有完全不同的特性,所以需要掌握清楚,以便实际编码的时候减少未知的错误。

super作为函数调用

在子类构造函数中,可以把super当函数一样使用,它用来调用父类的构造函数,它在调用时,虽然是执行父类的构造函数,但是内部this绑定的是子类要构造的实例。子类必须在constructor方法中调用super方法,否则新建实例时会报错。这是因为:

ES5 的继承,实质是先创造子类的实例对象this,然后再将父类的方法添加到this上面(Parent.apply(this))。ES6 的继承机制完全不同,实质是先将父类实例对象的属性和方法,加到this上面(所以必须先调用super方法),然后再用子类的构造函数修改this。

同时ES6还有规定:

  1. 在子类的构造函数中,只有调用super之后,才可以使用this关键字,否则会报错
  2. 如果子类没有定义constructor方法,那么会自动加一个构造函数,并在内部调用super

ES6这种super方式来复用父类构造函数的方式,比较简单好理解,这样的话,父类实例化过程中,得到的所有非原型的实例属性和实例方法,都能够在子类实例化的时候处理一遍,最后子类实例就完全地同步了父类实例所有非原型的实例成员拉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class A {
_dis = 0;
constructor(x) {
this._x = x;

this.hi = function () {
console.log('test hi');
}
}

// _dis _x hi 这三个属性在A类实例化的时候都属于非原型的实例属性或方法

getDis() {
return this._dis;
}

// getDis 是原型上的实例方法
};

let a = new A();
console.log(a.hasOwnProperty('_dis'));// true
console.log(a.hasOwnProperty('_x'));// true
console.log(a.hasOwnProperty('hi'));// true
console.log(a.hasOwnProperty('getDis'));// false

class B extends A{
//默认会被添加一个构造函数,并调用super
}

let b = new B();
console.log(b.hasOwnProperty('_dis'));// true
console.log(b.hasOwnProperty('_x'));// true
console.log(b.hasOwnProperty('hi'));// true
console.log(b.hasOwnProperty('getDis'));// false

从上面的例子可以很清楚地看到,super为子类实例复制了父类实例相同的非原型的实例成员。

前面记录到super在调用过程里面,this绑定的是子类的实例,可以通过一个简单的例子来验证这一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14

let tmp = null;

class A {
constructor() {
tmp = this;
}
};

class B extends A {
}

let b = new B();
b === tmp;

tmp作为一个全局变量,始终记录A类构造函数被调用过程中的this指向,最后b === tmp说明了B类实例化过程中,super调用时的this确实为B类实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14

let tmp = null;

class A {
constructor() {
tmp = this;
}
};

class B extends A {
}

let b = new B();
b === tmp;

上一篇笔记记录到如何通过new.target如果在父类构造函数中使用,那么在做子类实例化的时候,它会指向子类的构造函数,可以用下面的例子来印证:

1
2
3
4
5
6
7
8
9
10
11
12
class A {
constructor() {
console.log(new.target.name);
}
}
class B extends A {
constructor() {
super();
}
}
new A() // A
new B() // B

在子类构造函数中,把super当函数使用,是super关键字唯一被作为函数使用的场景,其它地方把它用作函数都会报错。

super作为对象使用

要区分两种场景,一种是在构造函数、实例方法或原型方法中使用super对象,另外一种是在静态方法中使用super对象。

在构造函数、或原型方法中使用super对象

在这个场景下,super对象指向为父类的原型对象,它的作用是帮助我们重用父类原型上的行为,尤其是在子类需要覆盖父类的同名方法的时候,特别有用。

所以可以在子类的构造函数中、原型方法中,通过super来访问父类原型对象上的属性或方法,且在super调用父类原型方法时,内部this会绑定为子类实例。由于super指向父类的原型对象,所以定义在父类实例上的方法或属性,是无法通过super调用的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class A {
_content = '';
_tag = 'A';

constructor(content) {
this._content = content;
}

render() {
return `<${this._tag}>${this._content}</${this._tag}>`;
}
}

A.prototype.id = 'a_pro';

class B extends A {
_tag = 'B';

constructor(content){
super(content);
this.test();

this.test2 = function () {
// 这里面无法使用super关键字的:ES6规定super只能用在对象的方法当中。目前,只有对象方法的简写法可以让 JavaScript 引擎确认,定义的是对象的方法。
}
}

test() {
//super作为对象在构造函数、原型方法中使用,指向的是父类原型对象
console.log(super.render === A.prototype.render); // true

//可以通过super调用父类原型方法,并且内部this绑定的是子类实例
console.log(super.render()); // <B>hello world</B>

//可以通过super访问到父类原型上定义的属性
console.log(super.id); // a_pro

//super指向的是父类的原型对象,所以无法取到父类的实例属性
console.log(super._content); // undefined
}
}

let b = new B('hello world');
b.test();

test方法最终会调用两次,一次是在构造函数中,一次是最后的主动调用,打印结果都是一样的。

有一种很特殊的情况,在ES6这本书里面有介绍:

由于this指向子类实例,所以如果通过super对某个属性赋值,这时super就是this,赋值的属性会变成子类实例的属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class A {
constructor() {
this.x = 1;
}
}

class B extends A {
constructor() {
super();
this.x = 2;
super.x = 3;
console.log(super.x); // undefined
console.log(this.x); // 3
}
}

let b = new B();

上面代码中,super.x赋值为3,这时等同于对this.x赋值为3。而当读取super.x的时候,读的是A.prototype.x,所以返回undefined。

这个结果虽然是看到了,但是并没有把原因解释清楚,既然super指向的是父类原型对象,取值的时候是去原型对象找属性,赋值的时候为啥不是给原型对象赋值呢?除非说super的赋值行为,在ES6的内部做了处理,等同于方法调用,由于方法调用内部this都是指向子类实例,所以给super赋值,最后等同于给子类实例赋值。

从我的想法来说,上面属于一种比较奇怪的现象,在实际编程的时候,我们是不应该去给super赋值的,那意味着子类的行为有篡改父类行为的含义,这种方式是违背封装原则的。

在静态方法中使用super对象

在子类的静态方法中通过super调用父类的方法时,方法内部的this指向当前的子类,而不是子类的实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class A {
x = 1;// 父类的实例属性x
static x = 2; // 父类的静态属性x

static getX() {// 父类的静态方法x
console.log(this.x);
}
}

class B extends A {
x = 3;// 子类的实例属性x
static x = 4; //子类的静态属性x
}

B.getX();// 4

其它补充

由于对象总是继承其他对象的,所以可以在任意一个字面量对象中,都能使用super关键字。

原生类的继承

1
2
3
4
5
6
7
8
9
Boolean()
Number()
String()
Array()
Date()
Function()
RegExp()
Error()
Object()

ES6以前,无法继承这些原生类。ES6可以了。

1
2
3
4
5
6
7
8
9
10
11
12
class MyArray extends Array {
constructor(...args) {
super(...args);
}
}

var arr = new MyArray();
arr[0] = 12;
arr.length // 1

arr.length = 0;
arr[0] // undefined

注意,继承Object的子类,有一个行为差异。

1
2
3
4
5
6
7
class NewObj extends Object{
constructor(){
super(...arguments);
}
}
var o = new NewObj({attr: true});
o.attr === true // false

上面代码中,NewObj继承了Object,但是无法通过super方法向父类Object传参。这是因为 ES6 改变了Object构造函数的行为,一旦发现Object方法不是通过new Object()这种形式调用,ES6 规定Object构造函数会忽略参数。

多重继承

mixin模式可以帮助我们实现多重继承,目前对多重继承在ES6中应用没有多大的兴趣,暂时只是把这个点留在这里备忘,将来有需要的时候可以考虑使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function mix(...mixins) {
class Mix {
constructor() {
for (let mixin of mixins) {
copyProperties(this, new mixin()); // 拷贝实例属性
}
}
}

for (let mixin of mixins) {
copyProperties(Mix, mixin); // 拷贝静态属性
copyProperties(Mix.prototype, mixin.prototype); // 拷贝原型属性
}

return Mix;
}

function copyProperties(target, source) {
for (let key of Reflect.ownKeys(source)) {
if ( key !== 'constructor'
&& key !== 'prototype'
&& key !== 'name'
) {
let desc = Object.getOwnPropertyDescriptor(source, key);
Object.defineProperty(target, key, desc);
}
}
}