类继承是一种类扩展另一种类的方法。
这样我们就可以在现有功能的基础上创建新的功能。
“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
,并可以访问动物方法,以便兔子可以执行“通用”动物可以执行的操作。
扩展另一个类的语法为: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!
Rabbit
类的对象可以访问 Rabbit
方法(例如 rabbit.hide()
),也可以访问 Animal
方法(例如 rabbit.run()
)。
在内部,extends
关键字使用良好的旧原型机制工作。它将 Rabbit.prototype.[[Prototype]]
设置为 Animal.prototype
。因此,如果在 Rabbit.prototype
中找不到方法,JavaScript 会从 Animal.prototype
中获取它。
例如,要找到 rabbit.run
方法,引擎会检查(从下到上)
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() {
// ...now this will be used for rabbit.stop()
// instead of stop() from class Animal
}
}
然而,通常我们不想完全替换父方法,而是想在其基础上构建以调整或扩展其功能。我们在我们的方法中做一些事情,但在它之前/之后或在过程中调用父方法。
类为此提供了 "super"
关键字。
super.method(...)
调用父方法。super(...)
调用父构造函数(仅在我们的构造函数内)。
例如,让我们的兔子在停止时自动隐藏
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(); // call parent stop
this.hide(); // and then hide
}
}
let rabbit = new Rabbit("White Rabbit");
rabbit.run(5); // White Rabbit runs with speed 5.
rabbit.stop(); // White Rabbit stands still. White Rabbit hides!
现在 Rabbit
具有 stop
方法,该方法在进程中调用父 super.stop()
。
super
正如在章节 重新审视箭头函数 中提到的,箭头函数没有 super
。
如果访问,则从外部函数获取。例如
class Rabbit extends Animal {
stop() {
setTimeout(() => super.stop(), 1000); // call parent stop after 1sec
}
}
箭头函数中的 super
与 stop()
中的相同,因此按预期工作。如果我们在此处指定“常规”函数,则会出现错误
// Unexpected super
setTimeout(function() { super.stop() }, 1000);
重写构造函数
使用构造函数时会变得有点棘手。
到目前为止,Rabbit
还没有自己的 constructor
。
根据 规范,如果一个类扩展了另一个类并且没有 constructor
,则会生成以下“空”constructor
class Rabbit extends Animal {
// generated for extending classes without own constructors
constructor(...args) {
super(...args);
}
}
正如我们所看到的,它基本上调用父 constructor
并将所有参数传递给它。如果我们不编写自己的构造函数,就会发生这种情况。
现在,让我们向 Rabbit
添加一个自定义构造函数。它将指定 earLength
以及 name
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;
}
// ...
}
// Doesn't work!
let rabbit = new Rabbit("White Rabbit", 10); // Error: this is not defined.
哎呀!我们遇到了一个错误。现在我们无法创建兔子了。出了什么事?
简短的回答是
- 继承类中的构造函数必须调用
super(...)
,并且(!)在使用this
之前执行此操作。
…但为什么?这里发生了什么事?事实上,这个要求似乎很奇怪。
当然,有一个解释。让我们深入了解一下,这样你就能真正理解正在发生的事情。
在 JavaScript 中,继承类的构造函数(所谓的“派生构造函数”)与其他函数之间存在区别。派生构造函数有一个特殊的内部属性 [[ConstructorKind]]:"derived"
。这是一个特殊的内部标签。
该标签影响其与 new
的行为。
- 当使用
new
执行常规函数时,它会创建一个空对象并将其分配给this
。 - 但是,当派生构造函数运行时,它不会执行此操作。它期望父构造函数来完成这项工作。
因此,派生构造函数必须调用 super
才能执行其父(基)构造函数,否则不会为 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;
}
// ...
}
// now fine
let rabbit = new Rabbit("White Rabbit", 10);
alert(rabbit.name); // White Rabbit
alert(rabbit.earLength); // 10
重写类字段:一个棘手的注意事项
本注意事项假定你对类有一定经验,也许是在其他编程语言中。
它提供了对该语言的更深入的见解,还解释了可能成为错误来源的行为(但并不常见)。
如果你发现难以理解,那就继续读下去,然后在稍后一段时间再返回来。
我们不仅可以重写方法,还可以重写类字段。
不过,当我们在父构造函数中访问重写字段时,会有一种棘手的行为,与大多数其他编程语言大不相同。
考虑此示例
class Animal {
name = 'animal';
constructor() {
alert(this.name); // (*)
}
}
class Rabbit extends Animal {
name = 'rabbit';
}
new Animal(); // animal
new Rabbit(); // animal
在此,类 Rabbit
扩展 Animal
并使用其自己的值重写 name
字段。
Rabbit
中没有自己的构造函数,因此调用 Animal
构造函数。
有趣的是,在 new Animal()
和 new Rabbit()
这两种情况下,第 (*)
行中的 alert
显示 animal
。
换句话说,父构造函数始终使用其自己的字段值,而不是重写的值。
这有什么奇怪之处?
如果还不清楚,请与方法进行比较。
这是相同的代码,但我们不调用 this.name
字段,而是调用 this.showName()
方法
class Animal {
showName() { // instead of this.name = 'animal'
alert('animal');
}
constructor() {
this.showName(); // instead of alert(this.name);
}
}
class Rabbit extends Animal {
showName() {
alert('rabbit');
}
}
new Animal(); // animal
new Rabbit(); // rabbit
请注意:现在输出不同了。
这就是我们自然期望的。当在派生类中调用父构造函数时,它将使用重写的方法。
…但对于类字段来说并非如此。如前所述,父构造函数始终使用父字段。
为什么会有区别?
嗯,原因是字段初始化顺序。类字段已初始化
- 在基类的构造函数之前(不扩展任何内容),
- 在派生类的
super()
之后立即执行。
在我们的例子中,Rabbit
是派生类。其中没有 constructor()
。如前所述,这与只有 super(...args)
的空构造函数相同。
因此,new Rabbit()
调用 super()
,从而执行父构造函数,并且(根据派生类的规则)只有在其类字段初始化之后。在执行父构造函数时,还没有 Rabbit
类字段,这就是为什么使用 Animal
字段的原因。
字段和方法之间的这种细微差别是 JavaScript 特有的。
幸运的是,只有在父构造函数中使用重写字段时,此行为才会显现。那么可能很难理解发生了什么,所以我们在这里解释一下。
如果这成为一个问题,可以通过使用 getter/setter 而不是字段来解决它。
Super:内部,[[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() {
// that's how super.eat() could presumably work
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
。// inside longEar.eat() we have this = longEar this.__proto__.eat.call(this) // (**) // becomes longEar.__proto__.eat.call(this) // that is rabbit.eat.call(this);
-
然后在
rabbit.eat
的(*)
行中,我们希望将调用传递到链中的更高位置,但this=longEar
,所以this.__proto__.eat
再次是rabbit.eat
!// inside rabbit.eat() we also have this = longEar this.__proto__.eat.call(this) // (*) // becomes longEar.__proto__.eat.call(this) // or (again) rabbit.eat.call(this);
-
…所以
rabbit.eat
在无限循环中调用自身,因为它无法再进一步提升。
仅使用 this
无法解决此问题。
[[HomeObject]]
为了提供解决方案,JavaScript 为函数添加了另一个特殊的内部属性:[[HomeObject]]
。
当函数被指定为类或对象方法时,其 [[HomeObject]]
属性将变为该对象。
然后 super
使用它来解析父原型及其方法。
让我们看看它是如何工作的,首先从普通对象开始
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();
}
};
// works correctly
longEar.eat(); // Long Ear eats.
它按预期工作,这归功于 [[HomeObject]]
机制。一种方法(例如 longEar.eat
)知道其 [[HomeObject]]
并从其原型中获取父方法。无需使用 this
。
方法不是“自由的”
正如我们之前所知,通常函数是“自由的”,不绑定到 JavaScript 中的对象。因此,它们可以在对象之间复制并在另一个 this
中调用。
[[HomeObject]]
的存在违反了该原则,因为方法会记住它们的对象。[[HomeObject]]
无法更改,因此这种联系是永久的。
语言中唯一使用 [[HomeObject]]
的地方是 super
。因此,如果方法不使用 super
,那么我们仍然可以将其视为自由的并在对象之间进行复制。但使用 super
时可能会出错。
以下是复制后 super
结果错误的演示
let animal = {
sayHi() {
alert(`I'm an animal`);
}
};
// rabbit inherits from animal
let rabbit = {
__proto__: animal,
sayHi() {
super.sayHi();
}
};
let plant = {
sayHi() {
alert("I'm a plant");
}
};
// tree inherits from plant
let tree = {
__proto__: plant,
sayHi: rabbit.sayHi // (*)
};
tree.sayHi(); // I'm an animal (?!?)
调用 tree.sayHi()
会显示“我是动物”。绝对错误。
原因很简单
- 在
(*)
行中,方法tree.sayHi
从rabbit
复制。也许我们只是想避免代码重复? - 它的
[[HomeObject]]
是rabbit
,因为它是在rabbit
中创建的。没有办法更改[[HomeObject]]
。 tree.sayHi()
的代码内部有super.sayHi()
。它从rabbit
往上走,并从animal
中获取方法。
以下是发生的事情的图表
方法,而不是函数属性
[[HomeObject]]
在类和普通对象中的方法中都有定义。但对于对象,方法必须精确指定为 method()
,而不是 "method: function()"
。
对我们来说,这种差异可能并不重要,但对 JavaScript 来说很重要。
在下面的示例中,使用非方法语法进行比较。[[HomeObject]]
属性未设置,并且继承不起作用
let animal = {
eat: function() { // intentionally writing like this instead of 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
,因此方法被继承。
- 这意味着
- 重写构造函数时
- 在使用
this
之前,我们必须在Child
构造函数中将父构造函数调用为super()
。
- 在使用
- 重写其他方法时
- 我们可以在
Child
方法中使用super.method()
来调用Parent
方法。
- 我们可以在
- 内部
- 方法在内部
[[HomeObject]]
属性中记住它们的类/对象。这就是super
解析父方法的方式。 - 因此,使用
super
将方法从一个对象复制到另一个对象是不安全的。
- 方法在内部
此外
- 箭头函数没有自己的
this
或super
,因此它们可以透明地适应周围的环境。
评论
<code>
标签,对于多行 - 将它们包装在<pre>
标签中,对于超过 10 行 - 使用沙箱(plnkr,jsbin,codepen…)