Skip to content

Class

申明

类声明的类体在严格模式下执行。class 声明与 let 非常相似:

  • class 声明的作用域既可以是块级作用域,也可以是函数作用域。
  • class 声明只能在其声明位置之后才能访问(参见暂时性死区)。因此 class 声明通常被认为是不可变量提升的(与函数声明不同)。
  • class 声明在脚本顶层声明时不会在 globalThis 上创建属性(与函数声明不同)。
  • 在同一作用域内,class 声明不能被任何其他声明重复声明。

基本

  • ES6 提供了更接近传统语言的写法,引入了 Class(类)这个概念,作为对象的模板。通过class关键字,可以定义类。
  • ES6 的class可以看作只是一个语法糖,它的绝大部分功能,ES5 都可以做到。class写法更语义化。

相同的代码分别用ES5和ES6实现

ES5构造函数

javascript
function Point(x, y) {
  this.x = x;
  this.y = y;
}

Point.prototype.toString = function () {
  return '(' + this.x + ', ' + this.y + ')';
};

var p = new Point(1, 2);
function Point(x, y) {
  this.x = x;
  this.y = y;
}

Point.prototype.toString = function () {
  return '(' + this.x + ', ' + this.y + ')';
};

var p = new Point(1, 2);

ES6class类

javascript
class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
  toString() {
    return '(' + this.x + ', ' + this.y + ')';
  }
}
const p = new Point(1, 2);
class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
  toString() {
    return '(' + this.x + ', ' + this.y + ')';
  }
}
const p = new Point(1, 2);

构造函数里面的代码 = [constructor]里面的代码;

构造函数的prototype属性和方法 = 中的属性和方法;

类的定义

类实际上是“特殊的函数”,就像你能够定义的函数表达式和函数声明一样,类也有两种定义方式:类表达式类声明

javascript
// 类声明
class Rectangle {
  constructor(height, width) {
    this.height = height;
    this.width = width;
  }
}

// 类表达式;类是匿名的,但是它被赋值给了变量
const Rectangle = class {
  constructor(height, width) {
    this.height = height;
    this.width = width;
  }
};

// 类表达式;类有它自己的名字
const Rectangle = class Rectangle2 {
  constructor(height, width) {
    this.height = height;
    this.width = width;
  }
};
// 类声明
class Rectangle {
  constructor(height, width) {
    this.height = height;
    this.width = width;
  }
}

// 类表达式;类是匿名的,但是它被赋值给了变量
const Rectangle = class {
  constructor(height, width) {
    this.height = height;
    this.width = width;
  }
};

// 类表达式;类有它自己的名字
const Rectangle = class Rectangle2 {
  constructor(height, width) {
    this.height = height;
    this.width = width;
  }
};

类的[constructor]

  • constructor方法是类的默认方法,通过new命令生成对象实例时,自动调用该方法。
  • 一个必须有constructor方法,如果没有显式定义,一个空的constructor方法会被默认添加。

类的主体

  • 类的主体是其被花括号 {} 包裹的部分。这里是你定义方法或构造函数等类成员的地方。
  • 类的主体会执行在严格模式下,即便没有写 "use strict" 指令也一样。
  • 可以从以下三个方面表述一个类元素的特征:(它们总共有 16 种可能的组合)

    ①、种类:getter、setter、方法、属性(字段);

    ②、位置:静态的或位于实例上(static/super)

    ③、可见性:公有或私有;

get、set

  • 与 ES5 一样,在“类”的内部可以使用get和set关键字,对某个属性设置存值函数和取值函数,拦截该属性的存取行为。
javascript
class MyClass {
  constructor() {
    // ...
  }
  get prop() {
    return 'getter';
  }
  set prop(value) {
    console.log('setter: '+value);
  }
}
let inst = new MyClass();
inst.prop = 123; // setter: 123
inst.prop // 'getter'
class MyClass {
  constructor() {
    // ...
  }
  get prop() {
    return 'getter';
  }
  set prop(value) {
    console.log('setter: '+value);
  }
}
let inst = new MyClass();
inst.prop = 123; // setter: 123
inst.prop // 'getter'

静态属性、方法

