类继承是一种扩展类的方式。我们能使用类继承,基于现存的类,创建新的功能。

“extends”关键字

有一个类 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 对象和 Animal 类之间的关系:

image.png

接下来我们再创建一个 class Rabbit

rabbit(兔子)也是 animal(动物),因此 Rabbit 是基于 Animal 的,要能够访问后者的方法,这样兔子就具备了所有动物都具有的“一般”能力。

继承语法是这样的:class Child extends Parent

下面我来创建 class Rabbit,继承自 Animal

  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!

Rabbit 对象上既能调用 Rabbit 的方法,例如 rabbit.hide(),也能调用 Animal 的方法,例如 rabbit.run()

extends 关键字在内部使用的依旧是原型(prototype)继承方案。将 Rabbit.prototype.[[Prototype]] 的值设置为 Animal.prototype。因此,如果在 Rabbit.prototype 上没有找到方法,JavaScript 就会从 Animal.prototype 中查找使用。

image.png
假如,我们调用了 rabbit.run 方法,引擎是按照下面的方式查找的(从图上看,是自下往上的):

  1. rabbit 对象(没有 run 方法)
  2. 它的原型,即 Rabbit.prototype(有 hide 方法,但无 run 方法)
  3. 它的原型,即 Animal.prototype(因为 extends 的作用),最好找到了 run 方法。

JavaScript 的内置对象也在使用原型继承。比如,Date.prototype.[[Prototype]] 的值就是 Object.prototype,这就是为什么在日期对象可以使用通用对象方法的原因。

💡 **extends** 之后可以跟任意的表达式

extends> 之后不是只能跟类名的,还可以跟任意的表达式。

例如,> extends> 一个能够生成类的函数调用:

```javascript function f(phrase) { return class { sayHi() { alert(phrase) } } }

class User extends f(“Hello”) {}

new User().sayHi(); // Hello

  1. > <br />这里,> `class User`> 继承的是 > `f("Hello")` 的调用结果。> <br />> <br />这种模式对于高级编程模式可能很有用,即——当我们需要使用函数,根据多条件判断生成要继承的父类。> <br />
  2. <a name="gwvvrt"></a>
  3. ## 重写方法
  4. 现在我们继续,来重写方法。默认,所有未在 `class Rabbit` 中定义的方法,都会从 `class Animal` 中去取。
  5. 但是,如果我们在 `Rabbit` 中定义了方法。拿 `stop()` 举例子,那么就不会使用父类里的同名方法了:
  6. ```javascript
  7. class Rabbit extends Animal {
  8. stop() {
  9. // ... 现在使用的是 rabbit.stop()
  10. // 而不是来自 class Animal 的 stop()
  11. }
  12. }

但有时,我们不希望完全覆盖父级方法,而是希望在它之上进行构建、调整或扩展它的功能。在子类方法里有书写自己的逻辑,同时在这些逻辑之前或之后,或是在逻辑执行的过程中,去调用了父级方法。

类为此提供了“super”关键字。

  • super.method(...),即调用父级方法。

  • super(...),即调用父级构造函数(仅能在 constructor 中使用)。

举个例子,让我们的 rabbit 在停止时自动隐藏( autohide when stopped):

  1. class Animal {
  2. constructor(name) {
  3. this.speed = 0;
  4. this.name = name;
  5. }
  6. run(speed) {
  7. this.speed += speed;
  8. console.log(`${this.name} runs with speed ${this.speed}.`);
  9. }
  10. stop() {
  11. this.speed = 0;
  12. console.log(`${this.name} stopped.`);
  13. }
  14. }
  15. class Rabbit extends Animal {
  16. hide() {
  17. console.log(`${this.name} hides!`);
  18. }
  19. stop() {
  20. super.stop(); // 调用父级的 stop 方法
  21. this.hide(); // 然后隐藏自己
  22. }
  23. }

现在,Rabbit 有一个 stop 方法,在调用这个方法的过程中使用了 super.stop() 调用父级的 stop 方法。

💡 箭头函数是没有 super 的**
如果我们在箭头函数里使用了 super,那么使用的将是外部函数的。例如:

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

箭头函数中的 superstop() 中的一样,所以代码能按预期工作。如果这里使用的是个“普通”函数,就会报错:

  1. // Unexpected super
  2. setTimeout(function() { super.stop() }, 1000);

