类继承是一个类扩展另一个类的一种方式。
因此,我们可以在现有功能之上创建新功能。
“extends” 关键字
假设我们有 class 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
和 class Animal
的图形化表示:
……然后我们想创建另一个 class Rabbit
:
因为 rabbits 是 animals,所以 class Rabbit
应该是基于 class Animal
的,可以访问 animal 的方法,以便 rabbits 可以做“一般”动物可以做的事儿。
扩展另一个类的语法是:class Child extends Parent
。
让我们创建一个继承自 Animal
的 class Rabbit
:
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!
Class Rabbit
的对象可以访问例如 rabbit.hide()
等 Rabbit
的方法,还可以访问例如 rabbit.run()
等 Animal
的方法。
在内部,关键字 extends
使用了很好的旧的原型机制进行工作。它将 Rabbit.prototype.[[Prototype]]
设置为 Animal.prototype
。所以,如果在 Rabbit.prototype
中找不到一个方法,JavaScript 就会从 Animal.prototype
中获取该方法。
例如,要查找 rabbit.run
方法,JavaScript 引擎会进行如下检查(如图所示从下到上):
- 查找对象
rabbit
(没有run
)。 - 查找它的原型,即
Rabbit.prototype
(有hide
,但没有run
)。 - 查找它的原型,即(由于
extends
)Animal.prototype
,在这儿找到了run
方法。
我们可以回忆一下 原生的原型 这一章的内容,JavaScript 内建对象同样也使用原型继承。例如,Date.prototype.[[Prototype]]
是 Object.prototype
。这就是为什么日期可以访问通用对象的方法。
在 extends
后允许任意表达式
类语法不仅允许指定一个类,在 extends
后可以指定任意表达式。
例如,一个生成父类的函数调用:
function f(phrase) {
return class {
sayHi() { alert(phrase) }
}
}
class User extends f("Hello") {}
new User().sayHi(); // Hello
这里 class User
继承自 f("Hello")
的结果。
这对于高级编程模式,例如当我们根据许多条件使用函数生成类,并继承它们时来说可能很有用。
重写方法
现在,让我们继续前行并尝试重写一个方法。默认情况下,所有未在 class Rabbit
中指定的方法均从 class Animal
中直接获取。
但是如果我们在 Rabbit
中指定了我们自己的方法,例如 stop()
,那么将会使用它:
class Rabbit extends Animal {
stop() {
// ……现在这个将会被用作 rabbit.stop()
// 而不是来自于 class Animal 的 stop()
}
}
但是通常来说,我们不希望完全替换父类的方法,而是希望在父类方法的基础上进行调整或扩展其功能。我们在我们的方法中做一些事儿,但是在它之前或之后或在过程中会调用父类方法。
Class 为此提供了 "super"
关键字。
- 执行
super.method(...)
来调用一个父类方法。 - 执行
super(...)
来调用一个父类 constructor(只能在我们的 constructor 中)。
例如,让我们的 rabbit 在停下来的时候自动 hide:
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.`);
}
}
class Rabbit extends Animal {
hide() {
alert(`${this.name} hides!`);
}
stop() {
super.stop(); // 调用父类的 stop
this.hide(); // 然后 hide
}
}
let rabbit = new Rabbit("White Rabbit");
rabbit.run(5); // White Rabbit 以速度 5 奔跑
rabbit.stop(); // White Rabbit 停止了。White rabbit hide 了!
现在,Rabbit
在执行过程中调用父类的 super.stop()
方法,所以 Rabbit
也具有了 stop
方法。
箭头函数没有 super
正如我们在 深入理解箭头函数 一章中所提到的,箭头函数没有 super
。
如果被访问,它会从外部函数获取。例如:
class Rabbit extends Animal {
stop() {
setTimeout(() => super.stop(), 1000); // 1 秒后调用父类的 stop
}
}
箭头函数中的 super
与 stop()
中的是一样的,所以它能按预期工作。如果我们在这里指定一个“普通”函数,那么将会抛出错误:
// 意料之外的 super
setTimeout(function() { super.stop() }, 1000);
重写 constructor
对于重写 constructor 来说,则有点棘手。
到目前为止,Rabbit
还没有自己的 constructor
。
根据 规范,如果一个类扩展了另一个类并且没有 constructor
,那么将生成下面这样的“空” constructor
:
class Rabbit extends Animal {
// 为没有自己的 constructor 的扩展类生成的
constructor(...args) {
super(...args);
}
}
正如我们所看到的,它调用了父类的 constructor
,并传递了所有的参数。如果我们没有写自己的 constructor,就会出现这种情况。
现在,我们给 Rabbit
添加一个自定义的 constructor。除了 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("White Rabbit", 10); // Error: this is not defined.
哎呦!我们得到了一个报错。现在我们没法新建 rabbit。是什么地方出错了?
简短的解释是:继承类的 constructor 必须调用 super(...)
,并且 (!) 一定要在使用 this
之前调用。
……但这是为什么呢?这里发生了什么?确实,这个要求看起来很奇怪。
当然,本文会给出一个解释。让我们深入细节,这样你就可以真正地理解发生了什么。
在 JavaScript 中,继承类(所谓的“派生构造器”,英文为 “derived constructor”)的构造函数与其他函数之间是有区别的。派生构造器具有特殊的内部属性 [[ConstructorKind]]:"derived"
。这是一个特殊的内部标签。
该标签会影响它的 new
行为:
- 当通过
new
执行一个常规函数时,它将创建一个空对象,并将这个空对象赋值给this
。 - 但是当继承的 constructor 执行时,它不会执行此操作。它期望父类的 constructor 来完成这项工作。
因此,派生的 constructor 必须调用 super
才能执行其父类(非派生的)的 constructor,否则 this
指向的那个对象将不会被创建。并且我们会收到一个报错。
为了让 Rabbit
的 constructor 可以工作,它需要在使用 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("White Rabbit", 10);
alert(rabbit.name); // White Rabbit
alert(rabbit.earLength); // 10
深入:内部探究和 [[HomeObject]]
进阶内容
如果你是第一次阅读本教程,那么则可以跳过本节。
这是关于继承和 super
背后的内部机制。
让我们更深入地研究 super
。我们将在这个过程中发现一些有趣的事儿。
首先要说的是,从我们迄今为止学到的知识来看,super
是不可能运行的。
的确是这样,让我们问问自己,以技术的角度它是如何工作的?当一个对象方法执行时,它会将当前对象作为 this
。随后如果我们调用 super.method()
,那么引擎需要从当前对象的原型中获取 method
。但这是怎么做到的?
这个任务看起来是挺容易的,但其实并不简单。引擎知道当前对象的 this
,所以它可以获取父 method
作为 this.__proto__.method
。不幸的是,这个“天真”的解决方法是行不通的。
让我们演示一下这个问题。简单起见,我们使用普通对象而不使用类。
如果你不想知道更多的细节知识,你可以跳过此部分,并转到下面的 [[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.
在 (*)
这一行,我们从原型(animal
)中获取 eat
,并在当前对象的上下文中调用它。请注意,.call(this)
在这里非常重要,因为简单的调用 this.__proto__.eat()
将在原型的上下文中执行 eat
,而非当前对象。
在上面的代码中,它确实按照了期望运行:我们获得了正确的 alert
。
现在,让我们在原型链上再添加一个对象。我们将看到这件事是如何被打破的:
let animal = {
name: "Animal",
eat() {
alert(`${this.name} eats.`);
}
};
let rabbit = {
__proto__: animal,
eat() {
// ...bounce around rabbit-style and call parent (animal) method
this.__proto__.eat.call(this); // (*)
}
};
let longEar = {
__proto__: rabbit,
eat() {
// ...do something with long ears and call parent (rabbit) method
this.__proto__.eat.call(this); // (**)
}
};
longEar.eat(); // Error: Maximum call stack size exceeded
代码无法再运行了!我们可以看到,在试图调用 longEar.eat()
时抛出了错误。
原因可能不那么明显,但是如果我们跟踪 longEar.eat()
调用,就可以发现原因。在 (*)
和 (**)
这两行中,this
的值都是当前对象(longEar
)。这是至关重要的一点:所有的对象方法都将当前对象作为 this
,而非原型或其他什么东西。
因此,在 (*)
和 (**)
这两行中,this.__proto__
的值是完全相同的:都是 rabbit
。它们俩都调用的是 rabbit.eat
,它们在不停地循环调用自己,而不是在原型链上向上寻找方法。
这张图介绍了发生的情况:
在
longEar.eat()
中,(**)
这一行调用rabbit.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
,所以this.__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
使用它来解析(resolve)父原型及其方法。
让我们看看它是怎么工作的,首先,对于普通对象:
let animal = {
name: "Animal",
eat() { // animal.eat.[[HomeObject]] == animal
alert(`${this.name} eats.`);
}
};
let rabbit = {
__proto__: animal,
name: "Rabbit",
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
,知道其 [[HomeObject]]
并且从其原型中获取父方法。并没有使用 this
。
方法并不是“自由”的
正如我们之前所知道的,函数通常都是“自由”的,并没有绑定到 JavaScript 中的对象。正因如此,它们可以在对象之间复制,并用另外一个 this
调用它。
[[HomeObject]]
的存在违反了这个原则,因为方法记住了它们的对象。[[HomeObject]]
不能被更改,所以这个绑定是永久的。
在 JavaScript 语言中 [[HomeObject]]
仅被用于 super
。所以,如果一个方法不使用 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
复制而来。也许我们只是想避免重复代码? - 它的
[[HomeObject]]
是rabbit
,因为它是在rabbit
中创建的。没有办法修改[[HomeObject]]
。 tree.sayHi()
内具有super.sayHi()
。它从rabbit
中上溯,然后从animal
中获取方法。
这是发生的情况示意图:
方法,不是函数属性
[[HomeObject]]
是为类和普通对象中的方法定义的。但是对于对象而言,方法必须确切指定为 method()
,而不是 "method: function()"
。
这个差别对我们来说可能不重要,但是对 JavaScript 来说却非常重要。
在下面的例子中,使用非方法(non-method)语法进行了比较。未设置 [[HomeObject]]
属性,并且继承无效:
let animal = {
eat: function() { // 这里是故意这样写的,而不是 eat() {...
// ...
}
};
let rabbit = {
__proto__: animal,
eat: function() {
super.eat();
}
};
rabbit.eat(); // 错误调用 super(因为这里没有 [[HomeObject]])
总结
- 想要扩展一个类:
class Child extends Parent
:- 这意味着
Child.prototype.__proto__
将是Parent.prototype
,所以方法会被继承。
- 这意味着
- 重写一个 constructor:
- 在使用
this
之前,我们必须在Child
的 constructor 中将父 constructor 调用为super()
。
- 在使用
- 重写一个方法:
- 我们可以在一个
Child
方法中使用super.method()
来调用Parent
方法。
- 我们可以在一个
- 内部:
- 方法在内部的
[[HomeObject]]
属性中记住了它们的类/对象。这就是super
如何解析父方法的。 - 因此,将一个带有
super
的方法从一个对象复制到另一个对象是不安全的。
- 方法在内部的
补充:
- 箭头函数没有自己的
this
或super
,所以它们能融入到就近的上下文中,像透明似的。
任务
创建实例时出错
重要程度: 5
这里有一份 Rabbit
扩展 Animal
的代码。
不幸的是,Rabbit
对象无法被创建。是哪里出错了呢?请解决它。
class Animal {
constructor(name) {
this.name = name;
}
}
class Rabbit extends Animal {
constructor(name) {
this.name = name;
this.created = Date.now();
}
}
let rabbit = new Rabbit("White Rabbit"); // Error: this is not defined
alert(rabbit.name);
解决方案
这是因为子类的 constructor 必须调用 super()
。
这里是修正后的代码:
class Animal {
constructor(name) {
this.name = name;
}
}
class Rabbit extends Animal {
constructor(name) {
super(name);
this.created = Date.now();
}
}
let rabbit = new Rabbit("White Rabbit"); // 现在好了
alert(rabbit.name); // White Rabbit
扩展 clock
重要程度: 5
我们获得了一个 Clock
类。到目前为止,它每秒都会打印一次时间。
class Clock {
constructor({ template }) {
this.template = template;
}
render() {
let date = new Date();
let hours = date.getHours();
if (hours < 10) hours = '0' + hours;
let mins = date.getMinutes();
if (mins < 10) mins = '0' + mins;
let secs = date.getSeconds();
if (secs < 10) secs = '0' + secs;
let output = this.template
.replace('h', hours)
.replace('m', mins)
.replace('s', secs);
console.log(output);
}
stop() {
clearInterval(this.timer);
}
start() {
this.render();
this.timer = setInterval(() => this.render(), 1000);
}
}
创建一个继承自 Clock
的新的类 ExtendedClock
,并添加参数 precision
— 每次 “ticks” 之间间隔的毫秒数,默认是 1000
(1 秒)。
- 你的代码应该在
extended-clock.js
文件里。 - 不要修改原有的
clock.js
。请扩展它。
解决方案
class ExtendedClock extends Clock {
constructor(options) {
super(options);
let { precision = 1000 } = options;
this.precision = precision;
}
start() {
this.render();
this.timer = setInterval(() => this.render(), this.precision);
}
};
类扩展自对象?
重要程度: 3
正如我们所知道的,所有的对象通常都继承自 Object.prototype
,并且可以访问“通用”对象方法,例如 hasOwnProperty
等。
例如:
class Rabbit {
constructor(name) {
this.name = name;
}
}
let rabbit = new Rabbit("Rab");
// hasOwnProperty 方法来自于 Object.prototype
alert( rabbit.hasOwnProperty('name') ); // true
但是,如果我们像这样 "class Rabbit extends Object"
把它明确地写出来,那么结果会与简单的 "class Rabbit"
有所不同么?
不同之处在哪里?
下面是此类的示例代码(它无法正常运行 — 为什么?修复它?):
class Rabbit extends Object {
constructor(name) {
this.name = name;
}
}
let rabbit = new Rabbit("Rab");
alert( rabbit.hasOwnProperty('name') ); // Error
解决方案
首先,让我们看看为什么之前的代码无法运行。
如果我们尝试运行它,就会发现原因其实很明显。派生类的 constructor 必须调用 super()
。否则 "this"
不会被定义。
下面是修复后的代码:
class Rabbit extends Object {
constructor(name) {
super(); // 需要在继承时调用父类的 constructor
this.name = name;
}
}
let rabbit = new Rabbit("Rab");
alert( rabbit.hasOwnProperty('name') ); // true
但这还不是全部原因。
即便修复了它,"class Rabbit extends Object"
和 class Rabbit
之间仍存在着重要差异。
我们知道,“extends” 语法会设置两个原型:
- 在构造函数的
"prototype"
之间设置原型(为了获取实例方法)。 - 在构造函数之间会设置原型(为了获取静态方法)。
在我们的例子里,对于 class Rabbit extends Object
,它意味着:
class Rabbit extends Object {}
alert( Rabbit.prototype.__proto__ === Object.prototype ); // (1) true
alert( Rabbit.__proto__ === Object ); // (2) true
所以,现在 Rabbit
可以通过 Rabbit
访问 Object
的静态方法,像这样:
class Rabbit extends Object {}
// 通常我们调用 Object.getOwnPropertyNames
alert ( Rabbit.getOwnPropertyNames({a: 1, b: 2})); // a,b
但是如果我们没有 extends Object
,那么 Rabbit.__proto__
将不会被设置为 Object
。
下面是示例:
class Rabbit {}
alert( Rabbit.prototype.__proto__ === Object.prototype ); // (1) true
alert( Rabbit.__proto__ === Object ); // (2) false (!)
alert( Rabbit.__proto__ === Function.prototype ); // true,所有函数都是默认如此
// error,Rabbit 中没有这样的函数
alert ( Rabbit.getOwnPropertyNames({a: 1, b: 2})); // Error
所以,在这种情况下,Rabbit
没有提供对 Object
的静态方法的访问。
顺便说一下,Function.prototype
有一些“通用”函数方法,例如 call
和 bind
等。在上述的两种情况下它们都是可用的,因为对于内建的 Object
构造函数而言,Object.__proto__ === Function.prototype
。
我们用一张图来解释:
所以,简而言之,这里有两点区别:
class Rabbit | class Rabbit extends Object |
---|---|
– | needs to call super() in constructor |
Rabbit.proto === Function.prototype | Rabbit.proto === Object |