javascript
class ClassWithStatic {
  static staticField;
  static staticFieldWithInitializer = value;
  static staticMethod() {/** ... */}
}
class ClassWithStatic {
  static staticField;
  static staticFieldWithInitializer = value;
  static staticMethod() {/** ... */}
}
  • 在class内部 属性、方法 前面使用static关键字,用来定义类的静态方法或字段。
  • 静态属性、方法被定义在类的自身上不是类的实例上。(实例是无法访问到的)
  • 静态属性(字段或方法)的名称不能是 prototype,类字段(静态或实例)的名称不能是 constructor。
  • 静态字段可以有初始化器。没有初始化器的静态字段将被初始化为 undefined。公有静态字段不会在子类中重新初始化,但可以通过原型链访问。在字段初始化器中,this 指向当前类(也可通过其名称访问),super 指向基类构造函数。
  • 表达式是同步求值的。不能在初始化表达式中使用 await 或 yield。
  • 静态字段初始化器和静态初始化块是逐个求值的。字段初始化器可以引用其上的字段值,但不能引用其下的字段值。所有静态方法都会事先添加并可被访问,但如果它们引用的字段在被初始化的字段的下方,则调用它们时可能会出现与预期不符的情况。
  • 静态方法通常是实用函数,如创建或克隆对象的函数,而静态属性则适用于缓存、固定配置或其他不需要跨实例复制的数据。

基本示例

javascript
class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
  static name = "Point";
  static distance(a, b) {
    const dx = a.x - b.x;
    const dy = a.y - b.y;
    return Math.hypot(dx, dy);
  }
}
const p1 = new Point(5, 5);
const p2 = new Point(10, 10);
p1.name; // undefined
p1.distance; // undefined
console.log(Point.displayName); // "Point"
console.log(Point.distance(p1, p2)); // 7.0710678118654755
class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
  static name = "Point";
  static distance(a, b) {
    const dx = a.x - b.x;
    const dy = a.y - b.y;
    return Math.hypot(dx, dy);
  }
}
const p1 = new Point(5, 5);
const p2 = new Point(10, 10);
p1.name; // undefined
p1.distance; // undefined
console.log(Point.displayName); // "Point"
console.log(Point.distance(p1, p2)); // 7.0710678118654755
在类中使用静态成员
javascript
class Triple {
  static customName = "三倍器";
  static description = "我可以让你提供的任何数变为它的三倍";
  static calculate(n = 1) {
    return n * 3;
  }
}

class SquaredTriple extends Triple {
  static longDescription;
  static description = "我可以让你提供的任何数变为其三倍的平方";
  static calculate(n) {
    return super.calculate(n) * super.calculate(n);
  }
}

console.log(Triple.description); // '我可以让你提供的任何数变为它的三倍'
console.log(Triple.calculate()); // 3
console.log(Triple.calculate(6)); // 18

let tp = new Triple();

console.log(SquaredTriple.tripple(3)); // 81(不会受父类实例化的影响)
console.log(SquaredTriple.description); // '我可以让你提供的任何数变为其三倍的平方'
console.log(SquaredTriple.longDescription); // undefined
console.log(SquaredTriple.customName); // '三倍器'

// 抛出错误,因为 calculate() 是静态成员,而不是实例成员。
console.log(tp.calculate()); // 'tp.calculate 不是一个函数'
class Triple {
  static customName = "三倍器";
  static description = "我可以让你提供的任何数变为它的三倍";
  static calculate(n = 1) {
    return n * 3;
  }
}

class SquaredTriple extends Triple {
  static longDescription;
  static description = "我可以让你提供的任何数变为其三倍的平方";
  static calculate(n) {
    return super.calculate(n) * super.calculate(n);
  }
}

console.log(Triple.description); // '我可以让你提供的任何数变为它的三倍'
console.log(Triple.calculate()); // 3
console.log(Triple.calculate(6)); // 18

let tp = new Triple();

console.log(SquaredTriple.tripple(3)); // 81(不会受父类实例化的影响)
console.log(SquaredTriple.description); // '我可以让你提供的任何数变为其三倍的平方'
console.log(SquaredTriple.longDescription); // undefined
console.log(SquaredTriple.customName); // '三倍器'

// 抛出错误,因为 calculate() 是静态成员,而不是实例成员。
console.log(tp.calculate()); // 'tp.calculate 不是一个函数'
从另一个静态方法调用静态成员
  • 为了在同一类的另一个静态方法中调用静态方法或属性,可以使用 this 关键字。
javascript
class StaticMethodCall {
  static staticProperty = "静态属性";
  static staticMethod() {
    return `静态方法和${this.staticProperty}被调用`;
  }
  static anotherStaticMethod() {
    return `从另外一个静态方法而来的${this.staticMethod()}`;
  }
}
StaticMethodCall.staticMethod(); // '静态方法和静态属性被调用'
StaticMethodCall.anotherStaticMethod(); // '从另外一个静态方法而来的静态方法和静态属性被调用'
class StaticMethodCall {
  static staticProperty = "静态属性";
  static staticMethod() {
    return `静态方法和${this.staticProperty}被调用`;
  }
  static anotherStaticMethod() {
    return `从另外一个静态方法而来的${this.staticMethod()}`;
  }
}
StaticMethodCall.staticMethod(); // '静态方法和静态属性被调用'
StaticMethodCall.anotherStaticMethod(); // '从另外一个静态方法而来的静态方法和静态属性被调用'
从类的构造函数和其他方法中调用静态成员
  • 静态成员不能使用 this 关键字从非静态方法直接访问静态成员。
  • 需要使用类名来调用 classname.static_method_name() 或 CLASSNAME.STATIC_PROPERTY_NAME