重写 constructor

构造函数这块内容要好好介绍一下。

到目前为止,Ranbbit 并没有设置自己的 constructor

根据 规范,如果一个类继承自另一个类,而且没有提供 constructor,那么就会使用下面的默认构造器:

  1. class Rabbit extends Animal {
  2. // 默认的 constructor
  3. constructor(...args) {
  4. super(...args);
  5. }
  6. }

如你所见,它调用了父级构造函数,并将所有参数传递了过去。如果我们不写自己的构造函数,就会使用这个构造函数。

现在我们为 Rabbit 添加自定义构造器,指定 nameearLength 属性:

  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('小白兔', 10); // Error: this is not defined.

哎呀!报错了。现在我们不能成功创建 rabbit,到底是是什么问题呢?

简短回答是:子类 constructor 中必须调用 super(...)并且 要在使用 this 之前调用。

但为什么呢?这个要求看起来有点奇怪。

当然,在深入细节后,我们就能真正理解原因了。

JavaScript 中,“子类构造函数”(即所谓的“衍生构造函数”)和其他函数还是有区别的。子类中,构造函数会用一个特殊的内部属性 [[ConstructorKind]]:"derived" 做标记。

这个标记影响着 new 的行为:

  • 当在一个常规函数前使用 new 调用,结果会创建一个空对象,并将其赋值给 this
  • 但是,衍生构造函数并非是这样运行的,它把这个任务交给了父级构造函数来做。

为了让 Rabbit 构造函数顺序执行,我们需要在使用 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('小白兔', 10);
  17. alert(rabbit.name); // 小白兔
  18. alert(rabbit.earLength); // 10

Super:内部属性 [[HomeObject]]

高级部分

如果你是第一次阅读这里的内容,这部分可以暂时忽略不看。

这部分是介绍关于继承和 super 背后的内部算法机制。

我们继续深入 super 底层使用的知识点。一路上我们会看到一些有趣的事情。

首先要说的是,我们目前所掌握的知识点,并不能模拟实现 super 的功能。

我们可以问问自己, super 在技术上是如何实现的?当一个对象方法被执行时,它会得到当前对象 this。如果我们调用 super.method() ,引擎需要从当前对象的原型中得到 method。但怎么做的呢?

好像挺好实现的,但并非这样。引擎如何确定当前对象 this 的?就是使用 this.__proto__.method 得到父级 method 的?不幸地是,这种实现方法是“幼稚的”,并不管用。

简单起见,我们直接使用普通对象(不用类)来说明为什么不管用。

如果你对 super 底层运作机制不感兴趣,可以直接忽略这节内容,直接进入下一部分 [[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.

(*) 处,我们使用当前上下文对象(即 this),调用原型对象 animaleat 方法。需要着重注意 .call(this) 这个地方,如果只是 this.__proto__.eat() 这种方式调用,使用的会是父类上下文对象(即 animal)执行 eat 方法,而非当前对象 rabbit

上面的代码执行结果是符合我们假设的:我们能看到正确的弹出内容。

图片.png

现在,我们再向原型链中添加一个对象。就能看到问题了:

  1. let animal = {
  2. name: "Animal",
  3. eat() {
  4. alert(`${this.name} eats.`);
  5. }
  6. };
  7. let rabbit = {
  8. __proto__: animal,
  9. eat() {
  10. // ...经 (**) 方式调用后,执行到此处,这里的 this 还是指向 longEar,
  11. // 于是 this.__proto__ 等于 rabbit,
  12. // 相当于,我们还是使用在 longEar 作为上下文对象,调用 rabbit.eat() 方法的
  13. this.__proto__.eat.call(this); // (*)
  14. }
  15. };
  16. let longEar = {
  17. __proto__: rabbit,
  18. eat() {
  19. // ..longEar 调用父类 (rabbit) 方法
  20. this.__proto__.eat.call(this); // (**)
  21. }
  22. };
  23. longEar.eat(); // Error: Maximum call stack size exceeded

调用 longEar.eat() 后,报栈溢出错误了!

直接看结果,并不清晰,所以我们要来分析一下,其中的执行过程。我们从 longEar.eat() 调用出发,看看发生了什么。在 (*)(**) 两处,this 指向的都是 longEar。这就是说,(*) 处于 (**) 处的代码功能完全相同。

最终成为一个死循环了。

类的继承 - 图4

下面再具体分析一下:

  1. longEar.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);
  1. rabbit.eat() 中,(*) 处代码,我们原本希望的是调用更高一层的原型链对象。但发现 this 被绑定成 longEar 了。因此,这里的 longEar.__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);
  1. 所以,rabbit.eat() 调用最终成为一个死循环了。

