15. 类
目录
请支持本书:购买 (PDF, EPUB, MOBI)捐赠
(广告,请不要屏蔽。)

15.



15.1 概述

一个类和一个子类

class Point {
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
    toString() {
        return `(${this.x}, ${this.y})`;
    }
}

class ColorPoint extends Point {
    constructor(x, y, color) {
        super(x, y);
        this.color = color;
    }
    toString() {
        return super.toString() + ' in ' + this.color;
    }
}

使用类

> const cp = new ColorPoint(25, 8, 'green');

> cp.toString();
'(25, 8) in green'

> cp instanceof ColorPoint
true
> cp instanceof Point
true

在底层,ES6 类并不是什么全新的东西:它们主要提供了更方便的语法来创建老式的构造函数。如果您使用 typeof,就可以看到这一点

> typeof Point
'function'

15.2 要点

15.2.1 基类

在 ECMAScript 6 中,类的定义如下

class Point {
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
    toString() {
        return `(${this.x}, ${this.y})`;
    }
}

您可以像使用 ES5 构造函数一样使用此类

> var p = new Point(25, 8);
> p.toString()
'(25, 8)'

实际上,类定义的结果是一个函数

> typeof Point
'function'

但是,您只能通过 new 调用类,而不能通过函数调用调用(其原因稍后解释

> Point()
TypeError: Classes can’t be function-called
15.2.1.1 类定义的成员之间没有分隔符

类定义的成员之间没有分隔标点符号。例如,对象字面量的成员之间用逗号分隔,这在类定义的顶层是非法的。允许使用分号,但会被忽略

class MyClass {
    foo() {}
    ; // OK, ignored
    , // SyntaxError
    bar() {}
}

允许使用分号是为了将来可能包含以分号结尾的成员的语法做准备。禁止使用逗号是为了强调类定义与对象字面量不同。

15.2.1.2 类声明不会被提升

函数声明会被*提升*:进入作用域时,其中声明的函数会立即可用,而与声明发生的位置无关。这意味着您可以调用稍后声明的函数

foo(); // works, because `foo` is hoisted

function foo() {}

相反,类声明不会被提升。因此,类只有在执行到达其定义并对其进行求值后才存在。事先访问它会导致 ReferenceError

new Foo(); // ReferenceError

class Foo {}

这种限制的原因是类可以有一个 extends 子句,其值为任意表达式。该表达式必须在其适当的“位置”进行求值,其求值不能被提升。

没有提升并不像您想象的那么受限。例如,在类声明之前出现的函数仍然可以引用该类,但您必须等到类声明被求值后才能调用该函数。

function functionThatUsesBar() {
    new Bar();
}

functionThatUsesBar(); // ReferenceError
class Bar {}
functionThatUsesBar(); // OK
15.2.1.3 类表达式

与函数类似,有两种*类定义*,即定义类的两种方式:*类声明*和*类表达式*。

与函数表达式类似,类表达式可以是匿名的

const MyClass = class {
    ···
};
const inst = new MyClass();

同样与函数表达式类似,类表达式可以具有仅在内部可见的名称

const MyClass = class Me {
    getClassName() {
        return Me.name;
    }
};
const inst = new MyClass();

console.log(inst.getClassName()); // Me
console.log(Me.name); // ReferenceError: Me is not defined

最后两行表明 Me 在类外部不会成为变量,但可以在类内部使用。

15.2.2 类定义的主体内部

类主体只能包含方法,而不能包含数据属性。原型具有数据属性通常被认为是一种反模式,因此这只是强制执行最佳实践。

15.2.2.1 constructor、静态方法、原型方法

让我们来看看您经常在类定义中发现的三种方法。

class Foo {
    constructor(prop) {
        this.prop = prop;
    }
    static staticMethod() {
        return 'classy';
    }
    prototypeMethod() {
        return 'prototypical';
    }
}
const foo = new Foo(123);

此类声明的对象图如下所示。理解它的提示:[[Prototype]] 是对象之间的继承关系,而 prototype 是一个普通属性,其值是一个对象。属性 prototype 仅在 new 运算符使用其值作为其创建的实例的原型时才特殊。

**首先,伪方法 constructor。** 此方法很特殊,因为它定义了表示类的函数

> Foo === Foo.prototype.constructor
true
> typeof Foo
'function'

它有时被称为 类构造函数。它具有一些普通构造函数所没有的特性(主要是能够通过 super() 构造函数调用其超类构造函数,这将在后面解释)。

**其次,静态方法。** *静态属性*(或*类属性*)是 Foo 本身的属性。如果您在方法定义前加上 static 前缀,则会创建一个类方法

> typeof Foo.staticMethod
'function'
> Foo.staticMethod()
'classy'

**第三,原型方法。** Foo 的*原型属性*是 Foo.prototype 的属性。它们通常是方法,由 Foo 的实例继承。

> typeof Foo.prototype.prototypeMethod
'function'
> foo.prototypeMethod()
'prototypical'
15.2.2.2 静态数据属性

为了及时完成 ES6 类,它们被故意设计为“尽可能最小化”。这就是为什么您目前只能创建静态方法、getter 和 setter,而不能创建静态数据属性。有一个提案建议将它们添加到语言中。在该提案被接受之前,您可以使用两种解决方法。

首先,您可以手动添加静态属性

class Point {
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
}
Point.ZERO = new Point(0, 0);

您可以使用 Object.defineProperty() 创建只读属性,但我喜欢赋值的简单性。

其次,您可以创建一个静态 getter

class Point {
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
    static get ZERO() {
        return new Point(0, 0);
    }
}

在这两种情况下,您都会获得一个可以读取的属性 Point.ZERO。在第一种情况下,每次都返回相同的实例。在第二种情况下,每次都返回一个新实例。

15.2.2.3 Getter 和 setter

getter 和 setter 的语法与ECMAScript 5 对象字面量中的语法相同

class MyClass {
    get prop() {
        return 'getter';
    }
    set prop(value) {
        console.log('setter: '+value);
    }
}

您可以按如下方式使用 MyClass

> const inst = new MyClass();
> inst.prop = 123;
setter: 123
> inst.prop
'getter'
15.2.2.4 计算属性名

如果将方法名放在方括号中,则可以通过表达式定义方法名。例如,以下定义 Foo 的方法是等效的。

class Foo {
    myMethod() {}
}

class Foo {
    ['my'+'Method']() {}
}

const m = 'myMethod';
class Foo {
    [m]() {}
}

ECMAScript 6 中的几种特殊方法的键是符号。计算属性名允许您定义此类方法。例如,如果一个对象有一个键为 Symbol.iterator 的方法,则它是*可迭代的*。这意味着可以使用 for-of 循环和其他语言机制迭代其内容。

class IterableClass {
    [Symbol.iterator]() {
        ···
    }
}
15.2.2.5 生成器方法

如果在方法定义前加上星号 (*),它就会变成一个*生成器方法*。除其他外,生成器可用于定义键为 Symbol.iterator 的方法。以下代码演示了它是如何工作的。

class IterableArguments {
    constructor(...args) {
        this.args = args;
    }
    * [Symbol.iterator]() {
        for (const arg of this.args) {
            yield arg;
        }
    }
}

for (const x of new IterableArguments('hello', 'world')) {
    console.log(x);
}

// Output:
// hello
// world

15.2.3 子类化

extends 子句允许您创建现有构造函数的子类(该构造函数可能已通过类定义,也可能未通过类定义)

class Point {
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
    toString() {
        return `(${this.x}, ${this.y})`;
    }
}

class ColorPoint extends Point {
    constructor(x, y, color) {
        super(x, y); // (A)
        this.color = color;
    }
    toString() {
        return super.toString() + ' in ' + this.color; // (B)
    }
}

同样,此类的使用方式与您预期的一样

> const cp = new ColorPoint(25, 8, 'green');
> cp.toString()
'(25, 8) in green'

> cp instanceof ColorPoint
true
> cp instanceof Point
true

有两种类

有两种使用 super 的方法

15.2.3.1 子类的原型是超类

在 ECMAScript 6 中,子类的原型是超类

> Object.getPrototypeOf(ColorPoint) === Point
true

这意味着静态属性会被继承

class Foo {
    static classMethod() {
        return 'hello';
    }
}

class Bar extends Foo {
}
Bar.classMethod(); // 'hello'

您甚至可以超级调用静态方法

class Foo {
    static classMethod() {
        return 'hello';
    }
}

class Bar extends Foo {
    static classMethod() {
        return super.classMethod() + ', too';
    }
}
Bar.classMethod(); // 'hello, too'
15.2.3.2 超类构造函数调用

在派生类中,您必须先调用 super(),然后才能使用 this

class Foo {}

class Bar extends Foo {
    constructor(num) {
        const tmp = num * 2; // OK
        this.num = num; // ReferenceError
        super();
        this.num = num; // OK
    }
}

隐式地使派生构造函数在不调用 super() 的情况下也会导致错误

class Foo {}

class Bar extends Foo {
    constructor() {
    }
}

const bar = new Bar(); // ReferenceError
15.2.3.3 覆盖构造函数的结果

就像在 ES5 中一样,您可以通过显式返回一个对象来覆盖构造函数的结果

class Foo {
    constructor() {
        return Object.create(null);
    }
}
console.log(new Foo() instanceof Foo); // false

如果这样做,则 this 是否已初始化无关紧要。换句话说:如果您以这种方式覆盖结果,则不必在派生构造函数中调用 super()

15.2.3.4 类的默认构造函数

如果您没有为基类指定 constructor,则使用以下定义

constructor() {}

对于派生类,使用以下默认构造函数

constructor(...args) {
    super(...args);
}
15.2.3.5 对内置构造函数进行子类化

在 ECMAScript 6 中,您最终可以对所有内置构造函数进行子类化(ES5 的解决方法,但这些方法有很大的局限性)。

例如,您现在可以创建自己的异常类(在大多数引擎中,这些类将继承具有堆栈跟踪的特性)

class MyError extends Error {
}
throw new MyError('Something happened!');

您还可以创建 Array 的子类,其实例可以正确处理 length

class Stack extends Array {
    get top() {
        return this[this.length - 1];
    }
}

var stack = new Stack();
stack.push('world');
stack.push('hello');
console.log(stack.top); // hello
console.log(stack.length); // 2

请注意,对 Array 进行子类化通常不是最佳解决方案。通常最好创建自己的类(您可以控制其接口),并将委托给私有属性中的数组。

15.3 类的私有数据

本节介绍四种为 ES6 类管理私有数据的方法

  1. 将私有数据保存在类 constructor 的环境中
  2. 通过命名约定(例如,带前缀的下划线)标记私有属性
  3. 将私有数据保存在 WeakMap 中
  4. 使用符号作为私有属性的键

方法 #1 和 #2 在 ES5 中已经很常见,用于构造函数。方法 #3 和 #4 是 ES6 中的新增方法。让我们通过每种方法四次实现相同的示例。

15.3.1 通过构造函数环境实现私有数据

我们正在运行的示例是一个名为 Countdown 的类,它在计数器(其初始值为 counter)达到零时调用回调函数 action。这两个参数 actioncounter 应存储为私有数据。

在第一个实现中,我们将 actioncounter 存储在类构造函数的*环境*中。环境是内部数据结构,JavaScript 引擎在其中存储每次进入新作用域(例如,通过函数调用或构造函数调用)时出现的参数和局部变量。代码如下:

class Countdown {
    constructor(counter, action) {
        Object.assign(this, {
            dec() {
                if (counter < 1) return;
                counter--;
                if (counter === 0) {
                    action();
                }
            }
        });
    }
}

使用 Countdown 的方式如下:

> const c = new Countdown(2, () => console.log('DONE'));
> c.dec();
> c.dec();
DONE

优点

缺点

有关此技术的更多信息:请参阅“Speaking JavaScript”中的“构造函数环境中的私有数据(Crockford 隐私模式)”一节。

15.3.2 通过命名约定实现私有数据

以下代码将私有数据保存在名称通过带前缀的下划线标记的属性中

class Countdown {
    constructor(counter, action) {
        this._counter = counter;
        this._action = action;
    }
    dec() {
        if (this._counter < 1) return;
        this._counter--;
        if (this._counter === 0) {
            this._action();
        }
    }
}

优点

缺点

15.3.3 通过 WeakMap 实现私有数据

有一种使用 WeakMap 的巧妙技术,它结合了第一种方法(安全性)和第二种方法(能够使用原型方法)的优点。以下代码演示了此技术:我们使用 WeakMap _counter_action 来存储私有数据。

const _counter = new WeakMap();
const _action = new WeakMap();
class Countdown {
    constructor(counter, action) {
        _counter.set(this, counter);
        _action.set(this, action);
    }
    dec() {
        let counter = _counter.get(this);
        if (counter < 1) return;
        counter--;
        _counter.set(this, counter);
        if (counter === 0) {
            _action.get(this)();
        }
    }
}

两个 WeakMap _counter_action 中的每一个都将对象映射到其私有数据。由于 WeakMap 的工作方式,这不会阻止对象被垃圾回收。只要您对外部世界隐藏 WeakMap,私有数据就是安全的。

如果您想更加安全,可以将 WeakMap.prototype.getWeakMap.prototype.set 存储在变量中并调用它们(而不是动态调用方法)

const set = WeakMap.prototype.set;
···
set.call(_counter, this, counter);
    // _counter.set(this, counter);

然后,如果恶意代码用窥探我们私有数据的方法替换这些方法,您的代码将不会受到影响。但是,您只能防止在您的代码之后运行的代码。如果它在您的代码之前运行,您将无能为力。

优点

缺点

15.3.4 通过符号实现私有数据

私有数据的另一个存储位置是键为符号的属性

const _counter = Symbol('counter');
const _action = Symbol('action');

class Countdown {
    constructor(counter, action) {
        this[_counter] = counter;
        this[_action] = action;
    }
    dec() {
        if (this[_counter] < 1) return;
        this[_counter]--;
        if (this[_counter] === 0) {
            this[_action]();
        }
    }
}

每个符号都是唯一的,这就是为什么符号值属性键永远不会与任何其他属性键冲突的原因。此外,符号在某种程度上对外部世界是隐藏的,但并非完全隐藏

const c = new Countdown(2, () => console.log('DONE'));

console.log(Object.keys(c));
    // []
console.log(Reflect.ownKeys(c));
    // [ Symbol(counter), Symbol(action) ]

优点

缺点

15.3.5 扩展阅读

15.4 简单的混入

在 JavaScript 中使用子类有两个原因

类对于实现继承的用处有限,因为它们只支持单继承(一个类最多只能有一个超类)。因此,不可能从多个来源继承工具方法——它们必须全部来自超类。

那么我们如何解决这个问题呢?让我们通过一个例子来探讨解决方案。考虑一个企业的管理系统,其中 EmployeePerson 的子类。

class Person { ··· }
class Employee extends Person { ··· }

此外,还有用于存储和数据验证的工具类

class Storage {
    save(database) { ··· }
}
class Validation {
    validate(schema) { ··· }
}

如果我们可以像这样包含工具类就好了

// Invented ES6 syntax:
class Employee extends Storage, Validation, Person { ··· }

也就是说,我们希望 EmployeeStorage 的子类,Storage 应该是 Validation 的子类,而 Validation 应该是 Person 的子类。EmployeePerson 只会在这样一个类链中使用。但是 StorageValidation 将被多次使用。我们希望它们成为我们填充其超类的类的模板。此类模板称为*抽象子类*或*混入*。

在 ES6 中实现混入的一种方法是将其视为一个函数,其输入是一个超类,输出是一个扩展该超类的子类

const Storage = Sup => class extends Sup {
    save(database) { ··· }
};
const Validation = Sup => class extends Sup {
    validate(schema) { ··· }
};

在这里,我们受益于 extends 子句的操作数不是固定的标识符,而是一个任意表达式。使用这些混入,Employee 的创建方式如下

class Employee extends Storage(Validation(Person)) { ··· }

**致谢。** 我知道的这种技术的第一次出现是 Sebastian Markbåge 的 Gist

15.5 类的细节

到目前为止,我们所看到的是类的基本要素。只有当您对事物如何在幕后发生感兴趣时,才需要继续阅读。让我们从类的语法开始。以下是 ECMAScript 6 规范的 A.4 节 中显示的语法的略微修改版本。

ClassDeclaration:
    "class" BindingIdentifier ClassTail
ClassExpression:
    "class" BindingIdentifier? ClassTail

ClassTail:
    ClassHeritage? "{" ClassBody? "}"
ClassHeritage:
    "extends" AssignmentExpression
ClassBody:
    ClassElement+
ClassElement:
    MethodDefinition
    "static" MethodDefinition
    ";"

MethodDefinition:
    PropName "(" FormalParams ")" "{" FuncBody "}"
    "*" PropName "(" FormalParams ")" "{" GeneratorBody "}"
    "get" PropName "(" ")" "{" FuncBody "}"
    "set" PropName "(" PropSetParams ")" "{" FuncBody "}"

PropertyName:
    LiteralPropertyName
    ComputedPropertyName
LiteralPropertyName:
    IdentifierName  /* foo */
    StringLiteral   /* "foo" */
    NumericLiteral  /* 123.45, 0xFF */
ComputedPropertyName:
    "[" Expression "]"

两点观察

15.5.1 各种检查

15.5.2 属性的特性

类声明创建(可变的)let 绑定。下表描述了与给定类 Foo 相关的属性的特性

  可写 可枚举 可配置
静态属性 Foo.* true false true
Foo.prototype false false false
Foo.prototype.constructor false false true
原型属性 Foo.prototype.* true false true

备注

15.5.3 类具有内部名称

类具有词法内部名称,就像命名函数表达式一样。

15.5.3.1 命名函数表达式的内部名称

您可能知道命名函数表达式具有词法内部名称

const fac = function me(n) {
    if (n > 0) {
        // Use inner name `me` to
        // refer to function
        return n * me(n-1);
    } else {
        return 1;
    }
};
console.log(fac(3)); // 6

命名函数表达式的名称 me 变成了一个词法绑定变量,不受当前哪个变量持有该函数的影响。

15.5.3.2 类的内部名称

有趣的是,ES6 类也具有词法内部名称,您可以在方法(构造函数方法和常规方法)中使用这些名称

class C {
    constructor() {
        // Use inner name C to refer to class
        console.log(`constructor: ${C.prop}`);
    }
    logProp() {
        // Use inner name C to refer to class
        console.log(`logProp: ${C.prop}`);
    }
}
C.prop = 'Hi!';

const D = C;
C = null;

// C is not a class, anymore:
new C().logProp();
    // TypeError: C is not a function

// But inside the class, the identifier C
// still works
new D().logProp();
    // constructor: Hi!
    // logProp: Hi!

(在 ES6 规范中,内部名称由 ClassDefinitionEvaluation 的动态语义 设置。)

**致谢:** 感谢 Michael Ficarra 指出类具有内部名称。

15.6 子类的细节

在 ECMAScript 6 中,子类如下所示。

class Person {
    constructor(name) {
        this.name = name;
    }
    toString() {
        return `Person named ${this.name}`;
    }
    static logNames(persons) {
        for (const person of persons) {
            console.log(person.name);
        }
    }
}

class Employee extends Person {
    constructor(name, title) {
        super(name);
        this.title = title;
    }
    toString() {
        return `${super.toString()} (${this.title})`;
    }
}

const jane = new Employee('Jane', 'CTO');
console.log(jane.toString()); // Person named Jane (CTO)

下一节将检查由上一个示例创建的对象的结构。之后的一节将检查如何分配和初始化 jane

15.6.1 原型链

上一个示例创建了以下对象。

*原型链*是通过 [[Prototype]] 关系(这是一种继承关系)链接的对象。在图中,您可以看到两条原型链

15.6.1.1 左栏:类(函数)

派生类的原型是它扩展的类。这种设置的原因是您希望子类继承其超类的所有属性

> Employee.logNames === Person.logNames
true

基类的原型是 Function.prototype,它也是函数的原型

> const getProto = Object.getPrototypeOf.bind(Object);

> getProto(Person) === Function.prototype
true
> getProto(function () {}) === Function.prototype
true

这意味着基类及其所有派生类(它们的原型)都是函数。传统的 ES5 函数本质上是基类。

15.6.1.2 右栏:实例的原型链

类的主要目的是建立这条原型链。原型链以 Object.prototype 结束(其原型是 null)。这使得 Object 成为每个基类的隐式超类(就实例和 instanceof 运算符而言)。

这种设置的原因是您希望子类的实例原型继承超类实例原型的所有属性。

顺便说一句,通过对象字面量创建的对象也具有原型 Object.prototype

> Object.getPrototypeOf({}) === Object.prototype
true

15.6.2 分配和初始化实例

类构造函数之间的数据流不同于 ES5 中规范的子类化方式。在幕后,它大致如下所示。

// Base class: this is where the instance is allocated
function Person(name) {
    // Performed before entering this constructor:
    this = Object.create(new.target.prototype);

    this.name = name;
}
···

function Employee(name, title) {
    // Performed before entering this constructor:
    this = uninitialized;

    this = Reflect.construct(Person, [name], new.target); // (A)
        // super(name);

    this.title = title;
}
Object.setPrototypeOf(Employee, Person);
···

const jane = Reflect.construct( // (B)
             Employee, ['Jane', 'CTO'],
             Employee);
    // const jane = new Employee('Jane', 'CTO')

实例对象在 ES6 和 ES5 中的不同位置创建

前面的代码使用了两个新的 ES6 特性

这种子类化方式的优点是,它使普通代码能够对内置构造函数(例如 ErrorArray)进行子类化。后面的章节将解释为什么需要不同的方法。

提醒一下,以下是您在 ES5 中进行子类化的方式

function Person(name) {
    this.name = name;
}
···

function Employee(name, title) {
    Person.call(this, name);
    this.title = title;
}
Employee.prototype = Object.create(Person.prototype);
Employee.prototype.constructor = Employee;
···
15.6.2.1 安全检查
15.6.2.2 extends 子句

让我们研究一下 extends 子句如何影响类的设置(规范的第 14.5.14 节)。

extends 子句的值必须是“可构造的”(可通过 new 调用)。不过,允许使用 null

class C {
}
class C extends B {
}
class C extends Object {
}

请注意与第一种情况的以下细微差别:如果没有 extends 子句,则该类是基类并分配实例。如果一个类扩展了 Object,则它是一个派生类,Object 分配实例。生成的实例(包括它们的原型链)是相同的,但您获得它们的方式不同。

class C extends null {
}

这样的类可以让您避免在原型链中使用 Object.prototype

15.6.3 为什么不能在 ES5 中对内置构造函数进行子类化?

在 ECMAScript 5 中,大多数内置构造函数都不能进行子类化(存在几种解决方法)。

为了理解原因,让我们使用规范的 ES5 模式对 Array 进行子类化。我们很快就会发现,这不起作用。

function MyArray(len) {
    Array.call(this, len); // (A)
}
MyArray.prototype = Object.create(Array.prototype);

不幸的是,如果我们实例化 MyArray,我们会发现它无法正常工作:实例属性 length 不会随着我们添加数组元素而改变

> var myArr = new MyArray(0);
> myArr.length
0
> myArr[0] = 'foo';
> myArr.length
0

有两个障碍阻止 myArr 成为一个正确的数组。

**第一个障碍:初始化。** 您传递给构造函数 Arraythis(在 A 行)被完全忽略。这意味着您不能使用 Array 来设置为 MyArray 创建的实例。

> var a = [];
> var b = Array.call(a, 3);
> a !== b  // a is ignored, b is a new object
true
> b.length // set up correctly
3
> a.length // unchanged
0

**第二个障碍:分配。** Array 创建的实例对象是*奇异的*(ECMAScript 规范中用于描述具有普通对象不具备的功能的对象的术语):它们的属性 length 跟踪并影响数组元素的管理。通常,可以从头开始创建奇异对象,但不能将现有的普通对象转换为奇异对象。不幸的是,这就是 Array 在 A 行中调用时必须做的事情:它必须将为 MyArray 创建的普通对象转换为奇异数组对象。

15.6.3.1 解决方案:ES6 子类化

在 ECMAScript 6 中,对 Array 进行子类化如下所示

class MyArray extends Array {
    constructor(len) {
        super(len);
    }
}

这有效

> const myArr = new MyArray(0);
> myArr.length
0
> myArr[0] = 'foo';
> myArr.length
1

让我们研究一下 ES6 的子类化方法是如何消除前面提到的障碍的

15.6.4 在方法中引用超属性

以下 ES6 代码在 B 行进行超方法调用。

class Person {
    constructor(name) {
        this.name = name;
    }
    toString() { // (A)
        return `Person named ${this.name}`;
    }
}

class Employee extends Person {
    constructor(name, title) {
        super(name);
        this.title = title;
    }
    toString() {
        return `${super.toString()} (${this.title})`; // (B)
    }
}

const jane = new Employee('Jane', 'CTO');
console.log(jane.toString()); // Person named Jane (CTO)

为了理解超调用的工作原理,让我们看一下 jane 的对象图

在 B 行中,Employee.prototype.toString 对其重写的方法(从 A 行开始)进行超调用(B 行)。让我们将存储方法的对象称为该方法的*宿主对象*。例如,Employee.prototypeEmployee.prototype.toString() 的宿主对象。

B 行中的超调用涉及三个步骤

  1. 从当前方法的宿主对象的原型开始搜索。
  2. 查找名称为 toString 的方法。该方法可以在搜索开始的对象中找到,也可以在原型链的后面找到。
  3. 使用当前的 this 调用该方法。这样做的原因是:被超调用的方法必须能够访问相同的实例属性(在我们的示例中,是 jane 的自有属性)。

请注意,即使您只是在获取 (super.prop) 或设置 (super.prop = 123) 超属性(而不是进行方法调用),this 仍然可能(在内部)在步骤 #3 中发挥作用,因为可能会调用 getter 或 setter。

让我们以三种不同但等效的方式来表达这些步骤

// Variation 1: supermethod calls in ES5
var result = Person.prototype.toString.call(this) // steps 1,2,3

// Variation 2: ES5, refactored
var superObject = Person.prototype; // step 1
var superMethod = superObject.toString; // step 2
var result = superMethod.call(this) // step 3

// Variation 3: ES6
var homeObject = Employee.prototype;
var superObject = Object.getPrototypeOf(homeObject); // step 1
var superMethod = superObject.toString; // step 2
var result = superMethod.call(this) // step 3

变体 3 是 ECMAScript 6 处理超调用的方式。这种方法得到了函数的*环境*具有的两个内部*绑定*的支持(*环境*为作用域中的变量提供存储空间,即所谓的*绑定*)

15.6.4.1 在哪里可以使用 super

每当涉及到原型链时,引用超属性都很方便,这就是为什么您可以在对象字面量和类定义内部的方法定义(包括生成器方法定义、getter 和 setter)中使用它的原因。该类可以是派生的,也可以不是派生的,该方法可以是静态的,也可以不是静态的。

在函数声明、函数表达式和生成器函数中,不允许使用 super 来引用属性。

15.6.4.2 陷阱:使用 super 的方法不能移动

您不能移动使用 super 的方法:此类方法具有内部插槽 [[HomeObject]],将其绑定到创建它的对象。如果通过赋值移动它,它将继续引用原始对象的超属性。在未来的 ECMAScript 版本中,可能也会有办法转移此类方法。

15.7 物种模式

ECMAScript 6 中内置构造函数的另一个机制已经可以扩展:有时方法会创建其类的新实例。如果您创建一个子类,该方法应该返回其类的实例还是子类的实例?一些内置的 ES6 方法允许您通过所谓的*物种模式*来配置它们如何创建实例。

例如,考虑 Array 的子类 SortedArray。如果我们在该类的实例上调用 map(),我们希望它返回 Array 的实例,以避免不必要的排序。默认情况下,map() 返回接收器 (this) 的实例,但物种模式允许您更改这一点。

15.7.1 示例的辅助方法

在接下来的三节中,我将在示例中使用两个辅助函数

function isObject(value) {
    return (value !== null
       && (typeof value === 'object'
           || typeof value === 'function'));
}

/**
 * Spec-internal operation that determines whether `x`
 * can be used as a constructor.
 */
function isConstructor(x) {
    ···
}

15.7.2 标准物种模式

标准物种模式由 Promise.prototype.then()、类型化数组的 filter() 方法和其他操作使用。它的工作原理如下

在 JavaScript 中实现,该模式如下所示

function SpeciesConstructor(O, defaultConstructor) {
    const C = O.constructor;
    if (C === undefined) {
        return defaultConstructor;
    }
    if (! isObject(C)) {
        throw new TypeError();
    }
    const S = C[Symbol.species];
    if (S === undefined || S === null) {
        return defaultConstructor;
    }
    if (! isConstructor(S)) {
        throw new TypeError();
    }
    return S;
}

15.7.3 数组的物种模式

普通数组以略微不同的方式实现物种模式

function ArraySpeciesCreate(self, length) {
    let C = undefined;
    // If the receiver `self` is an Array,
    // we use the species pattern
    if (Array.isArray(self)) {
        C = self.constructor;
        if (isObject(C)) {
            C = C[Symbol.species];
        }
    }
    // Either `self` is not an Array or the species
    // pattern didn’t work out:
    // create and return an Array
    if (C === undefined || C === null) {
        return new Array(length);
    }
    if (! IsConstructor(C)) {
        throw new TypeError();
    }
    return new C(length);
}

Array.prototype.map() 通过 ArraySpeciesCreate(this, this.length) 创建它返回的数组。

15.7.4 静态方法中的物种模式

Promise 对静态方法(例如 Promise.all())使用物种模式的变体

let C = this; // default
if (! isObject(C)) {
    throw new TypeError();
}
// The default can be overridden via the property `C[Symbol.species]`
const S = C[Symbol.species];
if (S !== undefined && S !== null) {
    C = S;
}
if (!IsConstructor(C)) {
    throw new TypeError();
}
const instance = new C(···);

15.7.5 在子类中覆盖默认物种

这是属性 [Symbol.species] 的默认 getter

static get [Symbol.species]() {
    return this;
}

此默认 getter 由内置类 ArrayArrayBufferMapPromiseRegExpSet%TypedArray% 实现。它由这些内置类的子类自动继承。

您可以通过两种方式覆盖默认物种:使用您选择的构造函数或使用 null

15.7.5.1 将物种设置为选择的构造函数

您可以通过静态 getter(A 行)覆盖默认物种

class MyArray1 extends Array {
    static get [Symbol.species]() { // (A)
        return Array;
    }
}

结果,map() 返回 Array 的实例

const result1 = new MyArray1().map(x => x);
console.log(result1 instanceof Array); // true

如果您没有覆盖默认物种,map() 将返回子类的实例

class MyArray2 extends Array { }

const result2 = new MyArray2().map(x => x);
console.log(result2 instanceof MyArray2); // true
15.7.5.1.1 通过数据属性指定物种

如果您不想使用静态 getter,则需要使用 Object.defineProperty()。您不能使用赋值,因为已经有一个具有该键的属性,该属性只有一个 getter。这意味着它是只读的,不能被赋值。

例如,这里我们将 MyArray1 的种类设置为 Array

Object.defineProperty(
    MyArray1, Symbol.species, {
        value: Array
    });
15.7.5.2 将种类设置为 null

如果将种类设置为 null,则使用默认构造函数(使用哪个构造函数取决于使用的是哪个种类的模式变体,有关详细信息,请参阅前面的章节)。

class MyArray3 extends Array {
    static get [Symbol.species]() {
        return null;
    }
}

const result3 = new MyArray3().map(x => x);
console.log(result3 instanceof Array); // true

15.8 类的优缺点

类在 JavaScript 社区中存在争议:一方面,来自基于类的语言的人们很高兴他们不再需要处理 JavaScript 非常规的继承机制。另一方面,许多 JavaScript 程序员认为,JavaScript 复杂的地方不在于原型继承,而在于构造函数。

ES6 类提供了一些明显的优势

让我们来看看关于 ES6 类的一些常见抱怨。你会发现我同意其中大部分观点,但我认为类的优点远远超过其缺点。我很高兴它们出现在 ES6 中,我建议使用它们。

15.8.1 抱怨:ES6 类掩盖了 JavaScript 继承的本质

是的,ES6 类确实掩盖了 JavaScript 继承的本质。类的外观(语法)与其行为方式(语义)之间存在不幸的脱节:它看起来像一个对象,但它是一个函数。我更希望类是*构造函数对象*,而不是构造函数。我在 Proto.js 项目 中通过一个小型库探索了这种方法(这证明了这种方法的适用性)。

但是,向后兼容性很重要,这就是为什么类作为构造函数也有意义的原因。这样,ES6 代码和 ES5 代码更具互操作性。

语法和语义之间的脱节会在 ES6 及更高版本中造成一些摩擦。但是,您可以通过简单地从表面上理解 ES6 类来过上舒适的生活。我认为这种错觉永远不会困扰你。新手可以更快地入门,并在以后(在他们对该语言更加熟悉之后)阅读幕后发生的事情。

15.8.2 抱怨:类只提供单继承

类只提供单继承,这严重限制了您在面向对象设计方面的表达自由。但是,计划一直是让它们成为多重继承机制(例如特征)的基础。

然后,类成为一个可实例化的实体和一个组装特征的位置。在此之前,如果您想要多重继承,则需要求助于库。

15.8.3 抱怨:由于强制使用 new,类将您锁定

如果要实例化一个类,在 ES6 中必须使用 new。这意味着您无法在不更改调用站点的情况下从类切换到工厂函数。这确实是一个限制,但有两个缓解因素

因此,类在语法上*确实*限制了您,但是,一旦 JavaScript 具有特征,它们就不会在*概念上*(关于面向对象设计)限制您。

15.9 常见问题解答:类

15.9.1 为什么不能函数调用类?

目前禁止函数调用类。这样做是为了为将来保留选项,以便最终添加一种通过类处理函数调用的方法。

15.9.2 给定一个参数数组,如何实例化一个类?

类的 Function.prototype.apply() 的类似物是什么?也就是说,如果我有一个类 TheClass 和一个参数数组 args,我该如何实例化 TheClass

一种方法是通过扩展运算符 (...)

function instantiate(TheClass, args) {
    return new TheClass(...args);
}

另一种选择是使用 Reflect.construct()

function instantiate(TheClass, args) {
    return Reflect.construct(TheClass, args);
}

15.10 类的下一步是什么?

类的设计格言是“最大限度地最小化”。讨论了几个高级功能,但最终为了获得 TC39 一致接受的设计而放弃了这些功能。

ECMAScript 的未来版本现在可以扩展这种最小化设计——类将为特征(或混合)、值对象(如果具有相同内容,则不同对象相等)和常量类(产生不可变实例)等功能奠定基础。

15.11 延伸阅读

以下文档是本章的重要来源

下一页:16. 模块