javascript
class StaticMethodCall {
  constructor() {
    console.log(StaticMethodCall.staticProperty); // '静态属性'
    console.log(this.constructor.staticProperty); // '静态属性'
    console.log(StaticMethodCall.staticMethod()); // '静态方法已调用'
    console.log(this.constructor.staticMethod()); // '静态方法已调用'
  }
  static staticProperty = "静态属性";
  static staticMethod() {
    return "静态方法已调用";
  }
}
class StaticMethodCall {
  constructor() {
    console.log(StaticMethodCall.staticProperty); // '静态属性'
    console.log(this.constructor.staticProperty); // '静态属性'
    console.log(StaticMethodCall.staticMethod()); // '静态方法已调用'
    console.log(this.constructor.staticMethod()); // '静态方法已调用'
  }
  static staticProperty = "静态属性";
  static staticMethod() {
    return "静态方法已调用";
  }
}

私有属性、方法

  • 通过添加#前缀来创建,在类的外部无法合法地引用。(es6之前使用闭包实现)
  • 类中所有声明的私有标识符都必须是唯一的,并且命名空间在静态属性和实例属性之间是共享的。唯一的例外是:两个声明定义了 getter-setter。(注意)
  • 私有描述符不能是 #constructor。(构造函数不能是私有的)
  • 私有属性不是普通的字符串属性,因此无法使用方括号表示法动态访问它。
  • 私有名称始终需要提前声明、并且不可删除。
  • 私有属性不是原型继承模型的一部分,因为它们只能在当前类内部被访问,而且不能被子类继承。不同类的私有属性名称之间没有任何交互。(Object.freeze() 和 Object.seal() 对私有属性没有影响)
  • 私有属性包括:

    私有字段、私有方法、私有静态字段、私有静态方法、私有 getter、私有 setter、私有静态 getter、私有静态 setter。

私有属性的访问
  1. 在静态函数中以及在外部定义的类的实例上访问私有属性。
javascript
class C {
    #x = 100;
    static getPrivate(obj){
        return obj.#x; 
    }
}
const c = new C();
console.log(C.getPrivate(c)) // 100
console.log(C.getPrivate({})) // {} 必须是 C 的实例。 否则抛出 TypeError
// 如果访问对象中不存在的私有属性,会抛出 TypeError 错误,而不是像普通属性一样返回 undefined。
class C {
    #x = 100;
    static getPrivate(obj){
        return obj.#x; 
    }
}
const c = new C();
console.log(C.getPrivate(c)) // 100
console.log(C.getPrivate({})) // {} 必须是 C 的实例。 否则抛出 TypeError
// 如果访问对象中不存在的私有属性,会抛出 TypeError 错误,而不是像普通属性一样返回 undefined。
  1. 使用 in 运算符 来检查一个外部定义的对象是否拥有一个私有属性。
javascript
class C {
  #x;
  constructor(x) {
    this.#x = x;
  }
  static getX(obj) {
    if (#x in obj) return obj.#x;
    return `必须是 C 的实例`;
  }
}
console.log(C.getX(new C("foo"))); // "foo"
console.log(C.getX(new C(0.196))); // 0.196
console.log(C.getX(new C(new Date()))); // 
console.log(C.getX({})); // 必须是 C 的实例
class C {
  #x;
  constructor(x) {
    this.#x = x;
  }
  static getX(obj) {
    if (#x in obj) return obj.#x;
    return `必须是 C 的实例`;
  }
}
console.log(C.getX(new C("foo"))); // "foo"
console.log(C.getX(new C(0.196))); // 0.196
console.log(C.getX(new C(new Date()))); // 
console.log(C.getX({})); // 必须是 C 的实例

一个对象具有当前类的一个私有属性(无论是通过 try...catch 还是 in 检查),那么它一定具有其他所有的私有属性。通常情况下,一个对象具有一个类的私有属性意味着它是由该类构造的(尽管并非总是如此)。

私有字段

私有字段包括私有实例字段私有静态字段,私有字段只能在类声明内部被访问。

