类继承是一种扩展类的方式。我们能使用类继承,基于现存的类,创建新的功能。
“extends”关键字
有一个类 Animal
:
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
run(speed) {
this.speed += speed;
alert(`${this.name} runs with speed ${this.speed}.`);
}
stop() {
this.speed = 0;
alert(`${this.name} stands still.`);
}
}
let animal = new Animal("My animal");
下面我们用一张图表示 animal
对象和 Animal
类之间的关系:
接下来我们再创建一个 class Rabbit
。
rabbit(兔子)也是 animal(动物),因此 Rabbit
是基于 Animal
的,要能够访问后者的方法,这样兔子就具备了所有动物都具有的“一般”能力。
继承语法是这样的:class Child extends Parent
。
下面我来创建 class Rabbit
,继承自 Animal
:
class Rabbit extends Animal {
hide() {
alert(`${this.name} hides!`);
}
}
let rabbit = new Rabbit("White Rabbit");
rabbit.run(5); // White Rabbit runs with speed 5.
rabbit.hide(); // White Rabbit hides!
Rabbit
对象上既能调用 Rabbit
的方法,例如 rabbit.hide()
,也能调用 Animal
的方法,例如 rabbit.run()
。
extends
关键字在内部使用的依旧是原型(prototype)继承方案。将 Rabbit.prototype.[[Prototype]]
的值设置为 Animal.prototype
。因此,如果在 Rabbit.prototype
上没有找到方法,JavaScript 就会从 Animal.prototype
中查找使用。
假如,我们调用了 rabbit.run
方法,引擎是按照下面的方式查找的(从图上看,是自下往上的):
rabbit
对象(没有run
方法)- 它的原型,即
Rabbit.prototype
(有hide
方法,但无run
方法) - 它的原型,即
Animal.prototype
(因为extends
的作用),最好找到了run
方法。
JavaScript 的内置对象也在使用原型继承。比如,Date.prototype.[[Prototype]]
的值就是 Object.prototype
,这就是为什么在日期对象可以使用通用对象方法的原因。
💡
**extends**
之后可以跟任意的表达式
extends
> 之后不是只能跟类名的,还可以跟任意的表达式。
例如,> extends
> 一个能够生成类的函数调用:
```javascript function f(phrase) { return class { sayHi() { alert(phrase) } } }
class User extends f(“Hello”) {}
new User().sayHi(); // Hello
> <br />这里,> `class User`> 继承的是 > `f("Hello")` 的调用结果。> <br />> <br />这种模式对于高级编程模式可能很有用,即——当我们需要使用函数,根据多条件判断生成要继承的父类。> <br />
<a name="gwvvrt"></a>
## 重写方法
现在我们继续,来重写方法。默认,所有未在 `class Rabbit` 中定义的方法,都会从 `class Animal` 中去取。
但是,如果我们在 `Rabbit` 中定义了方法。拿 `stop()` 举例子,那么就不会使用父类里的同名方法了:
```javascript
class Rabbit extends Animal {
stop() {
// ... 现在使用的是 rabbit.stop()
// 而不是来自 class Animal 的 stop()
}
}
但有时,我们不希望完全覆盖父级方法,而是希望在它之上进行构建、调整或扩展它的功能。在子类方法里有书写自己的逻辑,同时在这些逻辑之前或之后,或是在逻辑执行的过程中,去调用了父级方法。
类为此提供了“super
”关键字。
super.method(...)
,即调用父级方法。super(...)
,即调用父级构造函数(仅能在constructor
中使用)。
举个例子,让我们的 rabbit
在停止时自动隐藏( autohide when stopped):
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
run(speed) {
this.speed += speed;
console.log(`${this.name} runs with speed ${this.speed}.`);
}
stop() {
this.speed = 0;
console.log(`${this.name} stopped.`);
}
}
class Rabbit extends Animal {
hide() {
console.log(`${this.name} hides!`);
}
stop() {
super.stop(); // 调用父级的 stop 方法
this.hide(); // 然后隐藏自己
}
}
现在,Rabbit
有一个 stop
方法,在调用这个方法的过程中使用了 super.stop()
调用父级的 stop
方法。
💡 箭头函数是没有 super 的**
如果我们在箭头函数里使用了super
,那么使用的将是外部函数的。例如:
class Rabbit extends Animal {
stop() {
setTimeout(() => super.stop(), 1000); // 1 秒钟后调用父级的 stop 方法
}
}
箭头函数中的
super
与stop()
中的一样,所以代码能按预期工作。如果这里使用的是个“普通”函数,就会报错:
// Unexpected super
setTimeout(function() { super.stop() }, 1000);
重写 constructor
构造函数这块内容要好好介绍一下。
到目前为止,Ranbbit
并没有设置自己的 constructor
。
根据 规范,如果一个类继承自另一个类,而且没有提供 constructor
,那么就会使用下面的默认构造器:
class Rabbit extends Animal {
// 默认的 constructor
constructor(...args) {
super(...args);
}
}
如你所见,它调用了父级构造函数,并将所有参数传递了过去。如果我们不写自己的构造函数,就会使用这个构造函数。
现在我们为 Rabbit
添加自定义构造器,指定 name
、earLength
属性:
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
// ...
}
class Rabbit extends Animal {
constructor(name, earLength) {
this.speed = 0;
this.name = name;
this.earLength = earLength;
}
// ...
}
// 不行
let rabbit = new Rabbit('小白兔', 10); // Error: this is not defined.
哎呀!报错了。现在我们不能成功创建 rabbit
,到底是是什么问题呢?
简短回答是:子类 constructor
中必须调用 super(...)
,并且 要在使用 this
之前调用。
但为什么呢?这个要求看起来有点奇怪。
当然,在深入细节后,我们就能真正理解原因了。
JavaScript 中,“子类构造函数”(即所谓的“衍生构造函数”)和其他函数还是有区别的。子类中,构造函数会用一个特殊的内部属性 [[ConstructorKind]]:"derived"
做标记。
这个标记影响着 new
的行为:
- 当在一个常规函数前使用
new
调用,结果会创建一个空对象,并将其赋值给this
。 - 但是,衍生构造函数并非是这样运行的,它把这个任务交给了父级构造函数来做。
为了让 Rabbit
构造函数顺序执行,我们需要在使用 this
之前调用 super()
:
class Animal {
constructor(name) {
this.speed = 0;
this.name = name;
}
// ...
}
class Rabbit extends Animal {
constructor(name, earLength) {
super(name);
this.earLength = earLength;
}
// ...
}
// 现在可以了
let rabbit = new Rabbit('小白兔', 10);
alert(rabbit.name); // 小白兔
alert(rabbit.earLength); // 10
Super:内部属性 [[HomeObject]]
高级部分
如果你是第一次阅读这里的内容,这部分可以暂时忽略不看。
这部分是介绍关于继承和 super 背后的内部算法机制。
我们继续深入 super
底层使用的知识点。一路上我们会看到一些有趣的事情。
首先要说的是,我们目前所掌握的知识点,并不能模拟实现 super
的功能。
我们可以问问自己, super
在技术上是如何实现的?当一个对象方法被执行时,它会得到当前对象 this
。如果我们调用 super.method()
,引擎需要从当前对象的原型中得到 method
。但怎么做的呢?
好像挺好实现的,但并非这样。引擎如何确定当前对象 this
的?就是使用 this.__proto__.method
得到父级 method
的?不幸地是,这种实现方法是“幼稚的”,并不管用。
简单起见,我们直接使用普通对象(不用类)来说明为什么不管用。
如果你对 super
底层运作机制不感兴趣,可以直接忽略这节内容,直接进入下一部分 [[HomeObject]] 的内容阅读。不碍事的。当然,如果你感兴趣向继续深入,请接着阅读吧。
下面例子里,rabbit.__proto__ = animal
。我们尝试:在 rabbit.eat()
里使用 this.__proto__
调用 animal.eat()
方法:
let animal = {
name: 'Animal',
eat() {
alert(`${ this.name } eats.`)
}
};
let rabbit = {
__proto__: animal,
name: 'Rabbit',
eat() {
// 我们假设的 super.eat() 的运行方式
this.__proto__.eat.call(this); // (*)
}
};
rabbit.eat(); // Rabbit eats.
在 (*)
处,我们使用当前上下文对象(即 this
),调用原型对象 animal
的 eat
方法。需要着重注意 .call(this)
这个地方,如果只是 this.__proto__.eat()
这种方式调用,使用的会是父类上下文对象(即 animal
)执行 eat
方法,而非当前对象 rabbit
。
上面的代码执行结果是符合我们假设的:我们能看到正确的弹出内容。
现在,我们再向原型链中添加一个对象。就能看到问题了:
let animal = {
name: "Animal",
eat() {
alert(`${this.name} eats.`);
}
};
let rabbit = {
__proto__: animal,
eat() {
// ...经 (**) 方式调用后,执行到此处,这里的 this 还是指向 longEar,
// 于是 this.__proto__ 等于 rabbit,
// 相当于,我们还是使用在 longEar 作为上下文对象,调用 rabbit.eat() 方法的
this.__proto__.eat.call(this); // (*)
}
};
let longEar = {
__proto__: rabbit,
eat() {
// ..longEar 调用父类 (rabbit) 方法
this.__proto__.eat.call(this); // (**)
}
};
longEar.eat(); // Error: Maximum call stack size exceeded
调用 longEar.eat()
后,报栈溢出错误了!
直接看结果,并不清晰,所以我们要来分析一下,其中的执行过程。我们从 longEar.eat()
调用出发,看看发生了什么。在 (*)
和 (**)
两处,this
指向的都是 longEar
。这就是说,(*)
处于 (**)
处的代码功能完全相同。
最终成为一个死循环了。
下面再具体分析一下:
longEar.eat()
中,(**)
处的this
指的是longEar
。
// 在 longEar.eat() 中,this 指带的是 longEar
this.__proto__.eat.call(this); // (**)
// 即
longEar.__proto__.eat.call(this);
// 即
rabbit.eat.call(this);
rabbit.eat()
中,(*)
处代码,我们原本希望的是调用更高一层的原型链对象。但发现this
被绑定成longEar
了。因此,这里的longEar.__proto__.eat
又变成rabbit.eat
了!
// 在 rabbit.eat() 中,this 还是指带 longEar
this.__proto__.eat.call(this) // (*)
// 即
longEar.__proto__.eat.call(this)
// 即(再一次)
rabbit.eat.call(this);
- 所以,
rabbit.eat()
调用最终成为一个死循环了。
说明这个问题,不是靠 this
就能解决的。
[[HomeObject]]
针对上面的问题,JavaScript 为函数添加了一个特殊的内部属性:[[HomeObject]]
,作为解决办法。
当一个函数被指定成类方法或对象方法时,其内部的 [[HomeObject]]
属性就指向这个类或对象。
super
就是使用这个内部属性查找父级原型和方法的。
下面,我们使用普通对象,看下 super
的工作机制:
let animal = {
name: 'animal',
eat() { // animal.eat.[[HomeObject]] 等于 animal
alert(`${this.name} eats.`);
}
};
let rabbit = {
__proto__: animal,
eat() { // rabbit.eat.[[HomeObject]] 等于 rabbit
super.eat();
}
};
let longEar = {
__proto__: rabbit,
name: 'Long Ear',
eat() { // longEar.eat.[[HomeObject]] 等于 longEar
super.eat();
}
};
// 执行正确!
longEar.eat(); // Long Ear eats.
归因于 [[HomeObject]]
的算法机制,我们最终得到了预期结果。像 longEar.eat
这样的方法,无需使用 this
,根据内部的 [[HomeObject]]
属性就能从拿到正确的父级原型和方法。
方法不是“自由的”
因为普通函数的执行环境并没有绑定到特定对象上,因此我们可以说它们是“自由的”。也正以为这样,方法才可以在不同的对象间传递,并指定在不同的 this 环境下执行。
而 [[HomeObject]]
的存在就违反了这一原则,因为它让方法记住了它们的对象。[[HomeObject]]
的值不可被修改,永远都是指向它最初定义时的所属对象。
语言中唯一用到 [[HomeObject]]
的地方,就是 super
。因此,如果方法里没有使用 super
,那么可以被认为是自由的,可以在不同对象里复制使用。
如果 super
使用不当,就会导致错误的结果输出。 举个例子:
let animal = {
sayHi() {
console.log(`I'm an animal`);
}
};
// rabbit 继承自 animal
let rabbit = {
__proto__: animal,
sayHi() {
super.sayHi();
}
};
let plant = {
sayHi() {
console.log("I'm a plant");
}
};
// tree 继承自 plant
let tree = {
__proto__: plant,
sayHi: rabbit.sayHi // (*)
};
tree.sayHi(); // I'm an animal (?!?)
tree.sayHi()
的调用结果是 "I'm an animal"
,这肯定是不对的。
里有很简单:
- 在
(*)
处,tree.sayHi
方法是从rabbit
复制的。我们可以假设是为了避免书写代码才这样做的。 tree.sayHi
的[[HomeObject]]
是绑定死指向rabbit
的。我们无法修改这个内部属性值。tree.sayHi()
内部代码里有句super.sayHi()
,然后根据[[HomeObject]]
绑定的值,就找到animal
上的方法了。
下面使用一张图表,说明发生了什么:
方法属性,而非函数属性
📝 译者注
在对象字面量中,声明方法有两种方式(如下)。method1
使用对象方法的简洁表示法,method2
则是声明普通对象方法的方式——我们把 method1
称为方法属性, method2
称为函数属性。
let obj = {
method1() {},
method2: function () {}
}
[[HomeObject]]
是被定义在类方法和普通对象方法里的。对普通对象而言,方法必须是以 method()
的形式指定,才会有 [[HomeObject]]
这个属性存在(注意不是以 "method: function()"
的方式)。
区别对我们来说可能不重要,但对 JavaScruipt 来说却是重要的。
下例中,我们使用 "method: function()"
形式声明的方法来做个比较。会看见报错——说 [[HomeObject]]
属性没设置,因此继承也不会起作用了。
let animal = {
eat: function() { // 我们故意写成这样,而不是 eat() {... 的形式
// ...
}
};
let rabbit = {
__proto__: animal,
eat: function() {
super.eat();
}
};
rabbit.eat(); // Error calling super (because there's no [[HomeObject]])
总结
- 继承类的语法:
class Child extends Parent
- 这样做的结果是,
Child.prototype.__proto__
会被赋值为Parent.prototype
,因此就可以使用继承方法了。
- 这样做的结果是,
- 如果在
Child
中,要显式书写constructor
:- 那么必须先在
Child
的构造函数中调用super()
。
- 那么必须先在
- 如果要重写父级里的方法:
- 我们可以在
Child
的方法里,使用super.method()
的方式调用Parent
的方法。
- 我们可以在
- 内部:
- 类或对象中的方法,会使用一个内部属性
[[HomeObject]]
来记住它所属的类或对象。这就是为什么super
可以正确解析父级方法的原因。 - 因此去复制一个内部包含
super
调用的方法到另一个对象里去的方式并不安全。
- 类或对象中的方法,会使用一个内部属性
同时:
- 箭头函数不存在自己的
this
和super
,它会使用外部的上下文环境中的this
和super
。
(完)
📄 文档信息
🕘 更新时间:2020/01/27
🔗 原文链接:http://javascript.info/class-inheritance