2022 年 5 月 12 日

类继承

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

这样我们就可以在现有功能的基础上创建新的功能。

“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 方法,引擎会检查(从下到上)

  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 Userf("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
  }
}

箭头函数中的 superstop() 中的相同,因此按预期工作。如果我们在此处指定“常规”函数,则会出现错误

// 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,而不会在无限循环中向上移动链条。

以下是发生的事情的图片

  1. 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);
  2. 然后在 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);
  3. …所以 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.sayHirabbit 复制。也许我们只是想避免代码重复?
  • 它的 [[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]])

摘要

  1. 要扩展一个类:class Child extends Parent
    • 这意味着 Child.prototype.__proto__ 将为 Parent.prototype,因此方法被继承。
  2. 重写构造函数时
    • 在使用 this 之前,我们必须在 Child 构造函数中将父构造函数调用为 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);

这是因为子构造函数必须调用 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"); // ok now
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);
  }
}

创建一个新的类 ExtendedClock,它继承自 Clock 并添加参数 precision - “滴答”之间的 ms 数。默认情况下应该是 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 问题 或提交拉取请求,而不是评论。
  • 如果你无法理解文章中的内容 - 请详细说明。
  • 要插入几行代码,请使用 <code> 标签,对于多行 - 将它们包装在 <pre> 标签中,对于超过 10 行 - 使用沙箱(plnkrjsbincodepen…)