  1. 私有实例字段:在基类中的构造函数运行之前添加,或者在子类中调用 super() 之后立即添加,且只在类的实例上可用。
javascript
class ClassWithPrivateField {
  #privateField;
  constructor() {
    this.#privateField = 42;
  }
}
class Subclass extends ClassWithPrivateField {
  #subPrivateField;
  constructor() {
    super();
    this.#subPrivateField = 23;
  }
}
new Subclass() 
// Subclass {#privateField: 42, #subPrivateField: 23} 
// google浏览器可以打印,外部其实是无法访问的、也无法从派生的 Subclass 访问 #privateField
class ClassWithPrivateField {
  #privateField;
  constructor() {
    this.#privateField = 42;
  }
}
class Subclass extends ClassWithPrivateField {
  #subPrivateField;
  constructor() {
    super();
    this.#subPrivateField = 23;
  }
}
new Subclass() 
// Subclass {#privateField: 42, #subPrivateField: 23} 
// google浏览器可以打印,外部其实是无法访问的、也无法从派生的 Subclass 访问 #privateField

  1. 私有静态字段:在类实例化前被添加到类的构造函数中,并且只能在类本身上可用。
javascript
class ClassWithPrivateStaticField {
  static #privateStaticField = 42;
  static publicStaticMethod() {
    return ClassWithPrivateStaticField.#privateStaticField;
  }
}
console.log(ClassWithPrivateStaticField.publicStaticMethod()); // 42
class ClassWithPrivateStaticField {
  static #privateStaticField = 42;
  static publicStaticMethod() {
    return ClassWithPrivateStaticField.#privateStaticField;
  }
}
console.log(ClassWithPrivateStaticField.publicStaticMethod()); // 42

  1. 私有实例字段私有静态字段区别:

①、声明方式:

  • 私有实例字段在属性名前面加上 # 符号来声明,例如:#privateField
  • 私有静态字段在属性名前面加上 static # 符号来声明,例如:static #privateStaticField

②、 可见性:

  • 私有实例字段只能在类中其声明的位置和类中定义的任何方法中访问。在类的外部和子类中都无法直接访问私有实例字段。
  • 私有静态字段只能在类中其声明的位置和类中定义的任何方法中访问。在类的外部和子类中都无法直接访问私有静态字段。

③、 继承:

  • 私有实例字段在子类中无法继承。在子类中需要重新声明私有实例字段并在构造函数中使用 super() 关键字来访问父类中的私有实例字段。
  • 私有静态字段在子类中可以继承。子类可以直接访问父类中的私有静态字段。
私有方法

私有方法包括私有实例方法私有静态方法,私有方法只能在类声明内部被访问。

  1. 私有实例方法
  • 在实例字段安装之前立即安装,且只能在类的实例上可用,不能在类的 .prototype 属性上访问。
  • 私有实例方法可以是生成器方法、异步方法或异步生成器方法。私有 getter 和 setter 方法也同样适用,并且与公有 getter 和 setter 方法的语法相同。
  • 与公有方法不同,私有方法不能在类的 .prototype 属性上访问。
javascript
class ClassWithPrivateMethod {
  #privateMethod() {
    return 42;
  }
  publicMethod() {
    return this.#privateMethod();
  }
}
const instance = new ClassWithPrivateMethod();
console.log(instance.publicMethod()); // 42
class ClassWithPrivateMethod {
  #privateMethod() {
    return 42;
  }
  publicMethod() {
    return this.#privateMethod();
  }
}
const instance = new ClassWithPrivateMethod();
console.log(instance.publicMethod()); // 42

  1. 私有静态方法
  • 在类被解析时被添加到类的构造函数中,并且只能在类本身上可用。
  • 私有静态方法可以是生成器方法,异步方法或异步生成器方法。
  • 私有静态字段的限制同样适用于私有静态方法。同样地,使用 this 可能会出现意想不到的行为。
javascript
class ClassWithPrivateStaticMethod {
  static #privateStaticMethod() {
    return 42;
  }
  static publicStaticMethod() {
    return this.#privateStaticMethod();
  }
}
class Subclass extends ClassWithPrivateStaticMethod {}
console.log(Subclass.publicStaticMethod()); 
// TypeError: Cannot read private member #privateStaticMethod from an object whose class did not declare it
class ClassWithPrivateStaticMethod {
  static #privateStaticMethod() {
    return 42;
  }
  static publicStaticMethod() {
    return this.#privateStaticMethod();
  }
}
class Subclass extends ClassWithPrivateStaticMethod {}
console.log(Subclass.publicStaticMethod()); 
// TypeError: Cannot read private member #privateStaticMethod from an object whose class did not declare it
  1. 私有实例方法私有静态方法区别:

