类继承是一个类扩展另一个类的一种方式。

因此,我们可以在现有功能之上创建新功能。

“extends” 关键字

假设我们有 class Animal

  1. class Animal {
  2. constructor(name) {
  3. this.speed = 0;
  4. this.name = name;
  5. }
  6. run(speed) {
  7. this.speed = speed;
  8. alert(`${this.name} runs with speed ${this.speed}.`);
  9. }
  10. stop() {
  11. this.speed = 0;
  12. alert(`${this.name} stands still.`);
  13. }
  14. }
  15. let animal = new Animal("My animal");

这是我们对对象 animal 和 class Animal 的图形化表示:

类继承 - 图1

……然后我们想创建另一个 class Rabbit

因为 rabbits 是 animals,所以 class Rabbit 应该是基于 class Animal 的,可以访问 animal 的方法,以便 rabbits 可以做“一般”动物可以做的事儿。

扩展另一个类的语法是:class Child extends Parent

让我们创建一个继承自 Animalclass Rabbit

  1. class Rabbit extends Animal {
  2. hide() {
  3. alert(`${this.name} hides!`);
  4. }
  5. }
  6. let rabbit = new Rabbit("White Rabbit");
  7. rabbit.run(5); // White Rabbit runs with speed 5.
  8. rabbit.hide(); // White Rabbit hides!

Class Rabbit 的对象可以访问例如 rabbit.hide()Rabbit 的方法,还可以访问例如 rabbit.run()Animal 的方法。

在内部,关键字 extends 使用了很好的旧的原型机制进行工作。它将 Rabbit.prototype.[[Prototype]] 设置为 Animal.prototype。所以,如果在 Rabbit.prototype 中找不到一个方法,JavaScript 就会从 Animal.prototype 中获取该方法。

类继承 - 图2

