18日 七月 2020

类继承

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

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

“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

让我们创建一个继承自 Animalclass 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 引擎会进行如下检查(如图所示从下到上):

  1. 查找对象 rabbit(没有 run)。
  2. 查找它的原型,即 Rabbit.prototype(有 hide,但没有 run)。
  3. 查找它的原型,即(由于 extendsAnimal.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
  }
}

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

// 意料之外的 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 才能执行其父类(base)的 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

Overriding class fields: a tricky note

Advanced note

This note assumes you have a certain experience with classes, maybe in other programming languages.

It provides better insight into the language and also explains the behavior that might be a source of bugs (but not very often).

If you find it difficult to understand, just go on, continue reading, then return to it some time later.

We can override not only methods, but also class fields.

Although, there’s a tricky behavior when we access an overridden field in parent constructor, quite different from most other programming languages.

Consider this example:

class Animal {
  name = 'animal'

  constructor() {
    alert(this.name); // (*)
  }
}

class Rabbit extends Animal {
  name = 'rabbit';
}

new Animal(); // animal
new Rabbit(); // animal

Here, class Rabbit extends Animal and overrides name field with its own value.

There’s no own constructor in Rabbit, so Animal constructor is called.

What’s interesting is that in both cases: new Animal() and new Rabbit(), the alert in the line (*) shows animal.

In other words, parent constructor always uses its own field value, not the overridden one.

What’s odd about it?

If it’s not clear yet, please compare with methods.

Here’s the same code, but instead of this.name field we call this.showName() method:

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

Please note: now the output is different.

And that’s what we naturally expect. When the parent constructor is called in the derived class, it uses the overridden method.

…But for class fields it’s not so. As said, the parent constructor always uses the parent field.

Why is there the difference?

Well, the reason is in the field initialization order. The class field is initialized:

  • Before constructor for the base class (that doesn’t extend anything),
  • Imediately after super() for the derived class.

In our case, Rabbit is the derived class. There’s no constructor() in it. As said previously, that’s the same as if there was an empty constructor with only super(...args).

So, new Rabbit() calls super(), thus executing the parent constructor, and (per the rule for derived classes) only after that its class fields are initialized. At the time of the parent constructor execution, there are no Rabbit class fields yet, that’s why Animal fields are used.

This subtle difference between fields and methods is specific to JavaScript

Luckily, this behavior only reveals itself if an overridden field is used in the parent constructor. Then it may be difficult to understand what’s going on, so we’re explaining it here.

If it becomes a problem, one can fix it by using methods or getters/setters instead of fields.

深入:内部探究和 [[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,它们在不停地循环调用自己,而不是在原型链上向上寻找方法。

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

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

    // 在 longEar.eat() 中我们有 this = longEar
    this.__proto__.eat.call(this) // (**)
    // 变成了
    longEar.__proto__.eat.call(this)
    // 也就是
    rabbit.eat.call(this);
  2. 之后在 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);
  3. ……所以 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]])

总结

  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 对象无法被创建。是哪里出错了呢?请解决它。

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
重要程度: 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);
  }
};

使用沙箱打开解决方案。

教程路线图

评论

在评论之前先阅读本内容…
  • 如果你发现教程有错误,或者有其他需要修改和提升的地方 — 请 提交一个 GitHub issue 或 pull request,而不是在这评论。
  • 如果你对教程的内容有不理解的地方 — 请详细说明。
  • 使用 <code> 标签插入只有几个词的代码,插入多行代码可以使用 <pre> 标签,对于超过 10 行的代码,建议你使用沙箱(plnkrJSBincodepen…)