①、声明方式:

  • 私有实例方法在方法名前面加上 # 符号来声明,例如:#privateMethod()
  • 私有静态方法在方法名前面加上 static # 符号来声明,例如:static #privateStaticMethod()

②、可见性:

  • 私有实例方法只能在类中其声明的位置和类中定义的任何方法中访问。在类的外部和子类中都无法直接访问私有实例方法。
  • 私有静态方法只能在类中其声明的位置和类中定义的任何方法中访问。在类的外部和子类中都无法直接访问私有静态方法。

③、继承:

  • 私有实例方法在子类中无法继承。在子类中需要重新声明私有实例方法并在方法中使用 super() 关键字来访问父类中的私有实例方法。
  • 私有静态方法在子类中可以继承。子类可以直接访问父类中的私有静态方法。

TIP

备注:Chrome 控制台中运行的代码可以访问类的私有属性。这是 JavaScript 语法限制对开发者工具的一种放宽。

new.target

  • ES6 为new命令引入了一个new.target属性,该属性一般用在构造函数之中,返回 new 命令作用于的那个构造函数。
  • 如果构造函数不是通过new命令或Reflect.construct()调用的,new.target会返回undefined,因此这个属性可以用来确定构造函数是怎么调用的。
  • 在函数外部,使用new.target会报错。
函数调用中的 new.target

在普通的函数调用中(和作为构造函数来调用相对),new.target的值是undefined。这使得你可以检测一个函数是否是作为构造函数通过new被调用的。

javascript
function Foo() {
  if (!new.target) throw "Foo() must be called with new";
  console.log("Foo instantiated with new");
}
Foo(); // throws "Foo() must be called with new"
new Foo(); // logs "Foo instantiated with new"
function Foo() {
  if (!new.target) throw "Foo() must be called with new";
  console.log("Foo instantiated with new");
}
Foo(); // throws "Foo() must be called with new"
new Foo(); // logs "Foo instantiated with new"
构造函数中的 new.target

在类的构造方法中,new.target指向 直接被new执行的构造函数。并且当一个父类构造方法在子类构造方法中被调用时,情况与之相同。

javascript
class A {
  constructor() {
    console.log(new.target.name);
  }
}

class B extends A {
  constructor() {
    super();
  }
}

var a = new A(); // logs "A"
var b = new B(); // logs "B"

class C {
  constructor() {
    console.log(new.target);
  }
}
class D extends C {
  constructor() {
    super();
  }
}

var c = new C(); // logs class C{constructor(){console.log(new.target);}}
var d = new D(); // logs class D extends C{constructor(){super();}}
class A {
  constructor() {
    console.log(new.target.name);
  }
}

class B extends A {
  constructor() {
    super();
  }
}

var a = new A(); // logs "A"
var b = new B(); // logs "B"

class C {
  constructor() {
    console.log(new.target);
  }
}
class D extends C {
  constructor() {
    super();
  }
}

var c = new C(); // logs class C{constructor(){console.log(new.target);}}
var d = new D(); // logs class D extends C{constructor(){super();}}

new.target 指向的是初始化类的类定义。比如当 D 通过 new 初始化的时候,打印出了 D 的类定义,C 的例子与之类似,打印出的是 C 的类定义。

super()

  • super 关键字用于访问对象字面量或类的原型([Prototype])上的属性,或调用父类的构造函数。
  • super 关键字有两种使用方式:在派生类的构造函数体中作为“函数调用”,或作为“属性查询”。

    作为“函数调用”出现,它必须在使用 this 关键字之前和构造函数返回之前被调用。

    作为“属性查询”(作为对象时),在普通方法中,指向父类的原型对象;在静态方法中,指向父类。

  • 注意,super 的引用是由 super 声明的类或对象字面决定的,而不是方法被调用的对象。因此,取消绑定或重新绑定一个方法并不会改变其中 super 的引用(尽管它们会改变 this 的引用)。你可以把 super 看作是类或对象字面范围内的一个变量,这些方法在它上面创建了一个闭包。(但也要注意,它实际上并不是一个变量,正如上面所解释的那样)。
  • 当通过 super 设置属性时,该属性将被设置在 this 上。
javascript
class Square {
  constructor(height, width) {
    this.name = "矩形";
    this.height = height;
    this.width = width;
  }
}
class FilledRectangle extends Rectangle {
  constructor(height, width, color) {
    super(height, width);
    this.name = "填充矩形";
    this.color = color;
  }
}
class Square {
  constructor(height, width) {
    this.name = "矩形";
    this.height = height;
    this.width = width;
  }
}
class FilledRectangle extends Rectangle {
  constructor(height, width, color) {
    super(height, width);
    this.name = "填充矩形";
    this.color = color;
  }
}

extends

