类继承是一种类扩展另一种类的方法。
这样我们就可以在现有功能的基础上创建新的功能。
“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…)