例如,要查找 rabbit.run 方法,JavaScript 引擎会进行如下检查(如图所示从下到上):

  1. 查找对象 rabbit(没有 run)。
  2. 查找它的原型,即 Rabbit.prototype(有 hide,但没有 run)。
  3. 查找它的原型,即(由于 extendsAnimal.prototype,在这儿找到了 run 方法。

我们可以回忆一下 原生的原型 这一章的内容,JavaScript 内建对象同样也使用原型继承。例如,Date.prototype.[[Prototype]]Object.prototype。这就是为什么日期可以访问通用对象的方法。

extends 后允许任意表达式

类语法不仅允许指定一个类,在 extends 后可以指定任意表达式。

例如,一个生成父类的函数调用:

  1. function f(phrase) {
  2. return class {
  3. sayHi() { alert(phrase) }
  4. }
  5. }
  6. class User extends f("Hello") {}
  7. new User().sayHi(); // Hello

这里 class User 继承自 f("Hello") 的结果。

这对于高级编程模式,例如当我们根据许多条件使用函数生成类,并继承它们时来说可能很有用。

重写方法

现在,让我们继续前行并尝试重写一个方法。默认情况下,所有未在 class Rabbit 中指定的方法均从 class Animal 中直接获取。

但是如果我们在 Rabbit 中指定了我们自己的方法,例如 stop(),那么将会使用它:

  1. class Rabbit extends Animal {
  2. stop() {
  3. // ……现在这个将会被用作 rabbit.stop()
  4. // 而不是来自于 class Animal 的 stop()
  5. }
  6. }

但是通常来说,我们不希望完全替换父类的方法,而是希望在父类方法的基础上进行调整或扩展其功能。我们在我们的方法中做一些事儿,但是在它之前或之后或在过程中会调用父类方法。

Class 为此提供了 "super" 关键字。

  • 执行 super.method(...) 来调用一个父类方法。
  • 执行 super(...) 来调用一个父类 constructor(只能在我们的 constructor 中)。

例如,让我们的 rabbit 在停下来的时候自动 hide:

  1. class Animal {
  2. constructor(name) {
  3. this.speed = 0;
  4. this.name = name;
  5. }
  6. run(speed) {
  7. this.speed = speed;
  8. alert(`${this.name} runs with speed ${this.speed}.`);
  9. }
  10. stop() {
  11. this.speed = 0;
  12. alert(`${this.name} stands still.`);
  13. }
  14. }
  15. class Rabbit extends Animal {
  16. hide() {
  17. alert(`${this.name} hides!`);
  18. }
  19. stop() {
  20. super.stop(); // 调用父类的 stop
  21. this.hide(); // 然后 hide
  22. }
  23. }
  24. let rabbit = new Rabbit("White Rabbit");
  25. rabbit.run(5); // White Rabbit 以速度 5 奔跑
  26. rabbit.stop(); // White Rabbit 停止了。White rabbit hide 了!

现在,Rabbit 在执行过程中调用父类的 super.stop() 方法,所以 Rabbit 也具有了 stop 方法。

箭头函数没有 super

正如我们在 深入理解箭头函数 一章中所提到的,箭头函数没有 super

如果被访问,它会从外部函数获取。例如:

  1. class Rabbit extends Animal {
  2. stop() {
  3. setTimeout(() => super.stop(), 1000); // 1 秒后调用父类的 stop
  4. }
  5. }

箭头函数中的 superstop() 中的是一样的,所以它能按预期工作。如果我们在这里指定一个“普通”函数,那么将会抛出错误:

  1. // 意料之外的 super
  2. setTimeout(function() { super.stop() }, 1000);

重写 constructor

对于重写 constructor 来说,则有点棘手。

到目前为止,Rabbit 还没有自己的 constructor

根据 规范,如果一个类扩展了另一个类并且没有 constructor,那么将生成下面这样的“空” constructor

  1. class Rabbit extends Animal {
  2. // 为没有自己的 constructor 的扩展类生成的
  3. constructor(...args) {
  4. super(...args);
  5. }
  6. }

正如我们所看到的,它调用了父类的 constructor,并传递了所有的参数。如果我们没有写自己的 constructor,就会出现这种情况。

现在,我们给 Rabbit 添加一个自定义的 constructor。除了 name 之外,它还会指定 earLength

  1. class Animal {
  2. constructor(name) {
  3. this.speed = 0;
  4. this.name = name;
  5. }
  6. // ...
  7. }
  8. class Rabbit extends Animal {
  9. constructor(name, earLength) {
  10. this.speed = 0;
  11. this.name = name;
  12. this.earLength = earLength;
  13. }
  14. // ...
  15. }
  16. // 不工作!
  17. 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(),就像下面这样:

  1. class Animal {
  2. constructor(name) {
  3. this.speed = 0;
  4. this.name = name;
  5. }
  6. // ...
  7. }
  8. class Rabbit extends Animal {
  9. constructor(name, earLength) {
  10. super(name);
  11. this.earLength = earLength;
  12. }
  13. // ...
  14. }
  15. // 现在可以了
  16. let rabbit = new Rabbit("White Rabbit", 10);
  17. alert(rabbit.name); // White Rabbit
  18. 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()

  1. let animal = {
  2. name: "Animal",
  3. eat() {
  4. alert(`${this.name} eats.`);
  5. }
  6. };
  7. let rabbit = {
  8. __proto__: animal,
  9. name: "Rabbit",
  10. eat() {
  11. // 这就是 super.eat() 可以大概工作的方式
  12. this.__proto__.eat.call(this); // (*)
  13. }
  14. };
  15. rabbit.eat(); // Rabbit eats.

(*) 这一行,我们从原型(animal)中获取 eat,并在当前对象的上下文中调用它。请注意,.call(this) 在这里非常重要,因为简单的调用 this.__proto__.eat() 将在原型的上下文中执行 eat,而非当前对象。

在上面的代码中,它确实按照了期望运行:我们获得了正确的 alert

现在,让我们在原型链上再添加一个对象。我们将看到这件事是如何被打破的:

  1. let animal = {
  2. name: "Animal",
  3. eat() {
  4. alert(`${this.name} eats.`);
  5. }
  6. };
  7. let rabbit = {
  8. __proto__: animal,
  9. eat() {
  10. // ...bounce around rabbit-style and call parent (animal) method
  11. this.__proto__.eat.call(this); // (*)
  12. }
  13. };
  14. let longEar = {
  15. __proto__: rabbit,
  16. eat() {
  17. // ...do something with long ears and call parent (rabbit) method
  18. this.__proto__.eat.call(this); // (**)
  19. }
  20. };
  21. longEar.eat(); // Error: Maximum call stack size exceeded

代码无法再运行了!我们可以看到,在试图调用 longEar.eat() 时抛出了错误。

原因可能不那么明显,但是如果我们跟踪 longEar.eat() 调用,就可以发现原因。在 (*)(**) 这两行中,this 的值都是当前对象(longEar)。这是至关重要的一点:所有的对象方法都将当前对象作为 this,而非原型或其他什么东西。

因此,在 (*)(**) 这两行中,this.__proto__ 的值是完全相同的:都是 rabbit。它们俩都调用的是 rabbit.eat,它们在不停地循环调用自己,而不是在原型链上向上寻找方法。

这张图介绍了发生的情况:

类继承 - 图3

  1. longEar.eat() 中,(**) 这一行调用 rabbit.eat 并为其提供 this=longEar

    1. // 在 longEar.eat() 中我们有 this = longEar
    2. this.__proto__.eat.call(this) // (**)
    3. // 变成了
    4. longEar.__proto__.eat.call(this)
    5. // 也就是
    6. rabbit.eat.call(this);
  2. 之后在 rabbit.eat(*) 行中,我们希望将函数调用在原型链上向更高层传递,但是 this=longEar,所以 this.__proto__.eat 又是 rabbit.eat

    1. // 在 rabbit.eat() 中我们依然有 this = longEar
    2. this.__proto__.eat.call(this) // (*)
    3. // 变成了
    4. longEar.__proto__.eat.call(this)
    5. // 或(再一次)
    6. rabbit.eat.call(this);
  3. ……所以 rabbit.eat 在不停地循环调用自己,因此它无法进一步地提升。

这个问题没法仅仅通过使用 this 来解决。

[[HomeObject]]

为了提供解决方法,JavaScript 为函数添加了一个特殊的内部属性:[[HomeObject]]

当一个函数被定义为类或者对象方法时,它的 [[HomeObject]] 属性就成为了该对象。

然后 super 使用它来解析(resolve)父原型及其方法。

让我们看看它是怎么工作的,首先,对于普通对象:

  1. let animal = {
  2. name: "Animal",
  3. eat() { // animal.eat.[[HomeObject]] == animal
  4. alert(`${this.name} eats.`);
  5. }
  6. };
  7. let rabbit = {
  8. __proto__: animal,
  9. name: "Rabbit",
  10. eat() { // rabbit.eat.[[HomeObject]] == rabbit
  11. super.eat();
  12. }
  13. };
  14. let longEar = {
  15. __proto__: rabbit,
  16. name: "Long Ear",
  17. eat() { // longEar.eat.[[HomeObject]] == longEar
  18. super.eat();
  19. }
  20. };
  21. // 正确执行
  22. longEar.eat(); // Long Ear eats.

它基于 [[HomeObject]] 运行机制按照预期执行。一个方法,例如 longEar.eat,知道其 [[HomeObject]] 并且从其原型中获取父方法。并没有使用 this

方法并不是“自由”的

正如我们之前所知道的,函数通常都是“自由”的,并没有绑定到 JavaScript 中的对象。正因如此,它们可以在对象之间复制,并用另外一个 this 调用它。

[[HomeObject]] 的存在违反了这个原则,因为方法记住了它们的对象。[[HomeObject]] 不能被更改,所以这个绑定是永久的。

在 JavaScript 语言中 [[HomeObject]] 仅被用于 super。所以,如果一个方法不使用 super,那么我们仍然可以视它为自由的并且可在对象之间复制。但是用了 super 再这样做可能就会出错。

下面是复制后错误的 super 结果的示例:

  1. let animal = {
  2. sayHi() {
  3. console.log(`I'm an animal`);
  4. }
  5. };
  6. // rabbit 继承自 animal
  7. let rabbit = {
  8. __proto__: animal,
  9. sayHi() {
  10. super.sayHi();
  11. }
  12. };
  13. let plant = {
  14. sayHi() {
  15. console.log("I'm a plant");
  16. }
  17. };
  18. // tree 继承自 plant
  19. let tree = {
  20. __proto__: plant,
  21. sayHi: rabbit.sayHi // (*)
  22. };
  23. 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 中获取方法。

这是发生的情况示意图:

类继承 - 图4

方法,不是函数属性

[[HomeObject]] 是为类和普通对象中的方法定义的。但是对于对象而言,方法必须确切指定为 method(),而不是 "method: function()"

这个差别对我们来说可能不重要,但是对 JavaScript 来说却非常重要。

在下面的例子中,使用非方法(non-method)语法进行了比较。未设置 [[HomeObject]] 属性,并且继承无效:

  1. let animal = {
  2. eat: function() { // 这里是故意这样写的,而不是 eat() {...
  3. // ...
  4. }
  5. };
  6. let rabbit = {
  7. __proto__: animal,
  8. eat: function() {
  9. super.eat();
  10. }
  11. };
  12. rabbit.eat(); // 错误调用 super(因为这里没有 [[HomeObject]])

总结

  1. 想要扩展一个类:class Child extends Parent
    • 这意味着 Child.prototype.__proto__ 将是 Parent.prototype,所以方法会被继承。
  2. 重写一个 constructor:
    • 在使用 this 之前,我们必须在 Child 的 constructor 中将父 constructor 调用为 super()
  3. 重写一个方法:
    • 我们可以在一个 Child 方法中使用 super.method() 来调用 Parent 方法。
  4. 内部:
    • 方法在内部的 [[HomeObject]] 属性中记住了它们的类/对象。这就是 super 如何解析父方法的。
    • 因此,将一个带有 super 的方法从一个对象复制到另一个对象是不安全的。

补充:

  • 箭头函数没有自己的 thissuper,所以它们能融入到就近的上下文中,像透明似的。

任务

创建实例时出错

重要程度: 5

这里有一份 Rabbit 扩展 Animal 的代码。

不幸的是,Rabbit 对象无法被创建。是哪里出错了呢?请解决它。

  1. class Animal {
  2. constructor(name) {
  3. this.name = name;
  4. }
  5. }
  6. class Rabbit extends Animal {
  7. constructor(name) {
  8. this.name = name;
  9. this.created = Date.now();
  10. }
  11. }
  12. let rabbit = new Rabbit("White Rabbit"); // Error: this is not defined
  13. alert(rabbit.name);

解决方案

这是因为子类的 constructor 必须调用 super()

这里是修正后的代码:

  1. class Animal {
  2. constructor(name) {
  3. this.name = name;
  4. }
  5. }
  6. class Rabbit extends Animal {
  7. constructor(name) {
  8. super(name);
  9. this.created = Date.now();
  10. }
  11. }
  12. let rabbit = new Rabbit("White Rabbit"); // 现在好了
  13. alert(rabbit.name); // White Rabbit

扩展 clock

重要程度: 5

我们获得了一个 Clock 类。到目前为止,它每秒都会打印一次时间。

  1. class Clock {
  2. constructor({ template }) {
  3. this.template = template;
  4. }
  5. render() {
  6. let date = new Date();
  7. let hours = date.getHours();
  8. if (hours < 10) hours = '0' + hours;
  9. let mins = date.getMinutes();
  10. if (mins < 10) mins = '0' + mins;
  11. let secs = date.getSeconds();
  12. if (secs < 10) secs = '0' + secs;
  13. let output = this.template
  14. .replace('h', hours)
  15. .replace('m', mins)
  16. .replace('s', secs);
  17. console.log(output);
  18. }
  19. stop() {
  20. clearInterval(this.timer);
  21. }
  22. start() {
  23. this.render();
  24. this.timer = setInterval(() => this.render(), 1000);
  25. }
  26. }

创建一个继承自 Clock 的新的类 ExtendedClock,并添加参数 precision — 每次 “ticks” 之间间隔的毫秒数,默认是 1000(1 秒)。

  • 你的代码应该在 extended-clock.js 文件里。
  • 不要修改原有的 clock.js。请扩展它。

打开一个任务沙箱。

解决方案

  1. class ExtendedClock extends Clock {
  2. constructor(options) {
  3. super(options);
  4. let { precision = 1000 } = options;
  5. this.precision = precision;
  6. }
  7. start() {
  8. this.render();
  9. this.timer = setInterval(() => this.render(), this.precision);
  10. }
  11. };

使用沙箱打开解决方案。

类扩展自对象?

重要程度: 3

正如我们所知道的,所有的对象通常都继承自 Object.prototype,并且可以访问“通用”对象方法,例如 hasOwnProperty 等。

例如:

  1. class Rabbit {
  2. constructor(name) {
  3. this.name = name;
  4. }
  5. }
  6. let rabbit = new Rabbit("Rab");
  7. // hasOwnProperty 方法来自于 Object.prototype
  8. alert( rabbit.hasOwnProperty('name') ); // true

但是,如果我们像这样 "class Rabbit extends Object" 把它明确地写出来,那么结果会与简单的 "class Rabbit" 有所不同么?

不同之处在哪里?

下面是此类的示例代码(它无法正常运行 — 为什么?修复它?):

  1. class Rabbit extends Object {
  2. constructor(name) {
  3. this.name = name;
  4. }
  5. }
  6. let rabbit = new Rabbit("Rab");
  7. alert( rabbit.hasOwnProperty('name') ); // Error

解决方案

首先,让我们看看为什么之前的代码无法运行。

如果我们尝试运行它,就会发现原因其实很明显。派生类的 constructor 必须调用 super()。否则 "this" 不会被定义。

下面是修复后的代码:

  1. class Rabbit extends Object {
  2. constructor(name) {
  3. super(); // 需要在继承时调用父类的 constructor
  4. this.name = name;
  5. }
  6. }
  7. let rabbit = new Rabbit("Rab");
  8. alert( rabbit.hasOwnProperty('name') ); // true

但这还不是全部原因。

即便修复了它,"class Rabbit extends Object"class Rabbit 之间仍存在着重要差异。

我们知道,“extends” 语法会设置两个原型:

  1. 在构造函数的 "prototype" 之间设置原型(为了获取实例方法)。
  2. 在构造函数之间会设置原型(为了获取静态方法)。

在我们的例子里,对于 class Rabbit extends Object,它意味着:

  1. class Rabbit extends Object {}
  2. alert( Rabbit.prototype.__proto__ === Object.prototype ); // (1) true
  3. alert( Rabbit.__proto__ === Object ); // (2) true

所以,现在 Rabbit 可以通过 Rabbit 访问 Object 的静态方法,像这样:

  1. class Rabbit extends Object {}
  2. // 通常我们调用 Object.getOwnPropertyNames
  3. alert ( Rabbit.getOwnPropertyNames({a: 1, b: 2})); // a,b

但是如果我们没有 extends Object,那么 Rabbit.__proto__ 将不会被设置为 Object

下面是示例:

  1. class Rabbit {}
  2. alert( Rabbit.prototype.__proto__ === Object.prototype ); // (1) true
  3. alert( Rabbit.__proto__ === Object ); // (2) false (!)
  4. alert( Rabbit.__proto__ === Function.prototype ); // true,所有函数都是默认如此
  5. // error,Rabbit 中没有这样的函数
  6. alert ( Rabbit.getOwnPropertyNames({a: 1, b: 2})); // Error

所以,在这种情况下,Rabbit 没有提供对 Object 的静态方法的访问。

顺便说一下,Function.prototype 有一些“通用”函数方法,例如 callbind 等。在上述的两种情况下它们都是可用的,因为对于内建的 Object 构造函数而言,Object.__proto__ === Function.prototype

我们用一张图来解释:

类继承 - 图5

所以,简而言之,这里有两点区别:

class Rabbitclass Rabbit extends Object
needs to call super() in constructor
Rabbit.proto === Function.prototypeRabbit.proto === Object