javascript
class childClass extends parentClass { /** ... */}
class childClass extends parentClass { /** ... */}
  • extends 关键字用于类声明或者类表达式中,以创建一个类,该类是另一个类的子类。
  • 任何可以用 new 调用并具有 prototype 属性的构造函数都可以作为候选的父类的构造函数。这两个条件必须同时成立。ParentClass 的 prototype 属性必须是 Object 或 null。
  • extend 的右侧不一定是标识符。你可以使用任何求值为构造函数的表达式。这通常有助于创建混入(mixin)
  • extends 表达式中的 this 值是围绕类定义的 this ,而引用类的名称会导致 ReferenceError,因为类尚未初始化。在此表达式中,await 和 yield 按预期工作(因为此时没有初始化)。
  • 基类可以从构造函数中返回任何内容,而派生类必须返回对象或 undefined ,否则将抛出 TypeError。(派生类中使用super())
  • 如果父类构造函数返回一个对象,则在进一步初始化类字段时,该对象将被用作派生类的 this 值。这种技巧被称为“返回覆盖”,它允许在无关对象上定义派生类的字段(包括私有字段)。

extends 为 ChildClass 和 ChildClass.prototype 设置了原型。

继承ChildClass 的原型对象ChildClass.prototype 的原型对象
缺少 extendsFunction.prototypeObject.prototype
extends nullFunction.prototypenull
extends ParentClassParentClassParentClass.prototype
javascript
class ParentClass {}
class ChildClass extends ParentClass {}

// 允许静态属性的继承
Object.getPrototypeOf(ChildClass) === ParentClass;
// 允许实例属性的继承
Object.getPrototypeOf(ChildClass.prototype) === ParentClass.prototype;
class ParentClass {}
class ChildClass extends ParentClass {}

// 允许静态属性的继承
Object.getPrototypeOf(ChildClass) === ParentClass;
// 允许实例属性的继承
Object.getPrototypeOf(ChildClass.prototype) === ParentClass.prototype;

子类化内置类

扩展类时可能会遇到的一些问题:

  1. 在子类上调用静态工厂方法(如 Promise.resolve() 或 Array.from())时,返回的实例始终是子类的实例。
  2. 在子类上调用返回新实例的实例方法(如 Promise.prototype.then() 或 Array.prototype.map())时,返回的实例始终是子类的实例。
  3. 在可能的情况下,实例方法会尽量委托给最小的原始方法集。例如,对于 Promise 的子类,覆盖 then() 会自动导致 catch() 的行为发生变化;或对于 Map 的子类,覆盖 set() 会自动导致 Map() 构造函数的行为发生变化。

正确地实现上述期望需要实现:

  1. 第一个要求静态方法读取 this 的值,以获取构造函数来构造返回的实例。这意味着 [p1,p2,p3].map(Promise.resolve) 会抛出错误,因为 Promise.resolve 中的 this 是 undefined。解决这个问题的方法是,如果 this 不是构造函数,就回退到基类,就像 Array.from() 所做的那样,但这仍然意味着基类是特例。
  2. 第二个要求实例方法读取 this.constructor 以获取构造函数。但是,new this.constructor() 可能会破坏老旧的代码,因为 constructor 属性是可写和可配置的,而且不受任何保护。因此,许多复制的内置方法都使用构造函数的 [Symbol.species] 属性(默认情况下只返回 this,即构造函数本身)。然而,[Symbol.species] 允许运行任意代码和创建任意类型的实例,这就带来了安全问题,并使子类化语义变得非常复杂。
  3. 第三个会导致自定义代码的可见调用,从而使很多优化更难实现。例如,如果使用包含 x 个元素的可迭代元素调用 Map() 构造函数,那么它必须明显地调用 set() 方法 x 次,而不仅仅是将元素复制到内部存储。

警告:

标准委员会目前的立场是,以前版本规范中的内置类的子类化机制设计过度,对性能和安全性造成了不可忽视的影响。新的内置方法较少考虑子类,引擎实现者正在研究是否要删除某些子类机制。在增强内置类时,请考虑使用组合而非继承

使用 extends
javascript
class Square extends Polygon {
  constructor(length) {
    super(length, length); // 在这里,它会调用父类的构造函数,并为多边形的宽度和高度提供长度
    this.name = "Square";  // 在派生类中,必须先调用 super() 才能用“this”。省略这一点将导致引用错误。
  }
  get area() {
    return this.height * this.width;
  }
}
class Square extends Polygon {
  constructor(length) {
    super(length, length); // 在这里,它会调用父类的构造函数,并为多边形的宽度和高度提供长度
    this.name = "Square";  // 在派生类中,必须先调用 super() 才能用“this”。省略这一点将导致引用错误。
  }
  get area() {
    return this.height * this.width;
  }
}
扩展普通对象

