一、类继承是一个类扩展另一个类的一种方式。
1、因此,我们可以在现有功能之上创建新功能。
二、想要扩展一个类
class Child extends Parent
1、这意味着Child.prototype.proto将是Parent.prototype,所以方法会被继承。
二、重写一个 constructor:
1、在使用this之前,我们必须在Child的 constructor 中将父 constructor 调用为super()。
三、重写一个方法:
1、我们可以在一个Child方法中使用super.method()来调用Parent方法。
四、内部:
1、方法在内部的[[HomeObject]]属性中记住了它们的类/对象。这就是super如何解析父方法的。
2、因此,将一个带有super的方法从一个对象复制到另一个对象是不安全的。
五、箭头函数没有自己的this或super,所以它们能融入到就近的上下文中,像透明似的。
“extends” 关键字
一、假设我们有 classAnimal:
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");
1、这是我们对对象animal和 classAnimal的图形化表示:
二、然后我们想创建另一个class Rabbit:
1、因为 rabbits 是 animals,所以 classRabbit应该是基于 classAnimal的,可以访问 animal 的方法,以便 rabbits 可以做“一般”动物可以做的事儿。
三、扩展另一个类的语法是:
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!
五、Class Rabbit的对象可以访问例如rabbit.hide()等Rabbit的方法,还可以访问例如rabbit.run()等Animal的方法。
六、在内部,关键字extends使用了很好的旧的原型机制进行工作。它将Rabbit.prototype.[[Prototype]]设置为Animal.prototype。
1、所以,如果在Rabbit.prototype中找不到一个方法,JavaScript 就会从Animal.prototype中获取该方法。
【示例1】要查找rabbit.run方法,JavaScript 引擎会进行如下检查(如图所示从下到上):
- 查找对象rabbit(没有run)。
- 查找它的原型,即Rabbit.prototype(有hide,但没有run)。
- 查找它的原型,即(由于extends)Animal.prototype,在这儿找到了run方法。
八、JavaScript 内建对象同样也使用原型继承(见原生的原型:https://www.yuque.com/tqpuuk/yrrefz/nqskq6)。例如,Date.prototype.[[Prototype]]是Object.prototype。这就是为什么日期可以访问通用对象的方法。
九、在extends后允许任意表达式
1、类语法不仅允许指定一个类,在extends后可以指定任意表达式。
【示例1】一个生成父类的函数调用:
function f(phrase) {
return class {
sayHi() { alert(phrase); }
};
}
class User extends f("Hello") {} // class User继承自f("Hello")的结果
new User().sayHi(); // Hello
2、这对于高级编程模式,例如当我们根据许多条件使用函数生成类,并继承它们时来说可能很有用。
重写
重写方法
一、现在,让我们尝试重写一个方法。默认情况下,所有未在class Rabbit中指定的方法均从class Animal中直接获取。
二、但是如果我们在Rabbit中指定了我们自己的方法,例如stop(),那么将会使用它:
class Rabbit extends Animal {
stop() {
// ……现在这个将会被用作 rabbit.stop()
// 而不是来自于 class Animal 的 stop()
}
}
三、但是通常来说,我们不希望完全替换父类的方法,而是希望在父类方法的基础上进行调整或扩展其功能。我们在我们的方法中做一些事儿,但是在它之前或之后或在过程中会调用父类方法。
四、Class 为此提供了”super”关键字。
- 执行super.method(…)来调用一个父类方法。
- 执行super(…)来调用一个父类 constructor(只能在我们的 constructor 中)。
【示例1】让我们的 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 了!
(1)现在,Rabbit在执行过程中调用父类的super.stop()方法,所以Rabbit也具有了stop方法。
五、箭头函数没有super
1、如果被访问,它会从外部函数获取。例如:
class Rabbit extends Animal {
stop() {
setTimeout(() => super.stop(), 1000); // 1 秒后调用父类的 stop
}
}
2、箭头函数中的super与stop()中的是一样的,所以它能按预期工作。如果我们在这里指定一个“普通”函数,那么将会抛出错误:
// 意料之外的 super
setTimeout(function() { super.stop() }, 1000);
重写 constructor
一、对于重写 constructor 来说,则有点棘手。
二、到目前为止,Rabbit还没有自己的constructor。
三、根据规范,如果一个类扩展了另一个类并且没有constructor,那么将生成下面这样的“空”constructor:
class Rabbit extends Animal {
// 为没有自己的 constructor 的扩展类生成的
constructor(...args) {
super(...args);
}
}
1、正如我们所看到的,它调用了父类的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.
1、我们得到了一个报错。现在我们没法新建 rabbit。是什么地方出错了?
2、简短的解释是:继承类的 constructor 必须调用super(…),并且 (!) 一定要在使用this之前调用。
3、这是为什么呢?这里发生了什么?
五、在 JavaScript 中,继承类(所谓的“派生构造器”,英文为 “derived constructor”)的构造函数与其他函数之间是有区别的。派生构造器具有特殊的内部属性[[ConstructorKind]]:”derived”。这是一个特殊的内部标签。
1、该标签会影响它的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
重写类字段
一、我们不仅可以重写方法,还可以重写类字段。
二 、不过,当我们访问在父类构造器中的一个被重写的字段时,这里会有一个诡异的行为,这与绝大多数其他编程语言都很不一样。
【示例1】
class Animal {
name = 'animal';
constructor() {
alert(this.name); // (*)
}
}
class Rabbit extends Animal {
name = 'rabbit';
}
new Animal(); // animal
new Rabbit(); // animal
1、这里,Rabbit继承自Animal,并且用它自己的值重写了name字段。
2、因为Rabbit中没有自己的构造器,所以Animal的构造器被调用了。
3、有趣的是在这两种情况下:new Animal()和new Rabbit(),在(*)行的alert都打印了animal。
(1)换句话说, 父类构造器总是会使用它自己字段的值,而不是被重写的那一个。
4、古怪的是什么呢?
(1)如果这还不清楚,那么让我们用方法来进行比较。
(2)这里是相同的代码,但是我们调用this.showName()方法而不是this.name字段:
class Animal {
showName() { // 而不是 this.name = 'animal'
alert('animal');
}
constructor() {
this.showName(); // 而不是 alert(this.name);
}
}
class Rabbit extends Animal {
showName() {
alert('rabbit');
}
}
new Animal(); // animal
new Rabbit(); // rabbit
(3)请注意:这时的输出是不同的。
(4)这才是我们本来所期待的结果。当父类构造器在派生的类中被调用时,它会使用被重写的方法。
(5)但对于类字段并非如此。正如前文所述,父类构造器总是使用父类的字段。
三、这里为什么会有这样的区别呢?
1、实际上,原因在于字段初始化的顺序。类字段是这样初始化的:
- 对于基类(还未继承任何东西的那种),在构造函数调用前初始化。
- 对于派生类,在super()后立刻初始化。
2、在我们的例子中,Rabbit是派生类,里面没有constructor()。正如先前所说,这相当于一个里面只有super(…args)的空构造器。
3、所以,new Rabbit()调用了super(),因此它执行了父类构造器,并且(根据派生类规则)只有在此之后,它的类字段才被初始化。在父类构造器被执行的时候,Rabbit还没有自己的类字段,这就是为什么Animal类字段被使用了。
4、这种字段与方法之间微妙的区别只特定于 JavaScript。
5、幸运的是,这种行为仅在一个被重写的字段被父类构造器使用时才会显现出来。接下来它会发生的东西可能就比较难理解了,所以我们要在这里对此行为进行解释。
6、如果出问题了,我们可以通过使用方法或者 getter/setter 替代类字段,来修复这个问题。
super背后的内部机制
一、从我们迄今为止学到的知识来看,super是不可能运行的。
1、以技术的角度它是如何工作的?
(1)当一个对象方法执行时,它会将当前对象作为this。随后如果我们调用super.method(),那么引擎需要从当前对象的原型中获取method。但这是怎么做到的?
2、这个任务看起来是挺容易的,但其实并不简单。引擎知道当前对象的this,所以它可以获取父method作为this.proto.method。
(1)不幸的是,这个“天真”的解决方法是行不通的。
二、让我们演示一下这个问题。简单起见,我们使用普通对象而不使用类。
【示例1】在下面的例子中,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.
1、在(*)这一行,我们从原型(animal)中获取eat,并在当前对象的上下文中调用它。请注意,.call(this)在这里非常重要,因为简单的调用this.proto.eat()将在原型的上下文中执行eat,而非当前对象。
2、在上面的代码中,它确实按照了期望运行:我们获得了正确的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
1、代码无法再运行了!我们可以看到,在试图调用longEar.eat()时抛出了错误。
2、原因可能不那么明显,但是如果我们跟踪longEar.eat()调用,就可以发现原因。
3、在()和(**)这两行中,this的值都是当前对象(longEar)。这是至关重要的一点:所有的对象方法都将当前对象作为this,而非原型或其他什么东西。
4、因此,在()和()这两行中,this.proto的值是完全相同的:都是rabbit。它们俩都调用的是rabbit.eat,它们在不停地循环调用自己,而不是在原型链上向上寻找方法。
5、这张图介绍了发生的情况:
(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在不停地循环调用自己,因此它无法进一步地提升。
6、这个问题没法仅仅通过使用this来解决。
[[HomeObject]]
一、为了提供解决方法,JavaScript 为函数添加了一个特殊的内部属性:[[HomeObject]]。
1、当一个函数被定义为类或者对象方法时,它的[[HomeObject]]属性就成为了该对象。
2、然后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.
1、它基于[[HomeObject]]运行机制按照预期执行。一个方法,例如longEar.eat,知道其[[HomeObject]]并且从其原型中获取父方法。并没有使用this。
方法并不是“自由”的
一、正如我们之前所知道的,函数通常都是“自由”的,并没有绑定到 JavaScript 中的对象。正因如此,它们可以在对象之间复制,并用另外一个this调用它。
二、[[HomeObject]]的存在违反了这个原则,因为方法记住了它们的对象。[[HomeObject]]不能被更改,所以这个绑定是永久的。
三、在 JavaScript 语言中[[HomeObject]]仅被用于super。所以,如果一个方法不使用super,那么我们仍然可以视它为自由的并且可在对象之间复制。但是用了super再这样做可能就会出错。
【示例1】复制后错误的super结果的示例
let animal = {
sayHi() {
alert(`I'm an animal`);
}
};
// rabbit 继承自 animal
let rabbit = {
__proto__: animal,
sayHi() {
super.sayHi();
}
};
let plant = {
sayHi() {
alert("I'm a plant");
}
};
// tree 继承自 plant
let tree = {
__proto__: plant,
sayHi: rabbit.sayHi // (*)
};
tree.sayHi(); // I'm an animal (?!?)
1、调用tree.sayHi()显示 “I’m an animal”。这绝对是错误的。
2、原因很简单:
- 在(*)行,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]])