说明这个问题,不是靠 this 就能解决的。

[[HomeObject]]

针对上面的问题,JavaScript 为函数添加了一个特殊的内部属性:[[HomeObject]],作为解决办法。

当一个函数被指定成类方法或对象方法时,其内部的 [[HomeObject]] 属性就指向这个类或对象。

super 就是使用这个内部属性查找父级原型和方法的。

下面,我们使用普通对象,看下 super 的工作机制:

  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. eat() { // rabbit.eat.[[HomeObject]] 等于 rabbit
  10. super.eat();
  11. }
  12. };
  13. let longEar = {
  14. __proto__: rabbit,
  15. name: 'Long Ear',
  16. eat() { // longEar.eat.[[HomeObject]] 等于 longEar
  17. super.eat();
  18. }
  19. };
  20. // 执行正确!
  21. longEar.eat(); // Long Ear eats.

归因于 [[HomeObject]] 的算法机制,我们最终得到了预期结果。像 longEar.eat 这样的方法,无需使用 this,根据内部的 [[HomeObject]] 属性就能从拿到正确的父级原型和方法。

方法不是“自由的”

因为普通函数的执行环境并没有绑定到特定对象上,因此我们可以说它们是“自由的”。也正以为这样,方法才可以在不同的对象间传递,并指定在不同的 this 环境下执行。

[[HomeObject]] 的存在就违反了这一原则,因为它让方法记住了它们的对象。[[HomeObject]] 的值不可被修改,永远都是指向它最初定义时的所属对象。

语言中唯一用到 [[HomeObject]] 的地方,就是 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 复制的。我们可以假设是为了避免书写代码才这样做的。
  • tree.sayHi[[HomeObject]] 是绑定死指向 rabbit 的。我们无法修改这个内部属性值。
  • tree.sayHi() 内部代码里有句 super.sayHi(),然后根据 [[HomeObject]] 绑定的值,就找到 animal 上的方法了。

下面使用一张图表,说明发生了什么:

图片.png

方法属性,而非函数属性

📝 译者注

在对象字面量中,声明方法有两种方式(如下)。method1 使用对象方法的简洁表示法,method2 则是声明普通对象方法的方式——我们把 method1 称为方法属性, method2 称为函数属性。

let obj = {

method1() {}, method2: function () {} }

[[HomeObject]] 是被定义在类方法和普通对象方法里的。对普通对象而言,方法必须是以 method() 的形式指定,才会有 [[HomeObject]] 这个属性存在(注意不是以 "method: function()" 的方式)。

区别对我们来说可能不重要,但对 JavaScruipt 来说却是重要的。

下例中,我们使用 "method: function()" 形式声明的方法来做个比较。会看见报错——说 [[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(); // Error calling super (because there's no [[HomeObject]])

总结

  1. 继承类的语法:class Child extends Parent
    1. 这样做的结果是,Child.prototype.__proto__ 会被赋值为 Parent.prototype,因此就可以使用继承方法了。
  2. 如果在 Child 中,要显式书写 constructor
    1. 那么必须先在 Child 的构造函数中调用 super()
  3. 如果要重写父级里的方法:
    1. 我们可以在 Child 的方法里,使用 super.method() 的方式调用 Parent 的方法。
  4. 内部:
    1. 类或对象中的方法,会使用一个内部属性 [[HomeObject]] 来记住它所属的类或对象。这就是为什么 super 可以正确解析父级方法的原因。
    2. 因此去复制一个内部包含 super 调用的方法到另一个对象里去的方式并不安全。

同时:

  • 箭头函数不存在自己的 thissuper,它会使用外部的上下文环境中的 thissuper

(完)


📄 文档信息

🕘 更新时间:2020/01/27
🔗 原文链接:http://javascript.info/class-inheritance