类不能扩展常规(不可构造)对象。如果想通过在继承实例中使用常规对象的所有属性来继承该对象,可以使用 Object.setPrototypeOf() 代替:

javascript
const Animal = {
  speak() {
    console.log(`${this.name} 发出了噪音`);
  },
};
class Dog {
  constructor(name) {
    this.name = name;
  }
}
Object.setPrototypeOf(Dog.prototype, Animal);
const d = new Dog("Mitzie");
d.speak(); // Mitzie 发出了噪音
const Animal = {
  speak() {
    console.log(`${this.name} 发出了噪音`);
  },
};
class Dog {
  constructor(name) {
    this.name = name;
  }
}
Object.setPrototypeOf(Dog.prototype, Animal);
const d = new Dog("Mitzie");
d.speak(); // Mitzie 发出了噪音
扩展内置对象

这个示例继承了内置的 Date 对象。

javascript
class MyDate extends Date {
  getFormattedDate() {
    const months = [
      "Jan", "Feb", "Mar", "Apr", "May", "Jun",
      "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
    ];
    return `${this.getDate()}-${months[this.getMonth()]}-${this.getFullYear()}`;
  }
}
class MyDate extends Date {
  getFormattedDate() {
    const months = [
      "Jan", "Feb", "Mar", "Apr", "May", "Jun",
      "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
    ];
    return `${this.getDate()}-${months[this.getMonth()]}-${this.getFullYear()}`;
  }
}
扩展 Object
  • 所有 JavaScript 对象默认情况下都继承自 Object.prototype,因此乍一看,编写 extends Object 似乎是多余的。与完全不写 extends 的唯一区别是构造函数本身继承了 Object 的静态方法,例如 Object.keys()。然而,由于没有任何 Object 静态方法会使用 this 值,因此继承这些静态方法仍然没有任何价值。
  • Object() 构造函数特殊处理了子类化情况。如果通过 super() 隐式调用该构造函数,则该构造函数始终以 new.target.prototype 为原型初始化一个新对象。传递给 super() 的任何值都将被忽略。
javascript
class C extends Object {
  constructor(v) {
    super(v);
  }
}
console.log(new C(1) instanceof Number); // false
console.log(C.keys({ a: 1, b: 2 })); // [ 'a', 'b' ]
class C extends Object {
  constructor(v) {
    super(v);
  }
}
console.log(new C(1) instanceof Number); // false
console.log(C.keys({ a: 1, b: 2 })); // [ 'a', 'b' ]
  • 将这种行为与不对子类进行特殊处理的自定义包装器进行比较:
javascript
function MyObject(v) {
  return new Object(v);
}
class D extends MyObject {
  constructor(v) {
    super(v);
  }
}
console.log(new D(1) instanceof Number); // true
function MyObject(v) {
  return new Object(v);
}
class D extends MyObject {
  constructor(v) {
    super(v);
  }
}
console.log(new D(1) instanceof Number); // true
Species
  • 你可能希望在派生数组类 MyArray 中返回 Array 对象。Species 模式可让你覆盖默认构造函数。
  • 在使用 Array.prototype.map() 等返回默认构造函数的方法时,你希望这些方法返回的是父 Array 对象,而不是 MyArray 对象。Symbol.species 符号可让你做到这一点:
javascript
class MyArray extends Array {
  // 将 Species 覆盖到父类 Array 的构造函数
  static get [Symbol.species]() {
    return Array;
  }
}
const a = new MyArray(1, 2, 3);
const mapped = a.map((x) => x * x);
console.log(mapped instanceof MyArray); // false
console.log(mapped instanceof Array); // true
class MyArray extends Array {
  // 将 Species 覆盖到父类 Array 的构造函数
  static get [Symbol.species]() {
    return Array;
  }
}
const a = new MyArray(1, 2, 3);
const mapped = a.map((x) => x * x);
console.log(mapped instanceof MyArray); // false
console.log(mapped instanceof Array); // true
混入
  • 抽象子类或混入是类的模板。一个类只能有一个父类,因此不可能从工具类等多重继承。功能必须由超类提供。
javascript
class Foo {}
class Bar extends calculatorMixin(randomizerMixin(Foo)) {}
class Foo {}
class Bar extends calculatorMixin(randomizerMixin(Foo)) {}

一个以父类为输入,以扩展该父类的子类为输出的函数可以用来实现混入:

javascript
const calculatorMixin = (Base) =>
  class extends Base {
    calc() {}
  };
const randomizerMixin = (Base) =>
  class extends Base {
    randomize() {}
  };
