Skip to content

类继承

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

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

"extends" 关键字

假设我们有 class Animal

js
class Animal {
    constructor(name) {
        this.speed = 0;
        this.name = name;
    }
    run(speed) {
        this.speed = speed;
        console.log(`${this.name} runs with speed ${this.speed}.`);
    }
    stop() {
        this.speed = 0;
        console.log(`${this.name} stands still.`);
    }
}

let animal = new Animal("My animal");

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

image-20220312100912572

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

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

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

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

js
class Rabbit extends Animal {
    hide() {
        console.log(`${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 中获取该方法。

image-20220312103417226

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

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

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

extends 后允许任意表达式

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

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

js
function f(phrase) {
    return class {
        sayHi() { console.log(phrase); }
    };
}

class User extends f("Hello") { }

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

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

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

重写方法

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

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

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

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

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

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

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

js
class Animal {

    constructor(name) {
        this.speed = 0;
        this.name = name;
    }

    run(speed) {
        this.speed = speed;
        console.log(`${this.name} runs with speed ${this.speed}.`);
    }

    stop() {
        this.speed = 0;
        console.log(`${this.name} stands still.`);
    }

}

class Rabbit extends Animal {
    hide() {
        console.log(`${this.name} hides!`);
    }

    stop() {
        super.stop(); // 调用父类的 stop
        this.hide(); // 然后 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 在执行过程中调用父类的 super.stop() 方法,所以 Rabbit 也具有了 stop 方法。

箭头函数没有 super

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

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

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

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

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

重写 constructor

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

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

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

js
class Rabbit extends Animal {
  // 为没有自己的 constructor 的扩展类生成的
  constructor(...args) {
    super(...args);
  }
}

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

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

js
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.
// ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor

哎呦!我们得到了一个报错。现在我们没法新建 rabbit。是什么地方出错了?

简短的解释是:

继承类的 constructor 必须调用 super(...),并且 (!) 一定要在使用 this 之前调用。

……但这是为什么呢?这里发生了什么?确实,这个要求看起来很奇怪。

当然,本文会给出一个解释。让我们深入细节,这样你就可以真正地理解发生了什么。

在 JavaScript 中,继承类(所谓的“派生构造器”,英文为 “derived constructor”)的构造函数与其他函数之间是有区别的。派生构造器具有特殊的内部属性 [[ConstructorKind]]:"derived"。这是一个特殊的内部标签。

该标签会影响它的 new 行为:

  • 当通过 new 执行一个常规函数时,它将创建一个空对象,并将这个空对象赋值给 this
  • 但是当继承的 constructor 执行时,它不会执行此操作。它期望父类的 constructor 来完成这项工作。

因此,派生的 constructor 必须调用 super 才能执行其父类(base)的 constructor,否则 this 指向的那个对象将不会被创建。并且我们会收到一个报错。

为了让 Rabbit 的 constructor 可以工作,它需要在使用 this 之前调用 super(),就像下面这样:

js
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);
console.log(rabbit.name); // White Rabbit
console.log(rabbit.earLength); // 10
console.log(rabbit.speed); // 0

重写类字段:一个棘手的注意要点

高阶要点

这个要点假设你对类已经有了一定的经验,或许是在其他编程语言中。

这里提供了一个更好的视角来窥探这门语言,且解释了它的行为为什么可能会是 bugs 的来源(但不是非常频繁)。

如果你发现这难以理解,什么都别管,继续往下阅读,之后有机会再回来看。

我们不仅可以重写方法,还可以重写类字段。

不过,当我们访问在父类构造器中的一个被重写的字段时,这里会有一个诡异的行为,这与绝大多数其他编程语言都很不一样。

请思考此示例:

js
class Animal {
    name = 'animal';

    constructor() {
        console.log(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.showName() 方法而不是 this.name 字段:

js
class Animal {
    showName() {  // 而不是 this.name = 'animal'
        console.log('animal');
    }

    constructor() {
        this.showName(); // 而不是 alert(this.name);
    }
}

class Rabbit extends Animal {
    showName() {
        console.log('rabbit');
    }
}

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

请注意:这时的输出是不同的。

这才是我们本来所期待的结果。当父类构造器在派生的类中被调用时,它会使用被重写的方法。

……但对于类字段并非如此。正如前文所述,父类构造器总是使用父类的字段。

这里为什么会有这样的区别呢?

实际上,原因在于字段初始化的顺序。类字段是这样初始化的:

  • 对于基类(还未继承任何东西的那种),在构造函数调用前初始化。
  • 对于派生类,在 super() 后立刻初始化。

在我们的例子中,Rabbit 是派生类,里面没有 constructor()。正如先前所说,这相当于一个里面只有 super(...args) 的空构造器。

所以,new Rabbit() 调用了 super(),因此它执行了父类构造器,并且(根据派生类规则)只有在此之后,它的类字段才被初始化。在父类构造器被执行的时候,Rabbit 还没有自己的类字段,这就是为什么 Animal 类字段被使用了。

这种字段与方法之间微妙的区别只特定于 JavaScript。

幸运的是,这种行为仅在一个被重写的字段被父类构造器使用时才会显现出来。接下来它会发生的东西可能就比较难理解了,所以我们要在这里对此行为进行解释。

如果出问题了,我们可以通过使用方法或者 getter/setter 替代类字段,来修复这个问题。

深入:内部探究和[[HomeObject]]

进阶内容

如果你是第一次阅读本教程,那么则可以跳过本节。

这是关于继承和 super 背后的内部机制。

让我们更深入地研究 super。我们将在这个过程中发现一些有趣的事儿。

首先要说的是,从我们迄今为止学到的知识来看,super 是不可能运行的。

的确是这样,让我们问问自己,以技术的角度它是如何工作的?当一个对象方法执行时,它会将当前对象作为 this。随后如果我们调用 super.method(),那么引擎需要从当前对象的原型中获取 method。但这是怎么做到的?

这个任务看起来是挺容易的,但其实并不简单。引擎知道当前对象的 this,所以它可以获取父 method 作为 this.__proto__.method。不幸的是,这个“天真”的解决方法是行不通的。

让我们演示一下这个问题。简单起见,我们使用普通对象而不使用类。

如果你不想知道更多的细节知识,你可以跳过此部分,并转到下面的 [[HomeObject]] 小节。这没关系的。但如果你感兴趣,想学习更深入的知识,那就继续阅读吧。

在下面的例子中,rabbit.__proto__ = animal。现在让我们尝试一下:在 rabbit.eat() 我们将会使用 this.__proto__ 调用 animal.eat()

js
let animal = {
    name: "Animal",
    eat() {
        console.log(`${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

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

js
let animal = {
  name: "Animal",
  eat() {
    console.log(`${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,它们在不停地循环调用自己,而不是在原型链上向上寻找方法。

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

image-20220312112530371

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

    js
    // 在 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

    js
    // 在 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)父原型及其方法。

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

js
let animal = {
  name: "Animal",
  eat() {         // animal.eat.[[HomeObject]] == animal
    console.log(`${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 结果的示例:

js
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 中获取方法。

这是发生的情况示意图:

image-20220312113806156

方法,不是函数属性

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

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

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

js
let animal = {
    eat: function () { // 这里是故意这样写的,而不是 eat() {...
        // ...
    }
};

let rabbit = {
    __proto__: animal,
    eat: function () {
        super.eat();
    }
};

rabbit.eat();  // 错误调用 super(因为这里没有 [[HomeObject]])
// SyntaxError: 'super' keyword unexpected here

总结

new 都干了什么

学习继承,就得先来了解一下new都干了什么,来回顾一下,js中的new关键字

js
function Person(name, age) {
    this.name = name
    this.age = age
}

let a = new Person('cheny', 18)
console.log(a.name); // cheny
console.log(a.age); // 18

console.log(a.constructor === Person); // true ,new 出来的对象的 constructor 属性指向构造方法
console.log(a.__proto__ === Person.prototype); // true,new 出来的对象的 `__proto__` 指向构造方法的原型`prototype`

有几个关键点

  1. new 出来的对象的 constructor 属性指向构造方法

  2. new 出来的对象的 __proto__ 指向构造方法的原型prototype

  3. 如果原来构造返回有返回值,且返回值是一个对象,那么new出来的这个对象,就是构造方法的返回值,如果是基本类型就忽略,返回this

    js
    function Person(name, age) {
        this.name = name
        this.age = age
    
        return obj
    }
    
    let obj = {
        a: 'aaa',
        b: 'bbb'
    }
    
    let a = new Person('cheny', 18)
    console.log(a.name); // undefined
    console.log(a.age); // undefined
    
    console.log(a.a); // aaa
    console.log(a.b); // bbb
    console.log(a === obj); // true 如果原来构造返回有返回值,且返回值是一个对象,那么new出来的这个对象,就是构造方法的返回值,如果是基本类型就忽略,返回`this`
    
    console.log(a.constructor === Person); // false
    console.log(a.__proto__ === Person.prototype); // false

手写一个new

js
function myNew() {
    // 利用函数的arguments参数

    let obj = new Object() // 创建一个空对象

    let constructor = [].shift.call(arguments) // 把第一个参数取出来,是构造方法

    // 将新对象的原型指向构造方法的prototype上
    // 将自己的constructor指向构造方法
    obj.__proto__ = constructor.prototype
    obj.constructor = constructor

    // 执行构造方法,看一下返回值
    let x = constructor.apply(obj, arguments) // 这里的 arguments 是传入的参数,第一个值已经被shift掉了
    // 执行完这一步,obj上就把父类上的属性都继承下来了


    // (x || obj) 为了判断是否为 null,如果是null的话,也返回新对象
    return typeof x === 'object' ? (x || obj) : obj

}


function Person(name, age) {
    this.name = name
    this.age = age
}
Person.prototype.sayHi = function () {
    console.log(`hello I am ${this.name}, I am ${this.age}岁了`);
}

/* ===============测试===================== */

let a = myNew(Person, 'cheny', 18)
a.sayHi() // hello I am cheny, I am 18岁了

console.log(a.constructor === Person); // true
console.log(a.__proto__ === Person.prototype); // true

/* ===============测试===================== */

几种原生的继承方法

js中的类也有继承关系,先看一下原生的继承,之前学过,现在来回顾一下。js中的几种继承方法

1 原型链继承

js
function Parent() {
    this.name = 'parent'
}
Parent.prototype.sayHi = function () {
    console.log(`hello I am ${this.name}`)
}

function Child() { }
Child.prototype = new Parent()
// 上面这句结果就是 Child.prototype.__proto__ === Parent.prototype
Child.prototype.constructor = Child // 手动的改写一下 符合原型的规则
// 如果不改的话 new Parent的constructor是 Parent,不符合原型的规则

let child1 = new Child()
child1.sayHi() // hello I am parent

console.log(child1.__proto__ === Child.prototype); // true
console.log(child1.__proto__.__proto__ === Parent.prototype); // true

有缺点:

  1. 没有办法传参,很明显,没有地方传参,比如let c2 = new Child(1,2),传了也没有地方接收。
  2. 如果Parent属性有引用类型,一旦某个实例修改了当前的值,那么父亲所有的值也会被跟着修改,因为顺着原型链找到的值是引用类型,都是同一份,所以一个改变了,其余的跟着改变。

2 构造函数继承

js
function Person(name, age) {
    this.name = name
    this.age = age
}
Person.prototype.sayHi = function () {
    console.log(`hello I am ${this.name}, I am ${this.age}岁了`);
}


function Child(job, name, age) {
    Person.call(this, name, age)
    // new Child的时候,这个this就是新产生的对象,所以会将父亲的属性都挂载到新对象上,实现继承
    this.job = job
}

let c1 = new Child('前端', 'cheny', 18)
console.log(c1); // Child { name: 'cheny', age: 18, job: '前端' }

// 但是原型链上的方法没有继承过来
c1.sayHi() // TypeError: c1.sayHi is not a function

存在的缺点:

  1. 父构造函数原型上的方法没有继承过来,因为你就没管它
  2. 如果想继承方法,只能将父亲的方法都写在构造方法里面,通过this挂载上去,但是这样浪费内存空间,每次新new一个实例时,同样的方法都会被新创建一份。

3 组合继承

js
function Person(name, age) {
    this.name = name
    this.age = age
}
Person.prototype.sayHi = function () {
    console.log(`hello I am ${this.name}, I am ${this.age}岁了`);
}


function Child(job, name, age) {
    Person.call(this, name, age)
    // new Child的时候,这个this就是新产生的对象,所以会将父亲的属性都挂载到新对象上,实现继承
    this.job = job
}

// 再来一遍原型链继承
Child.prototype = new Person()
Child.prototype.constructor = Child

let c1 = new Child('前端', 'cheny', 18)
console.log(c1); // Child { name: 'cheny', age: 18, job: '前端' }

c1.sayHi() // hello I am cheny, I am 18岁了

也有缺点:

  1. 父构造方法被执行了两次,如果里面有大量的逻辑代码,很浪费时间

    第一次:Person.call(this, name, age)

    第二次:Child.prototype = new Person()

4 寄生组合继承

js
function Person(name, age) {
    this.name = name
    this.age = age
}
Person.prototype.sayHi = function () {
    console.log(`hello I am ${this.name}, I am ${this.age}岁了`);
}


function Child(job, name, age) {
    Person.call(this, name, age)
    // new Child的时候,这个this就是新产生的对象,所以会将父亲的属性都挂载到新对象上,实现继承
    this.job = job
}

// 寄生组合继承,用一个中间的桥梁
let TempFn = function () { } // 一个空的构造方法,避免Person执行两次
TempFn.prototype = Person.prototype // 先把prototype拿过来
Child.prototype = new TempFn()
// 执行结果就是 Child.prototype.__proto__ === TempFn.prototype === Person.prototype
Child.prototype.constructor = Child
// 再改一下 constructor 指向自己就好了

let c1 = new Child('前端', 'cheny', 18)
console.log(c1); // Child { name: 'cheny', age: 18, job: '前端' }

c1.sayHi() // hello I am cheny, I am 18岁了

本节的 extends 继承

  1. extends继承跟上面的寄生组合继承实际上差不多

    js
    class Parent {
        constructor(name, age) {
            this.name = name
            this.age = age
        }
    
        sayHi() {
            console.log(`hello I am ${this.name}, I am ${this.age}岁了`);
        }
    }
    
    class Child extends Parent {
        constructor(name, age, job) {
            super(name, age)
            this.job = job
        }
    
        work() {
            console.log(`I had to work`);
        }
    }
    
    let c1 = new Child('cheny', 18, '前端')
    console.log(c1); // Child { name: 'cheny', age: 18, job: '前端' }
    c1.sayHi() // hello I am cheny, I am 18岁了
    c1.work() // I had to work
    
    console.log(c1.constructor === Child); // true
    console.log(c1.__proto__ === Child.prototype); // true
    console.log(Child.prototype.constructor === Child); // true
    console.log(Child.prototype.__proto__ === Parent.prototype); // true
  2. extends后面其实允许跟任意表达式,这是个高级用法

    js
    function f(phrase) {
        return class {
            sayHi() { console.log(phrase); }
        };
    }
    
    // extends 后面 跟函数表达式也可以,只要结果返回的是一个类 class
    class User extends f("Hello") { }
    
    new User().sayHi(); // Hello
  3. 当我们重写父类方法时,有时候仍旧需要父类的逻辑,只是在此基础上新增一些操作,这时候可以使用 super关键词

    js
    class Parent {
        constructor(name, age) {
            this.name = name
            this.age = age
        }
    
        sayHi() {
            console.log(`hello I am ${this.name}, I am ${this.age}岁了`);
        }
    }
    
    class Child extends Parent {
        constructor(name, age, job) {
            super(name, age)
            this.job = job
        }
    
        sayHi() {
            super.sayHi() // 打完招呼就去工作
            console.log(`I had to work`);
        }
    }
    
    let c1 = new Child('cheny', 18, '前端')
    console.log(c1); // Child { name: 'cheny', age: 18, job: '前端' }
    c1.sayHi()
    /* 
    hello I am cheny, I am 18岁了
    I had to work
    */
  4. 执行super(...)可以调用父类的constructor,但是只能在我们自己的constructor中,并且必须在使用this之前调用,所以我们一般把它放在第一行。

    js
    class Parent {
        constructor(name, age) {
            this.name = name
            this.age = age
        }
    }
    
    class Child extends Parent {
        constructor(name, age, job) {
            // 必须在构造方法里使用
            super(name, age)
            // 并且必须在使用this之前调用,否则会报错
            this.job = job
            // ReferenceError: Must call super constructor in derived class before accessing 'this'
        }
    }
  5. 箭头函数中没有 super,如果访问的话,会从外部访问,所以有时候就可以利用这一点,解决一些延时操作找不到super的问题,用箭头函数包裹一下

    js
    class Parent {
        constructor(name, age) {
            this.name = name
            this.age = age
        }
    
        sayHi() {
            console.log(`hello I am ${this.name}, I am ${this.age}岁了`);
        }
    }
    
    class Child extends Parent {
        constructor(name, age, job) {
            super(name, age)
            this.job = job
        }
    
        sayHi() {
            // 这里如果用普通的函数,就会报错
            // 使用箭头函数的时候,没有super,就会去外部找super
            setTimeout(() => {
                super.sayHi() // 工作一秒后再打招呼
            }, 1000)
            console.log(`I had to work`);
        }
    }
    
    let c1 = new Child('cheny', 18, '前端')
    console.log(c1); // Child { name: 'cheny', age: 18, job: '前端' }
    c1.sayHi()
    /* 
    I had to work
    hello I am cheny, I am 18岁了
    */

    换一下普通函数,看下报错

    js
    class Parent {
        constructor(name, age) {
            this.name = name
            this.age = age
        }
    
        sayHi() {
            console.log(`hello I am ${this.name}, I am ${this.age}岁了`);
        }
    }
    
    class Child extends Parent {
        constructor(name, age, job) {
            super(name, age)
            this.job = job
        }
    
        sayHi() {
            // 报错了 SyntaxError: 'super' keyword unexpected here
            // 意料之外的 super
            setTimeout(function () {
                super.sayHi() // 工作一秒后再打招呼
            }, 1000)
            console.log(`I had to work`);
        }
    }
    
    let c1 = new Child('cheny', 18, '前端')
    console.log(c1); // Child { name: 'cheny', age: 18, job: '前端' }
    c1.sayHi() // SyntaxError: 'super' keyword unexpected here
  6. 继承的类,如果不写constructor,会默认给你加一个

    js
    class Rabbit extends Animal {
      // 为没有自己的 constructor 的扩展类生成的
      constructor(...args) {
        super(...args);
      }
    }
  7. 但是如果我们手动写继承类的constructor时,不调用父类的构造方法,是会报错的

    js
    class Parent {
        constructor(name, age) {
            this.name = name
            this.age = age
        }
    
        sayHi() {
            console.log(`hello I am ${this.name}, I am ${this.age}岁了`);
        }
    }
    
    class Child extends Parent {
        constructor(name, age, job) {
            // 在这里不调用父类的构造方法,会报错
            // ReferenceError: Must call super constructor in derived class before accessing 'this' or returning from derived constructor
            // 继承类的 constructor 必须调用 super(...),并且 (!) 一定要在使用 this 之前调用。
            this.name = name
            this.age = age
            this.job = job
        }
    
    }
    
    let c1 = new Child('cheny', 18, '前端')
  8. 上面报错的原因是

    在js中,继承类(派生构造器,derived constructor)的构造函数,与其他函数之间是有区别的。

    派生构造器内部具有特殊属性[[ConstructorKind]]:"derived"。这是一个特殊的内部标签,该标签会影响new的行为:

    1. 当通过new执行一个常规函数时,它将创建一个空对象,并将这个空对象赋值给this
    2. 但是当继承的constructor执行时,它不会执行此操作,它期望父类的constructor来完成这项工作。

    因此,派生的constructor必须调用super才能执行其父类(base,最上层的构造器,没有任何extends的类)的constructor,否则this指向的那个对象将不会被创建,并且会收到报错。

  9. 有一个非常bug的点,需要特殊记一下,我们不仅能重写父类的方法,还能重写父类的类字段,但是类字段就有意思了。

    1. 重写类字段

      js
      class Animal {
          name = 'animal';
      
          constructor() {
              console.log(this.name); // (*)
          }
      }
      
      class Rabbit extends Animal {
          name = 'rabbit';
      }
      
      new Animal(); // animal
      new Rabbit(); // animal

      Rabbit继承自Animal,并且自己重写了name字段。

      因为Rabbit中没有自己的构造器,所以Animal的构造器就被调用了。

      有趣的是,两个类的实例打印的都是 animal。

      换句话说,父类构造器总是会使用它自己字段的值,而不是背重写的哪一个。

    2. 古怪的是,如果重写类方法,就会正常打印

      js
      class Animal {
          showName() {  // 而不是 this.name = 'animal'
              console.log('animal');
          }
      
          constructor() {
              this.showName(); // 而不是 alert(this.name);
          }
      }
      
      class Rabbit extends Animal {
          showName() {
              console.log('rabbit');
          }
      }
      
      new Animal(); // animal
      new Rabbit(); // rabbit

      这才是我们期望的结果,父类构造器在派生类中被调用,会使用被重写的方法。

      但对于类字段并非如此。

    3. 为什么会有上面的区别呢?

      实际上,原因在于字段初始化的顺序,类字段是这样初始化的:

      1. 对于基类(还未继承任何东西的那种),在构造函数调用前初始化
      2. 对于派生类,在super()后立即初始化

      所以,上面的rabbit是派生类,里面没有constructor,相当于有一个空的super(...args)构造器。

      所以,new Rabbit调用了super(),因此它执行了父类构造器,并且(根据派生类规则),只有在此之后,它的类字段才被初始化。所以在父类构造器被执行的时候,Rabbit还没有自己的类字段,这就是为什么animal类字段被使用了。

      (这种字段与方法之间微妙的区别只特定与JavaScript)

      所以如果出现问题了,可以使用类的 get set 来解决,知道为啥出现这种意料之外的情况即可。

  10. 进阶内容,super的执行机制,挺复杂的,可以看文中的例子。

  11. 为了解决super执行复杂的问题,js引入了一个特殊的内部属性[[HomeObject]]

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

    super其实是使用这个字段来解析父原型及方法的。

  12. js中的函数都是自由的,可以在对象之间来回复制,并用另外一个this调用它,但是[[HomeObject]]的出现违反了这个原则。

    不过其实这个字段仅仅为super关键字来服务的,可以不用关心,并且这个字段是不能被更改的。

  13. [[HomeObject]]是为类和普通对象方法定义的。

    对于对象而言,方法必须确切指定为method(),而不是method: function()

    这个差别对我们来说可能不重要,但是对 JavaScript 来说却非常重要。比如下面这个例子,使用非方法(non-method)语法进行了比较。未设置 [[HomeObject]] 属性,并且继承无效::

    js
    let animal = {
        eat: function () { // 这里是故意这样写的,而不是 eat() {...
            // ...
        }
    };
    
    let rabbit = {
        __proto__: animal,
        eat: function () {
            super.eat();
        }
    };
    
    rabbit.eat();  // 错误调用 super(因为这里没有 [[HomeObject]])
    // SyntaxError: 'super' keyword unexpected here

参考

https://zh.javascript.info/class-inheritance