const calculatorMixin = (Base) =>
  class extends Base {
    calc() {}
  };
const randomizerMixin = (Base) =>
  class extends Base {
    randomize() {}
  };
避免继承--组合

问题:继承是一种非常强的耦合关系。它意味着子类默认继承基类的所有行为,但这并不总是你想要的。

javascript
class ReadOnlyMap extends Map {
  set() {
    throw new TypeError("A read-only map must be set at construction time.");
  }
}
const m = new ReadOnlyMap([["a", 1]]);
// TypeError: A read-only map must be set at construction time.
// 因为 Map() 构造函数调用了实例的 set() 方法。阻止了我们的预期
class ReadOnlyMap extends Map {
  set() {
    throw new TypeError("A read-only map must be set at construction time.");
  }
}
const m = new ReadOnlyMap([["a", 1]]);
// TypeError: A read-only map must be set at construction time.
// 因为 Map() 构造函数调用了实例的 set() 方法。阻止了我们的预期

我们可以通过使用一个私有标志来指示是否正在构造实例来解决这个问题。然而,这种设计的一个更重要的问题是,它破坏了里氏替换原则,该原则规定子类应该可以替换其超类。如果函数期望使用一个 Map 对象,那么它也应该能够使用一个 ReadOnlyMap 对象,这在这里就会被打破。

继承常常会导致圆——椭圆问题,因为两种类型虽然有很多共同特征,但都不能完美地包含另一种类型的行为。一般来说,除非有非常充分的理由使用继承,否则最好使用组合。组合是指一个类拥有另一个类对象的引用,但只将该对象用作实现细节。

javascript
class ReadOnlyMap {
  #data;
  constructor(values) {
    this.#data = new Map(values);
  }
  get(key) {
    return this.#data.get(key);
  }
  has(key) {
    return this.#data.has(key);
  }
  get size() {
    return this.#data.size;
  }
  *keys() {
    yield* this.#data.keys();
  }
  *values() {
    yield* this.#data.values();
  }
  *entries() {
    yield* this.#data.entries();
  }
  *[Symbol.iterator]() {
    yield* this.#data[Symbol.iterator]();
  }
}
class ReadOnlyMap {
  #data;
  constructor(values) {
    this.#data = new Map(values);
  }
  get(key) {
    return this.#data.get(key);
  }
  has(key) {
    return this.#data.has(key);
  }
  get size() {
    return this.#data.size;
  }
  *keys() {
    yield* this.#data.keys();
  }
  *values() {
    yield* this.#data.values();
  }
  *entries() {
    yield* this.#data.entries();
  }
  *[Symbol.iterator]() {
    yield* this.#data[Symbol.iterator]();
  }
}

在这种情况下,ReadOnlyMap 类不是 Map 的子类,但它仍然实现了大部分相同的方法。这意味着更多的代码重复,但也意味着 ReadOnlyMap 类与 Map 类不是强耦合的,并且在 Map 类更改时不会轻易中断,从而避免了子类化内置类的语义问题。

类的求值顺序

当一个 class 声明或 class 表达式被求值,它的各个组件将按照以下顺序被求值:

  1. 如果有 extends 语句,将会首先被求值。它必须被求取为一个合法的构造函数或 null,否则将抛出一个 TypeError。
  2. 提取 constructor 方法,如果 constructor 不存在将会用默认实现进行替换。但是,因为 constructor 的定义仅仅只是一个方法的定义,所以这一步是观察不到的。
  3. 按照声明顺序对类元素的属性键名求值。如果属性键名是计算属性名,则对表达式求值,表达式中的 this 指向类声明所处上下文的 this(不是类本身),属性值尚不会被求值。
  4. 按照声明顺序安设方法和访问器。实例方法和访问器被安设在当前类的 prototype 属性上,静态方法和访问器被安设在类本身。私有实例方法和访问器会被保存,之后会直接安置到实例上,这个步骤不可被观察到。
  5. 类现在已经用 extends 指定的原型和 constructor 指定的实现初始化完成。对于上面的所有步骤,如果有表达式尝试访问类名,会抛出一个 ReferenceError,因为类还没有初始化完成。
  6. 按照声明顺序求取类元素的值:

①、对于每个实例字段(公有或私有),其初始化器表达式会被保存。初始化器会在实例创建期间被求值,时间点在构造函数开头(对于基类)或者在调用 super() 返回时立刻求值(对于派生类)。

②、对于每个静态字段(公有的和私有的),其初始化器在被求值时,this 会指向类本身,并且属性会被创建到类上。

③、静态初始化块在被求值时,this 会指向类本身。

  1. 类现在已经被完全初始化并且可以被作为构造函数使用。