JavaScript 权威指南第七版(GPT 重译)(四一)

223 阅读33分钟

第九章:类

JavaScript 对象在第六章中有所涉及。该章将每个对象视为一组独特的属性,与其他对象不同。然而,通常有必要定义一种共享某些属性的对象。类的成员或实例具有自己的属性来保存或定义它们的状态,但它们还具有定义其行为的方法。这些方法由类定义,并由所有实例共享。例如,想象一个名为 Complex 的类,表示并对复数执行算术运算。Complex 实例将具有保存复数的实部和虚部(状态)的属性。Complex 类将定义执行这些数字的加法和乘法(行为)的方法。

在 JavaScript 中,类使用基于原型的继承:如果两个对象从同一原型继承属性(通常是函数值属性或方法),那么我们说这些对象是同一类的实例。简而言之,这就是 JavaScript 类的工作原理。JavaScript 原型和继承在§6.2.3 和§6.3.2 中有所涉及,您需要熟悉这些部分的内容才能理解本章。本章在§9.1 中涵盖了原型。

如果两个对象从同一原型继承,这通常(但不一定)意味着它们是由同一构造函数或工厂函数创建和初始化的。构造函数在§4.6、§6.2.2 和§8.2.3 中有所涉及,本章在§9.2 中有更多内容。

JavaScript 一直允许定义类。ES6 引入了全新的语法(包括class关键字),使得创建类变得更加容易。这些新的 JavaScript 类与旧式类的工作方式相同,本章首先解释了创建类的旧方法,因为这更清楚地展示了在幕后使类起作用的原理。一旦我们解释了这些基础知识,我们将转而开始使用新的简化类定义语法。

如果您熟悉像 Java 或 C++这样的强类型面向对象编程语言,您会注意到 JavaScript 类与这些语言中的类有很大不同。虽然有一些语法上的相似之处,并且您可以在 JavaScript 中模拟许多“经典”类的特性,但最好事先了解 JavaScript 的类和基于原型的继承机制与 Java 和类似语言的类和基于类的继承机制有很大不同。

9.1 类和原型

在 JavaScript 中,类是一组从同一原型对象继承属性的对象。因此,原型对象是类的核心特征。第六章介绍了Object.create()函数,该函数返回一个从指定类型对象继承的新创建对象。如果我们定义一个原型对象,然后使用Object.create()创建从中继承的对象,我们就定义了一个 JavaScript 类。通常,类的实例需要进一步初始化,通常定义一个函数来创建和初始化新对象。示例 9-1 演示了这一点:它定义了一个代表值范围的类的原型对象,并定义了一个工厂函数,用于创建和初始化类的新实例。

示例 9-1 一个简单的 JavaScript 类
// This is a factory function that returns a new range object.
function range(from, to) {
    // Use Object.create() to create an object that inherits from the
    // prototype object defined below.  The prototype object is stored as
    // a property of this function, and defines the shared methods (behavior)
    // for all range objects.
    let r = Object.create(range.methods);

    // Store the start and end points (state) of this new range object.
    // These are noninherited properties that are unique to this object.
    r.from = from;
    r.to = to;

    // Finally return the new object
    return r;
}

// This prototype object defines methods inherited by all range objects.
range.methods = {
    // Return true if x is in the range, false otherwise
    // This method works for textual and Date ranges as well as numeric.
    includes(x) { return this.from <= x && x <= this.to; },

    // A generator function that makes instances of the class iterable.
    // Note that it only works for numeric ranges.
    *[Symbol.iterator]() {
        for(let x = Math.ceil(this.from); x <= this.to; x++) yield x;
    },

    // Return a string representation of the range
    toString() { return "(" + this.from + "..." + this.to + ")"; }
};

// Here are example uses of a range object.
let r = range(1,3);      // Create a range object
r.includes(2)            // => true: 2 is in the range
r.toString()             // => "(1...3)"
[...r]                   // => [1, 2, 3]; convert to an array via iterator

在示例 9-1 的代码中有一些值得注意的事项:

  • 此代码定义了一个用于创建新 Range 对象的工厂函数range()

  • 它使用了range()函数的methods属性作为一个方便的存储原型对象的地方,该原型对象定义了类。将原型对象放在这里并没有什么特殊或成语化的地方。

  • range()函数在每个 Range 对象上定义了fromto属性。这些是定义每个独立 Range 对象的唯一状态的非共享、非继承属性。

  • range.methods对象使用了 ES6 的简写语法来定义方法,这就是为什么你在任何地方都看不到function关键字的原因。(查看§6.10.5 来回顾对象字面量简写方法语法。)

  • 原型中的一个方法具有计算名称(§6.10.2),即Symbol.iterator,这意味着它正在为 Range 对象定义一个迭代器。这个方法的名称前缀为*,表示它是一个生成器函数而不是常规函数。迭代器和生成器在第十二章中有详细介绍。目前,要点是这个 Range 类的实例可以与for/of循环和...扩展运算符一起使用。

  • range.methods中定义的共享的继承方法都使用了在range()工厂函数中初始化的fromto属性。为了引用它们,它们使用this关键字来引用通过其调用的对象。这种对this的使用是任何类的方法的基本特征。

9.2 类和构造函数

示例 9-1 演示了定义 JavaScript 类的一种简单方法。然而,这并不是惯用的做法,因为它没有定义构造函数。构造函数是为新创建的对象初始化而设计的函数。构造函数使用new关键字调用,如§8.2.3 所述。使用new调用构造函数会自动创建新对象,因此构造函数本身只需要初始化该新对象的状态。构造函数调用的关键特征是构造函数的prototype属性被用作新对象的原型。§6.2.3 介绍了原型并强调,几乎所有对象都有一个原型,但只有少数对象有一个prototype属性。最后,我们可以澄清这一点:函数对象具有prototype属性。这意味着使用相同构造函数创建的所有对象都继承自同一个对象,因此它们是同一类的成员。示例 9-2 展示了如何修改示例 9-1 的 Range 类以使用构造函数而不是工厂函数。示例 9-2 展示了在不支持 ES6 class关键字的 JavaScript 版本中创建类的惯用方法。即使现在class得到了很好的支持,仍然有很多旧的 JavaScript 代码定义类的方式就像这样,你应该熟悉这种习惯用法,这样你就可以阅读旧代码,并且当你使用class关键字时,你能理解发生了什么“底层”操作。

示例 9-2。使用构造函数的 Range 类
// This is a constructor function that initializes new Range objects.
// Note that it does not create or return the object. It just initializes this.
function Range(from, to) {
    // Store the start and end points (state) of this new range object.
    // These are noninherited properties that are unique to this object.
    this.from = from;
    this.to = to;
}

// All Range objects inherit from this object.
// Note that the property name must be "prototype" for this to work.
Range.prototype = {
    // Return true if x is in the range, false otherwise
    // This method works for textual and Date ranges as well as numeric.
    includes: function(x) { return this.from <= x && x <= this.to; },

    // A generator function that makes instances of the class iterable.
    // Note that it only works for numeric ranges.
    [Symbol.iterator]: function*() {
        for(let x = Math.ceil(this.from); x <= this.to; x++) yield x;
    },

    // Return a string representation of the range
    toString: function() { return "(" + this.from + "..." + this.to + ")"; }
};

// Here are example uses of this new Range class
let r = new Range(1,3);   // Create a Range object; note the use of new
r.includes(2)             // => true: 2 is in the range
r.toString()              // => "(1...3)"
[...r]                    // => [1, 2, 3]; convert to an array via iterator

值得仔细比较示例 9-1 和 9-2,并注意这两种定义类的技术之间的区别。首先,注意到我们将range()工厂函数重命名为Range()当我们将其转换为构造函数时。这是一个非常常见的编码约定:构造函数在某种意义上定义了类,而类的名称(按照约定)以大写字母开头。常规函数和方法的名称以小写字母开头。

接下来,请注意在示例末尾使用new关键字调用Range()构造函数,而range()工厂函数在没有使用new的情况下调用。示例 9-1 使用常规函数调用(§8.2.1)创建新对象,而示例 9-2 使用构造函数调用(§8.2.3)。因为使用new调用Range()构造函数,所以不需要调用Object.create()或采取任何操作来创建新对象。新对象在构造函数调用之前自动创建,并且可以作为this值访问。Range()构造函数只需初始化this。构造函数甚至不必返回新创建的对象。构造函数调用会自动创建一个新对象,将构造函数作为该对象的方法调用,并返回新对象。构造函数调用与常规函数调用如此不同的事实是我们给构造函数名称以大写字母开头的另一个原因。构造函数被编写为以构造函数方式调用,并且如果以常规函数方式调用,它们通常不会正常工作。将构造函数函数与常规函数区分开的命名约定有助于程序员知道何时使用new

示例 9-1 和 9-2 之间的另一个关键区别是原型对象的命名方式。在第一个示例中,原型是range.methods。这是一个方便且描述性强的名称,但是任意的。在第二个示例中,原型是Range.prototype,这个名称是强制的。对Range()构造函数的调用会自动使用Range.prototype作为新 Range 对象的原型。

最后,还要注意示例 9-1 和 9-2 之间没有变化的地方:两个类的范围方法的定义和调用方式是相同的。因为示例 9-2 演示了在 ES6 之前 JavaScript 版本中创建类的惯用方式,它没有在原型对象中使用 ES6 的简写方法语法,并且明确用function关键字拼写出方法。但你可以看到两个示例中方法的实现是相同的。

重要的是,要注意两个范围示例在定义构造函数或方法时都没有使用箭头函数。回想一下§8.1.3 中提到的,以这种方式定义的函数没有prototype属性,因此不能用作构造函数。此外,箭头函数从定义它们的上下文中继承this关键字,而不是根据调用它们的对象设置它,这使它们对于方法是无用的,因为方法的定义特征是它们使用this来引用被调用的实例。

幸运的是,新的 ES6 类语法不允许使用箭头函数定义方法,因此在使用该语法时不会出现这种错误。我们很快将介绍 ES6 的class关键字,但首先,还有更多关于构造函数的细节需要讨论。

9.2.1 构造函数、类标识和 instanceof

正如我们所见,原型对象对于类的标识是至关重要的:两个对象只有在它们继承自相同的原型对象时才是同一类的实例。初始化新对象状态的构造函数并不是基本的:两个构造函数可能具有指向相同原型对象的prototype属性。然后,这两个构造函数都可以用于创建同一类的实例。

尽管构造函数不像原型那样基础,但构造函数作为类的公共面孔。最明显的是,构造函数的名称通常被采用为类的名称。例如,我们说 Range() 构造函数创建 Range 对象。然而,更根本的是,构造函数在测试对象是否属于类时作为 instanceof 运算符的右操作数。如果我们有一个对象 r 并想知道它是否是 Range 对象,我们可以写:

r instanceof Range   // => true: r inherits from Range.prototype

instanceof 运算符在 §4.9.4 中有描述。左操作数应该是正在测试的对象,右操作数应该是命名类的构造函数。表达式 o instanceof Co 继承自 C.prototype 时求值为 true。继承不必是直接的:如果 o 继承自继承自继承自 C.prototype 的对象,表达式仍将求值为 true

从技术上讲,在前面的代码示例中,instanceof 运算符并不是在检查 r 是否实际由 Range 构造函数初始化。相反,它是在检查 r 是否继承自 Range.prototype。如果我们定义一个函数 Strange() 并将其原型设置为与 Range.prototype 相同,那么使用 new Strange() 创建的对象在 instanceof 方面将被视为 Range 对象(但实际上它们不会像 Range 对象一样工作,因为它们的 fromto 属性尚未初始化):

function Strange() {}
Strange.prototype = Range.prototype;
new Strange() instanceof Range   // => true

即使 instanceof 无法实际验证构造函数的使用,但它仍将构造函数作为其右操作数,因为构造函数是类的公共标识。

如果您想要测试对象的原型链以查找特定原型而不想使用构造函数作为中介,可以使用 isPrototypeOf() 方法。例如,在 示例 9-1 中,我们定义了一个没有构造函数的类,因此无法使用该类的 instanceof。然而,我们可以使用以下代码测试对象 r 是否是该无构造函数类的成员:

range.methods.isPrototypeOf(r);  // range.methods is the prototype object.

9.2.2 构造函数属性

在 示例 9-2 中,我们将 Range.prototype 设置为一个包含我们类方法的新对象。虽然将这些方法表达为单个对象字面量的属性很方便,但实际上并不需要创建一个新对象。任何常规的 JavaScript 函数(不包括箭头函数、生成器函数和异步函数)都可以用作构造函数,并且构造函数调用需要一个 prototype 属性。因此,每个常规的 JavaScript 函数¹ 自动具有一个 prototype 属性。该属性的值是一个具有单个、不可枚举的 constructor 属性的对象。constructor 属性的值是函数对象:

let F = function() {}; // This is a function object.
let p = F.prototype;   // This is the prototype object associated with F.
let c = p.constructor; // This is the function associated with the prototype.
c === F                // => true: F.prototype.constructor === F for any F

具有预定义原型对象及其 constructor 属性的存在意味着对象通常继承一个指向其构造函数的 constructor 属性。由于构造函数作为类的公共标识,这个构造函数属性给出了对象的类:

let o = new F();      // Create an object o of class F
o.constructor === F   // => true: the constructor property specifies the class

图 9-1 展示了构造函数、其原型对象、原型指向构造函数的反向引用以及使用构造函数创建的实例之间的关系。

js7e 0901

图 9-1. 一个构造函数、其原型和实例

注意图 9-1 使用我们的Range()构造函数作为示例。实际上,然而,在示例 9-2 中定义的 Range 类覆盖了预定义的Range.prototype对象为自己的对象。并且它定义的新原型对象没有constructor属性。因此,如定义的 Range 类的实例没有constructor属性。我们可以通过显式向原型添加构造函数来解决这个问题:

Range.prototype = {
    constructor: Range,  // Explicitly set the constructor back-reference

    /* method definitions go here */
};

另一种在旧版 JavaScript 代码中常见的技术是使用预定义的原型对象及其具有constructor属性,并使用以下代码逐个添加方法:

// Extend the predefined Range.prototype object so we don't overwrite
// the automatically created Range.prototype.constructor property.
Range.prototype.includes = function(x) {
    return this.from <= x && x <= this.to;
};
Range.prototype.toString = function() {
    return "(" + this.from + "..." + this.to + ")";
};

9.3 使用class关键字的类

类自从语言的第一个版本以来就一直是 JavaScript 的一部分,但在 ES6 中,它们终于得到了自己的语法,引入了class关键字。示例 9-3 展示了使用这种新语法编写的 Range 类的样子。

示例 9-3. 使用class重写的 Range 类
class Range {
    constructor(from, to) {
        // Store the start and end points (state) of this new range object.
        // These are noninherited properties that are unique to this object.
        this.from = from;
        this.to = to;
    }

    // Return true if x is in the range, false otherwise
    // This method works for textual and Date ranges as well as numeric.
    includes(x) { return this.from <= x && x <= this.to; }

    // A generator function that makes instances of the class iterable.
    // Note that it only works for numeric ranges.
    *[Symbol.iterator]() {
        for(let x = Math.ceil(this.from); x <= this.to; x++) yield x;
    }

    // Return a string representation of the range
    toString() { return `(${this.from}...${this.to})`; }
}

// Here are example uses of this new Range class
let r = new Range(1,3);   // Create a Range object
r.includes(2)             // => true: 2 is in the range
r.toString()              // => "(1...3)"
[...r]                    // => [1, 2, 3]; convert to an array via iterator

重要的是要理解,在示例 9-2 和 9-3 中定义的类的工作方式完全相同。引入class关键字到语言中并不改变 JavaScript 基于原型的类的基本性质。尽管示例 9-3 使用了class关键字,但生成的 Range 对象是一个构造函数,就像在示例 9-2 中定义的版本一样。新的class语法干净方便,但最好将其视为对在示例 9-2 中显示的更基本的类定义机制的“语法糖”。

注意示例 9-3 中类语法的以下几点:

  • 使用class关键字声明类,后面跟着类名和用大括号括起来的类体。

  • 类体包括使用对象字面量方法简写的方法定义(我们在示例 9-1 中也使用了),其中省略了function关键字。然而,与对象字面量不同,没有逗号用于将方法彼此分隔开。 (尽管类体在表面上与对象字面量相似,但它们并不是同一回事。特别是,它们不支持使用名称/值对定义属性。)

  • 关键字constructor用于为类定义构造函数。但实际上定义的函数并不真正命名为constructorclass声明语句定义了一个新变量Range,并将这个特殊的constructor函数的值赋给该变量。

  • 如果你的类不需要进行任何初始化,你可以省略constructor关键字及其主体,将为你隐式创建一个空的构造函数。

如果你想定义一个继承自另一个类的类,你可以使用extends关键字和class关键字:

// A Span is like a Range, but instead of initializing it with
// a start and an end, we initialize it with a start and a length
class Span extends Range {
    constructor(start, length) {
        if (length >= 0) {
            super(start, start + length);
        } else {
            super(start + length, start);
        }
    }
}

创建子类是一个独立的主题。我们将在§9.5 中返回并解释这里显示的extendssuper关键字。

类声明与函数声明一样,既有语句形式又有表达式形式。就像我们可以写:

let square = function(x) { return x * x; };
square(3)  // => 9

我们也可以写:

let Square = class { constructor(x) { this.area = x * x; } };
new Square(3).area  // => 9

与函数定义表达式一样,类定义表达式可以包括一个可选的类名。如果提供了这样的名称,那个名称仅在类体内部定义。

尽管函数表达式非常常见(特别是使用箭头函数简写),在 JavaScript 编程中,类定义表达式不是你经常使用的东西,除非你发现自己正在编写一个以类作为参数并返回子类的函数。

我们将通过提及一些重要的事项来结束对class关键字的介绍,这些事项从class语法中并不明显:

  • class声明体内的所有代码都隐式地处于严格模式中(§5.6.3),即使没有出现"use strict"指令。这意味着,例如,你不能在类体内使用八进制整数字面量或with语句,并且如果你忘记在使用之前声明一个变量,你更有可能得到语法错误。

  • 与函数声明不同,类声明不会“被提升”。回想一下§8.1.1 中提到的函数定义行为,就好像它们已经被移动到了包含文件或包含函数的顶部,这意味着你可以在实际函数定义之前的代码中调用函数。尽管类声明在某些方面类似于函数声明,但它们不共享这种提升行为:你不能在声明类之前实例化它。

9.3.1 静态方法

你可以通过在class体中的方法声明前加上static关键字来定义一个静态方法。静态方法被定义为构造函数的属性,而不是原型对象的属性。

例如,假设我们在示例 9-3 中添加了以下代码:

static parse(s) {
    let matches = s.match(/^\((\d+)\.\.\.(\d+)\)$/);
    if (!matches) {
        throw new TypeError(`Cannot parse Range from "${s}".`)
    }
    return new Range(parseInt(matches[1]), parseInt(matches[2]));
}

这段代码定义的方法是Range.parse(),而不是Range.prototype.parse(),你必须通过构造函数调用它,而不是通过实例调用:

let r = Range.parse('(1...10)'); // Returns a new Range object
r.parse('(1...10)');             // TypeError: r.parse is not a function

有时你会看到静态方法被称为类方法,因为它们是使用类/构造函数的名称调用的。当使用这个术语时,是为了将类方法与在类的实例上调用的常规实例方法进行对比。因为静态方法是在构造函数上调用而不是在任何特定实例上调用,所以在静态方法中几乎不可能使用this关键字。

我们将在示例 9-4 中看到静态方法的示例。

9.3.2 获取器、设置器和其他方法形式

class体内,你可以像在对象字面量中一样定义获取器和设置器方法(§6.10.6),唯一的区别是在类体中,你不在获取器或设置器后面加逗号。示例 9-4 包括了一个类中获取器方法的实际示例。

一般来说,在对象字面量中允许的所有简写方法定义语法在类体中也是允许的。这包括生成器方法(用*标记)和方法的名称是方括号中表达式的值的方法。事实上,你已经在示例 9-3 中看到了一个具有计算名称的生成器方法,使得 Range 类可迭代:

*[Symbol.iterator]() {
    for(let x = Math.ceil(this.from); x <= this.to; x++) yield x;
}

9.3.3 公共、私有和静态字段

在这里讨论使用class关键字定义的类时,我们只描述了类体内的方法定义。ES6 标准只允许创建方法(包括获取器、设置器和生成器)和静态方法;它不包括定义字段的语法。如果你想在类实例上定义一个字段(这只是面向对象的“属性”同义词),你必须在构造函数中或在其中一个方法中进行定义。如果你想为一个类定义一个静态字段,你必须在类体之外,在类定义之后进行定义。示例 9-4 包括了这两种字段的示例。

然而,标准化正在进行中,允许扩展类语法来定义实例和静态字段,包括公共和私有形式。截至 2020 年初,本节其余部分展示的代码尚不是标准 JavaScript,但已经在 Chrome 中得到支持,并在 Firefox 中部分支持(仅支持公共实例字段)。公共实例字段的语法已经被使用 React 框架和 Babel 转译器的 JavaScript 程序员广泛使用。

假设你正在编写一个像这样的类,其中包含一个初始化三个字段的构造函数:

class Buffer {
    constructor() {
        this.size = 0;
        this.capacity = 4096;
        this.buffer = new Uint8Array(this.capacity);
    }
}

使用可能会被标准化的新实例字段语法,你可以这样写:

class Buffer {
    size = 0;
    capacity = 4096;
    buffer = new Uint8Array(this.capacity);
}

字段初始化代码已经从构造函数中移出,现在直接出现在类体中。(当然,该代码仍然作为构造函数的一部分运行。如果你没有定义构造函数,那么字段将作为隐式创建的构造函数的一部分进行初始化。)出现在赋值左侧的this.前缀已经消失,但请注意,即使在初始化赋值的右侧,你仍然必须使用this.来引用这些字段。以这种方式初始化实例字段的优势在于,这种语法允许(但不要求)你将初始化器放在类定义的顶部,清楚地告诉读者每个实例的状态将由哪些字段保存。你可以通过只写字段名称后跟一个分号来声明没有初始化器的字段。如果这样做,字段的初始值将为undefined。对于所有类字段,始终明确指定初始值是更好的风格。

在添加此字段语法之前,类体看起来很像使用快捷方法语法的对象文字,只是逗号已被移除。这种带有等号和分号而不是冒号和逗号的字段语法清楚地表明类体与对象文字完全不同。

与寻求标准化这些实例字段的提案相同,还定义了私有实例字段。如果你使用前面示例中显示的实例字段初始化语法来定义一个以#开头的字段(这在 JavaScript 标识符中通常不是合法字符),那么该字段将可以在类体内(带有#前缀)使用,但对于类体外的任何代码来说是不可见和不可访问的(因此是不可变的)。如果对于前面的假设的 Buffer 类,你希望确保类的用户不能无意中修改实例的size字段,那么你可以使用一个私有的#size字段,然后定义一个获取器函数来提供只读访问权限:

class Buffer {
    #size = 0;
    get size() { return this.#size; }
}

请注意,私有字段必须在使用之前使用这种新字段语法进行声明。除非在类体中直接包含字段的“声明”,否则不能在类的构造函数中只写this.#size = 0;

最后,一个相关的提案旨在标准化static关键字用于字段。如果在公共或私有字段声明之前添加static,那么这些字段将作为构造函数的属性而不是实例的属性创建。考虑我们定义的静态Range.parse()方法。它包含一个可能很好地分解为自己的静态字段的相当复杂的正则表达式。使用提议的新静态字段语法,我们可以这样做:

static integerRangePattern = /^\((\d+)\.\.\.(\d+)\)$/;
static parse(s) {
    let matches = s.match(Range.integerRangePattern);
    if (!matches) {
        throw new TypeError(`Cannot parse Range from "${s}".`)
    }
    return new Range(parseInt(matches[1]), matches[2]);
}

如果我们希望这个静态字段只能在类内部访问,我们可以使用类似#pattern的私有名称。

9.3.4 示例:复数类

示例 9-4 定义了一个表示复数的类。这个类相对简单,但包括实例方法(包括获取器)、静态方法、实例字段和静态字段。它包含了一些被注释掉的代码,演示了我们如何使用尚未标准化的语法来在类体内定义实例字段和静态字段。

示例 9-4. Complex.js:一个复数类
/**
 * Instances of this Complex class represent complex numbers.
 * Recall that a complex number is the sum of a real number and an
 * imaginary number and that the imaginary number i is the square root of -1.
 */
class Complex {
    // Once class field declarations are standardized, we could declare
    // private fields to hold the real and imaginary parts of a complex number
    // here, with code like this:
    //
    // #r = 0;
    // #i = 0;

    // This constructor function defines the instance fields r and i on every
    // instance it creates. These fields hold the real and imaginary parts of
    // the complex number: they are the state of the object.
    constructor(real, imaginary) {
        this.r = real;       // This field holds the real part of the number.
        this.i = imaginary;  // This field holds the imaginary part.
    }

    // Here are two instance methods for addition and multiplication
    // of complex numbers. If c and d are instances of this class, we
    // might write c.plus(d) or d.times(c)
    plus(that) {
        return new Complex(this.r + that.r, this.i + that.i);
    }
    times(that) {
        return new Complex(this.r * that.r - this.i * that.i,
                           this.r * that.i + this.i * that.r);
    }

    // And here are static variants of the complex arithmetic methods.
    // We could write Complex.sum(c,d) and Complex.product(c,d)
    static sum(c, d) { return c.plus(d); }
    static product(c, d) { return c.times(d); }

    // These are some instance methods that are defined as getters
    // so they're used like fields. The real and imaginary getters would
    // be useful if we were using private fields this.#r and this.#i
    get real() { return this.r; }
    get imaginary() { return this.i; }
    get magnitude() { return Math.hypot(this.r, this.i); }

    // Classes should almost always have a toString() method
    toString() { return `{${this.r},${this.i}}`; }

    // It is often useful to define a method for testing whether
    // two instances of your class represent the same value
    equals(that) {
        return that instanceof Complex &&
            this.r === that.r &&
            this.i === that.i;
    }

    // Once static fields are supported inside class bodies, we could
    // define a useful Complex.ZERO constant like this:
    // static ZERO = new Complex(0,0);
}

// Here are some class fields that hold useful predefined complex numbers.
Complex.ZERO = new Complex(0,0);
Complex.ONE = new Complex(1,0);
Complex.I = new Complex(0,1);

使用示例 9-4 中定义的 Complex 类,我们可以使用构造函数、实例字段、实例方法、类字段和类方法的代码如下:

let c = new Complex(2, 3);     // Create a new object with the constructor
let d = new Complex(c.i, c.r); // Use instance fields of c
c.plus(d).toString()           // => "{5,5}"; use instance methods
c.magnitude                    // => Math.hypot(2,3); use a getter function
Complex.product(c, d)          // => new Complex(0, 13); a static method
Complex.ZERO.toString()        // => "{0,0}"; a static property

9.4 为现有类添加方法

JavaScript 的基于原型的继承机制是动态的:一个对象从其原型继承属性,即使原型的属性在对象创建后发生变化。这意味着我们可以通过简单地向其原型对象添加新方法来增强 JavaScript 类。

例如,这里是为计算复共轭添加一个方法到示例 9-4 的 Complex 类的代码:

// Return a complex number that is the complex conjugate of this one.
Complex.prototype.conj = function() { return new Complex(this.r, -this.i); };

JavaScript 内置类的原型对象也是开放的,这意味着我们可以向数字、字符串、数组、函数等添加方法。这对于在语言的旧版本中实现新的语言特性很有用:

// If the new String method startsWith() is not already defined...
if (!String.prototype.startsWith) {
    // ...then define it like this using the older indexOf() method.
    String.prototype.startsWith = function(s) {
        return this.indexOf(s) === 0;
    };
}

这里是另一个例子:

// Invoke the function f this many times, passing the iteration number
// For example, to print "hello" 3 times:
//     let n = 3;
//     n.times(i => { console.log(`hello ${i}`); });
Number.prototype.times = function(f, context) {
    let n = this.valueOf();
    for(let i = 0; i < n; i++) f.call(context, i);
};

像这样向内置类型的原型添加方法通常被认为是一个坏主意,因为如果 JavaScript 的新版本定义了同名方法,将会导致混乱和兼容性问题。甚至可以向Object.prototype添加方法,使其对所有对象可用。但这绝不是一个好主意,因为添加到Object.prototype的属性对for/in循环可见(尽管您可以通过使用Object.defineProperty()[§14.1]使新属性不可枚举来避免这种情况)。

9.5 子类

在面向对象编程中,一个类 B 可以扩展子类化另一个类 A。我们说 A 是超类,B 是子类。B 的实例继承 A 的方法。类 B 可以定义自己的方法,其中一些可能会覆盖类 A 定义的同名方法。如果 B 的方法覆盖了 A 的方法,那么 B 中的覆盖方法通常需要调用 A 中被覆盖的方法。同样,子类构造函数B()通常必须调用超类构造函数A(),以确保实例完全初始化。

本节首先展示了如何以旧的、ES6 之前的方式定义子类,然后迅速转向演示使用classextends关键字以及使用super关键字调用超类构造方法的子类化。接下来是一个关于避免子类化,依靠对象组合而不是继承的子节。本节以一个定义了一系列 Set 类的扩展示例结束,并演示了如何使用抽象类来将接口与实现分离。

9.5.1 子类和原型

假设我们想要定义一个 Span 子类,继承自示例 9-2 的 Range 类。这个子类将像 Range 一样工作,但不是用起始和结束来初始化,而是指定一个起始和一个距离,或者跨度。Span 类的一个实例也是 Range 超类的一个实例。跨度实例从Span.prototype继承了一个定制的toString()方法,但为了成为 Range 的子类,它还必须从Range.prototype继承方法(如includes())。

示例 9-5. Span.js:Range 的一个简单子类
// This is the constructor function for our subclass
function Span(start, span) {
    if (span >= 0) {
        this.from = start;
        this.to = start + span;
    } else {
        this.to = start;
        this.from = start + span;
    }
}

// Ensure that the Span prototype inherits from the Range prototype
Span.prototype = Object.create(Range.prototype);

// We don't want to inherit Range.prototype.constructor, so we
// define our own constructor property.
Span.prototype.constructor = Span;

// By defining its own toString() method, Span overrides the
// toString() method that it would otherwise inherit from Range.
Span.prototype.toString = function() {
    return `(${this.from}... +${this.to - this.from})`;
};

为了使 Span 成为 Range 的一个子类,我们需要让Span.prototypeRange.prototype继承。在前面示例中的关键代码行是这一行,如果这对你有意义,你就理解了 JavaScript 中子类是如何工作的:

Span.prototype = Object.create(Range.prototype);

使用Span()构造函数创建的对象将从Span.prototype对象继承。但我们创建该对象是为了从Range.prototype继承,因此 Span 对象将同时从Span.prototypeRange.prototype继承。

你可能注意到我们的Span()构造函数设置了与Range()构造函数相同的fromto属性,因此不需要调用Range()构造函数来初始化新对象。类似地,Span 的toString()方法完全重新实现了字符串转换,而不需要调用 Range 的toString()版本。这使 Span 成为一个特殊情况,我们只能在了解超类的实现细节时才能这样做。一个健壮的子类化机制需要允许类调用其超类的方法和构造函数,但在 ES6 之前,JavaScript 没有简单的方法来做这些事情。

幸运的是,ES6 通过super关键字作为class语法的一部分解决了这些问题。下一节将演示它是如何工作的。

9.5.2 使用 extends 和 super 创建子类

在 ES6 及更高版本中,你可以通过在类声明中添加extends子句来简单地创建一个超类,甚至可以对内置类这样做:

// A trivial Array subclass that adds getters for the first and last elements.
class EZArray extends Array {
    get first() { return this[0]; }
    get last() { return this[this.length-1]; }
}

let a = new EZArray();
a instanceof EZArray  // => true: a is subclass instance
a instanceof Array    // => true: a is also a superclass instance.
a.push(1,2,3,4);      // a.length == 4; we can use inherited methods
a.pop()               // => 4: another inherited method
a.first               // => 1: first getter defined by subclass
a.last                // => 3: last getter defined by subclass
a[1]                  // => 2: regular array access syntax still works.
Array.isArray(a)      // => true: subclass instance really is an array
EZArray.isArray(a)    // => true: subclass inherits static methods, too!

这个 EZArray 子类定义了两个简单的 getter 方法。EZArray 的实例表现得像普通数组,我们可以使用继承的方法和属性,比如push()pop()length。但我们也可以使用子类中定义的firstlast getter。不仅实例方法像pop()被继承了,静态方法像Array.isArray也被继承了。这是 ES6 类语法启用的一个新特性:EZArray()是一个函数,但它继承自Array()

// EZArray inherits instance methods because EZArray.prototype
// inherits from Array.prototype
Array.prototype.isPrototypeOf(EZArray.prototype) // => true

// And EZArray inherits static methods and properties because
// EZArray inherits from Array. This is a special feature of the
// extends keyword and is not possible before ES6.
Array.isPrototypeOf(EZArray) // => true

我们的 EZArray 子类过于简单,无法提供很多指导性。示例 9-6 是一个更加完整的示例。它定义了一个 TypedMap 的子类,继承自内置的 Map 类,并添加了类型检查以确保地图的键和值是指定类型(根据typeof)。重要的是,这个示例演示了使用super关键字来调用超类的构造函数和方法。

示例 9-6. TypedMap.js:检查键和值类型的 Map 子类
class TypedMap extends Map {
    constructor(keyType, valueType, entries) {
        // If entries are specified, check their types
        if (entries) {
            for(let [k, v] of entries) {
                if (typeof k !== keyType || typeof v !== valueType) {
                    throw new TypeError(`Wrong type for entry [${k}, ${v}]`);
                }
            }
        }

        // Initialize the superclass with the (type-checked) initial entries
        super(entries);

        // And then initialize this subclass by storing the types
        this.keyType = keyType;
        this.valueType = valueType;
    }

    // Now redefine the set() method to add type checking for any
    // new entries added to the map.
    set(key, value) {
        // Throw an error if the key or value are of the wrong type
        if (this.keyType && typeof key !== this.keyType) {
            throw new TypeError(`${key} is not of type ${this.keyType}`);
        }
        if (this.valueType && typeof value !== this.valueType) {
            throw new TypeError(`${value} is not of type ${this.valueType}`);
        }

        // If the types are correct, we invoke the superclass's version of
        // the set() method, to actually add the entry to the map. And we
        // return whatever the superclass method returns.
        return super.set(key, value);
    }
}

TypedMap()构造函数的前两个参数是期望的键和值类型。这些应该是字符串,比如“number”和“boolean”,这是typeof运算符返回的。你还可以指定第三个参数:一个包含[key,value]数组的数组(或任何可迭代对象),指定地图中的初始条目。如果指定了任何初始条目,构造函数首先验证它们的类型是否正确。接下来,构造函数使用super关键字调用超类构造函数,就像它是一个函数名一样。Map()构造函数接受一个可选参数:一个包含[key,value]数组的可迭代对象。因此,TypedMap()构造函数的可选第三个参数是Map()构造函数的可选第一个参数,我们使用super(entries)将其传递给超类构造函数。

在调用超类构造函数初始化超类状态后,TypedMap()构造函数接下来通过设置this.keyTypethis.valueType来初始化自己的子类状态。它需要设置这些属性以便在set()方法中再次使用它们。

在构造函数中使用super()时,有一些重要的规则你需要知道:

  • 如果你用extends关键字定义一个类,那么你的类的构造函数必须使用super()来调用超类构造函数。

  • 如果你在子类中没有定义构造函数,系统会自动为你定义一个。这个隐式定义的构造函数简单地接受传递给它的任何值,并将这些值传递给super()

  • 在调用super()之前,你不能在构造函数中使用this关键字。这强制了一个规则,即超类在子类之前初始化。

  • 特殊表达式new.target在没有使用new关键字调用的函数中是未定义的。然而,在构造函数中,new.target是对被调用的构造函数的引用。当子类构造函数被调用并使用super()来调用超类构造函数时,那个超类构造函数将会把子类构造函数视为new.target的值。一个设计良好的超类不应该知道自己是否被子类化,但在日志消息中使用new.target.name可能会很有用。

在构造函数之后,示例 9-6 的下一部分是一个名为set()的方法。Map 超类定义了一个名为set()的方法来向地图添加新条目。我们说这个 TypedMap 中的set()方法覆盖了其超类的set()方法。这个简单的 TypedMap 子类对于向地图添加新条目一无所知,但它知道如何检查类型,所以首先进行类型检查,验证要添加到地图中的键和值是否具有正确的类型,如果不是则抛出错误。这个set()方法没有任何方法将键和值添加到地图本身,但这就是超类set()方法的作用。因此,我们再次使用super关键字来调用超类的方法版本。在这个上下文中,super的工作方式很像this关键字:它引用当前对象但允许访问在超类中定义的重写方法。

在构造函数中,你必须在访问this并自己初始化新对象之前调用超类构造函数。当你重写一个方法时,没有这样的规则。重写超类方法的方法不需要调用超类方法。如果它确实使用super来调用被重写的方法(或超类中的任何方法),它可以在重写方法的开始、中间或结尾进行调用。

最后,在我们离开 TypedMap 示例之前,值得注意的是,这个类是使用私有字段的理想候选。目前这个类的写法,用户可以更改keyTypevalueType属性以规避类型检查。一旦支持私有字段,我们可以将这些属性更改为#keyType#valueType,这样它们就无法从外部更改。

9.5.3 代理而非继承

extends关键字使创建子类变得容易。但这并不意味着你应该创建大量子类。如果你想编写一个共享某个其他类行为的类,你可以尝试通过创建子类来继承该行为。但通常更容易和更灵活的方法是通过让你的类创建另一个类的实例并根据需要简单地委托给该实例来获得所需的行为。你创建一个新类不是通过子类化,而是通过包装或“组合”其他类。这种委托方法通常被称为“组合”,并且面向对象编程的一个经常引用的格言是应该“优先选择组合而非继承”。²

例如,假设我们想要一个直方图类,其行为类似于 JavaScript 的 Set 类,但不仅仅是跟踪值是否已添加到集合中,而是维护值已添加的次数。因为这个直方图类的 API 类似于 Set,我们可以考虑继承 Set 并添加一个count()方法。另一方面,一旦我们开始考虑如何实现这个count()方法,我们可能会意识到直方图类更像是一个 Map 而不是一个 Set,因为它需要维护值和它们被添加的次数之间的映射关系。因此,我们可以创建一个定义了类似 Set API 的类,但通过委托给内部 Map 对象来实现这些方法。示例 9-7 展示了我们如何做到这一点。

示例 9-7. Histogram.js:使用委托实现的类似 Set 的类
/**
 * A Set-like class that keeps track of how many times a value has
 * been added. Call add() and remove() like you would for a Set, and
 * call count() to find out how many times a given value has been added.
 * The default iterator yields the values that have been added at least
 * once. Use entries() if you want to iterate [value, count] pairs.
 */
class Histogram {
    // To initialize, we just create a Map object to delegate to
    constructor() { this.map = new Map(); }

    // For any given key, the count is the value in the Map, or zero
    // if the key does not appear in the Map.
    count(key) { return this.map.get(key) || 0; }

    // The Set-like method has() returns true if the count is non-zero
    has(key) { return this.count(key) > 0; }

    // The size of the histogram is just the number of entries in the Map.
    get size() { return this.map.size; }

    // To add a key, just increment its count in the Map.
    add(key) { this.map.set(key, this.count(key) + 1); }

    // Deleting a key is a little trickier because we have to delete
    // the key from the Map if the count goes back down to zero.
    delete(key) {
        let count = this.count(key);
        if (count === 1) {
            this.map.delete(key);
        } else if (count > 1) {
            this.map.set(key, count - 1);
        }
    }

    // Iterating a Histogram just returns the keys stored in it
    [Symbol.iterator]() { return this.map.keys(); }

    // These other iterator methods just delegate to the Map object
    keys() { return this.map.keys(); }
    values() { return this.map.values(); }
    entries() { return this.map.entries(); }
}

示例 9-7 中的Histogram()构造函数只是创建了一个 Map 对象。大多数方法都只是简单地委托给地图的一个方法,使得实现非常简单。因为我们使用了委托而不是继承,一个 Histogram 对象不是 Set 或 Map 的实例。但 Histogram 实现了许多常用的 Set 方法,在像 JavaScript 这样的无类型语言中,这通常已经足够了:正式的继承关系有时很好,但通常是可选的。

9.5.4 类层次结构和抽象类

示例 9-6 演示了我们如何继承 Map。示例 9-7 演示了我们如何委托给一个 Map 对象而不实际继承任何东西。使用 JavaScript 类来封装数据和模块化代码通常是一个很好的技术,你可能会经常使用class关键字。但你可能会发现你更喜欢组合而不是继承,并且很少需要使用extends(除非你使用要求你扩展其基类的库或框架)。

然而,在某些情况下,多级子类化是合适的,我们将以一个扩展示例结束本章,该示例演示了代表不同类型集合的类的层次结构。(示例 9-8 中定义的集合类与 JavaScript 内置的 Set 类类似,但不完全兼容。)

示例 9-8 定义了许多子类,但它还演示了如何定义抽象类——不包括完整实现的类——作为一组相关子类的共同超类。抽象超类可以定义所有子类继承和共享的部分实现。然后,子类只需要通过实现超类定义但未实现的抽象方法来定义自己的独特行为。请注意,JavaScript 没有任何正式定义抽象方法或抽象类的规定;我在这里仅仅是使用这个名称来表示未实现的方法和未完全实现的类。

示例 9-8 有很好的注释,可以独立运行。我鼓励你将其作为本章关于类的顶尖示例来阅读。在示例 9-8 中的最终类使用了&|~运算符进行大量的位操作,你可以在§4.8.3 中复习。

示例 9-8. Sets.js:抽象和具体集合类的层次结构
/**
 * The AbstractSet class defines a single abstract method, has().
 */
class AbstractSet {
    // Throw an error here so that subclasses are forced
    // to define their own working version of this method.
    has(x) { throw new Error("Abstract method"); }
}

/**
 * NotSet is a concrete subclass of AbstractSet.
 * The members of this set are all values that are not members of some
 * other set. Because it is defined in terms of another set it is not
 * writable, and because it has infinite members, it is not enumerable.
 * All we can do with it is test for membership and convert it to a
 * string using mathematical notation.
 */
class NotSet extends AbstractSet {
    constructor(set) {
        super();
        this.set = set;
    }

    // Our implementation of the abstract method we inherited
    has(x) { return !this.set.has(x); }
    // And we also override this Object method
    toString() { return `{ x| x ∉ ${this.set.toString()} }`; }
}

/**
 * Range set is a concrete subclass of AbstractSet. Its members are
 * all values that are between the from and to bounds, inclusive.
 * Since its members can be floating point numbers, it is not
 * enumerable and does not have a meaningful size.
 */
class RangeSet extends AbstractSet {
    constructor(from, to) {
        super();
        this.from = from;
        this.to = to;
    }

    has(x) { return x >= this.from && x <= this.to; }
    toString() { return `{ x| ${this.from} ≤ x ≤ ${this.to} }`; }
}

/*
 * AbstractEnumerableSet is an abstract subclass of AbstractSet.  It defines
 * an abstract getter that returns the size of the set and also defines an
 * abstract iterator. And it then implements concrete isEmpty(), toString(),
 * and equals() methods on top of those. Subclasses that implement the
 * iterator, the size getter, and the has() method get these concrete
 * methods for free.
 */
class AbstractEnumerableSet extends AbstractSet {
    get size() { throw new Error("Abstract method"); }
    [Symbol.iterator]() { throw new Error("Abstract method"); }

    isEmpty() { return this.size === 0; }
    toString() { return `{${Array.from(this).join(", ")}}`; }
    equals(set) {
        // If the other set is not also Enumerable, it isn't equal to this one
        if (!(set instanceof AbstractEnumerableSet)) return false;

        // If they don't have the same size, they're not equal
        if (this.size !== set.size) return false;

        // Loop through the elements of this set
        for(let element of this) {
            // If an element isn't in the other set, they aren't equal
            if (!set.has(element)) return false;
        }

        // The elements matched, so the sets are equal
        return true;
    }
}

/*
 * SingletonSet is a concrete subclass of AbstractEnumerableSet.
 * A singleton set is a read-only set with a single member.
 */
class SingletonSet extends AbstractEnumerableSet {
    constructor(member) {
        super();
        this.member = member;
    }

    // We implement these three methods, and inherit isEmpty, equals()
    // and toString() implementations based on these methods.
    has(x) { return x === this.member; }
    get size() { return 1; }
    *[Symbol.iterator]() { yield this.member; }
}

/*
 * AbstractWritableSet is an abstract subclass of AbstractEnumerableSet.
 * It defines the abstract methods insert() and remove() that insert and
 * remove individual elements from the set, and then implements concrete
 * add(), subtract(), and intersect() methods on top of those. Note that
 * our API diverges here from the standard JavaScript Set class.
 */
class AbstractWritableSet extends  AbstractEnumerableSet {
    insert(x) { throw new Error("Abstract method"); }
    remove(x) { throw new Error("Abstract method"); }

    add(set) {
        for(let element of set) {
            this.insert(element);
        }
    }

    subtract(set) {
        for(let element of set) {
            this.remove(element);
        }
    }

    intersect(set) {
        for(let element of this) {
            if (!set.has(element)) {
                this.remove(element);
            }
        }
    }
}

/**
 * A BitSet is a concrete subclass of AbstractWritableSet with a
 * very efficient fixed-size set implementation for sets whose
 * elements are non-negative integers less than some maximum size.
 */
class BitSet extends AbstractWritableSet {
    constructor(max) {
        super();
        this.max = max;  // The maximum integer we can store.
        this.n = 0;      // How many integers are in the set
        this.numBytes = Math.floor(max / 8) + 1;   // How many bytes we need
        this.data = new Uint8Array(this.numBytes); // The bytes
    }

    // Internal method to check if a value is a legal member of this set
    _valid(x) { return Number.isInteger(x) && x >= 0 && x <= this.max; }

    // Tests whether the specified bit of the specified byte of our
    // data array is set or not. Returns true or false.
    _has(byte, bit) { return (this.data[byte] & BitSet.bits[bit]) !== 0; }

    // Is the value x in this BitSet?
    has(x) {
        if (this._valid(x)) {
            let byte = Math.floor(x / 8);
            let bit = x % 8;
            return this._has(byte, bit);
        } else {
            return false;
        }
    }

    // Insert the value x into the BitSet
    insert(x) {
        if (this._valid(x)) {               // If the value is valid
            let byte = Math.floor(x / 8);   // convert to byte and bit
            let bit = x % 8;
            if (!this._has(byte, bit)) {    // If that bit is not set yet
                this.data[byte] |= BitSet.bits[bit]; // then set it
                this.n++;                            // and increment set size
            }
        } else {
            throw new TypeError("Invalid set element: " + x );
        }
    }

    remove(x) {
        if (this._valid(x)) {              // If the value is valid
            let byte = Math.floor(x / 8);  // compute the byte and bit
            let bit = x % 8;
            if (this._has(byte, bit)) {    // If that bit is already set
                this.data[byte] &= BitSet.masks[bit];  // then unset it
                this.n--;                              // and decrement size
            }
        } else {
            throw new TypeError("Invalid set element: " + x );
        }
    }

    // A getter to return the size of the set
    get size() { return this.n; }

    // Iterate the set by just checking each bit in turn.
    // (We could be a lot more clever and optimize this substantially)
    *[Symbol.iterator]() {
        for(let i = 0; i <= this.max; i++) {
            if (this.has(i)) {
                yield i;
            }
        }
    }
}

// Some pre-computed values used by the has(), insert() and remove() methods
BitSet.bits = new Uint8Array([1, 2, 4, 8, 16, 32, 64, 128]);
BitSet.masks = new Uint8Array([~1, ~2, ~4, ~8, ~16, ~32, ~64, ~128]);

9.6 总结

本章已经解释了 JavaScript 类的关键特性:

  • 同一类的对象从相同的原型对象继承属性。原型对象是 JavaScript 类的关键特性,可以仅使用Object.create()方法定义类。

  • 在 ES6 之前,类通常是通过首先定义构造函数来定义的。使用function关键字创建的函数具有一个prototype属性,该属性的值是一个对象,当使用new作为构造函数调用函数时,该对象被用作所有创建的对象的原型。通过初始化这个原型对象,您可以定义类的共享方法。虽然原型对象是类的关键特征,但构造函数是类的公共标识。

  • ES6 引入了class关键字,使得定义类更容易,但在底层,构造函数和原型机制仍然保持不变。

  • 子类是在类声明中使用extends关键字定义的。

  • 子类可以使用super关键字调用其父类的构造函数或重写的方法。

¹ 除了 ES5 的Function.bind()方法返回的函数。绑定函数没有自己的原型属性,但如果作为构造函数调用它们,则它们使用基础函数的原型。

² 例如,参见 Erich Gamma 等人的设计模式(Addison-Wesley Professional)或 Joshua Bloch 的Effective Java(Addison-Wesley Professional)。

第十章:模块

模块化编程的目标是允许从不同作者和来源的代码模块组装大型程序,并且所有这些代码在各个模块作者未预料到的代码存在的情况下仍能正确运行。 从实际角度来看,模块化主要是关于封装或隐藏私有实现细节,并保持全局命名空间整洁,以便模块不会意外修改其他模块定义的变量、函数和类。

直到最近,JavaScript 没有内置模块支持,而在大型代码库上工作的程序员尽力利用类、对象和闭包提供的弱模块化。基于闭包的模块化,结合代码捆绑工具的支持,形成了一种基于require()函数的实用模块化形式,这被 Node 所采用。 基于require()的模块是 Node 编程环境的基本组成部分,但从未被正式纳入 JavaScript 语言的一部分。 相反,ES6 使用importexport关键字定义模块。 尽管importexport多年来一直是语言的一部分,但它们最近才被 Web 浏览器和 Node 实现。 作为一个实际问题,JavaScript 模块化仍然依赖于代码捆绑工具。

接下来的章节涵盖:

  • 使用类、对象和闭包自行创建模块

  • 使用require()的 Node 模块

  • 使用exportimportimport()的 ES6 模块

10.1 使用类、对象和闭包的模块

尽管这可能是显而易见的,但值得指出的是,类的一个重要特性是它们作为其方法的模块。 回想一下示例 9-8。 该示例定义了许多不同的类,所有这些类都有一个名为has()的方法。 但是,您可以毫无问题地编写一个使用该示例中多个集合类的程序:例如,SingletonSet 的has()方法不会覆盖 BitSet 的has()方法。

一个类的方法独立于其他不相关类的方法的原因是,每个类的方法被定义为独立原型对象的属性。 类是模块化的原因是对象是模块化的:在 JavaScript 对象中定义属性很像声明变量,但向对象添加属性不会影响程序的全局命名空间,也不会影响其他对象的属性。 JavaScript 定义了相当多的数学函数和常量,但是不是将它们全部定义为全局的,而是将它们作为单个全局 Math 对象的属性分组。 这种技术可以在示例 9-8 中使用。 该示例可以被编写为仅定义一个名为 Sets 的全局对象,其属性引用各种类。 使用此 Sets 库的用户可以使用类似Sets.SingletonSets.Bit的名称引用类。

在 JavaScript 编程中,使用类和对象进行模块化是一种常见且有用的技术,但这还不够。 特别是,它没有提供任何隐藏模块内部实现细节的方法。 再次考虑示例 9-8。 如果我们将该示例编写为一个模块,也许我们希望将各种抽象类保留在模块内部,只将具体子类提供给模块的用户。 同样,在 BitSet 类中,_valid()_has()方法是内部实用程序,不应该真正暴露给类的用户。 BitSet.bitsBitSet.masks是最好隐藏的实现细节。

正如我们在 §8.6 中看到的,函数内声明的局部变量和嵌套函数对该函数是私有的。这意味着我们可以使用立即调用的函数表达式通过将实现细节和实用函数隐藏在封闭函数中,使模块的公共 API 成为函数的返回值来实现一种模块化。对于 BitSet 类,我们可以将模块结构化如下:

const BitSet = (function() { // Set BitSet to the return value of this function
    // Private implementation details here
    function isValid(set, n) { ... }
    function has(set, byte, bit) { ... }
    const BITS = new Uint8Array([1, 2, 4, 8, 16, 32, 64, 128]);
    const MASKS = new Uint8Array([~1, ~2, ~4, ~8, ~16, ~32, ~64, ~128]);

    // The public API of the module is just the BitSet class, which we define
    // and return here. The class can use the private functions and constants
    // defined above, but they will be hidden from users of the class
    return class BitSet extends AbstractWritableSet {
        // ... implementation omitted ...
    };
}());

当模块中有多个项时,这种模块化方法变得更加有趣。例如,以下代码定义了一个迷你统计模块,导出 mean()stddev() 函数,同时隐藏了实现细节:

// This is how we could define a stats module
const stats = (function() {
    // Utility functions private to the module
    const sum = (x, y) => x + y;
    const square = x => x * x;

    // A public function that will be exported
    function mean(data) {
        return data.reduce(sum)/data.length;
    }

    // A public function that we will export
    function stddev(data) {
        let m = mean(data);
        return Math.sqrt(
            data.map(x => x - m).map(square).reduce(sum)/(data.length-1)
        );
    }

    // We export the public function as properties of an object
    return { mean, stddev };
}());

// And here is how we might use the module
stats.mean([1, 3, 5, 7, 9])   // => 5
stats.stddev([1, 3, 5, 7, 9]) // => Math.sqrt(10)

10.1.1 自动化基于闭包的模块化

请注意,将 JavaScript 代码文件转换为这种模块的过程是一个相当机械化的过程,只需在文件开头和结尾插入一些文本即可。所需的只是一些约定,用于指示哪些值要导出,哪些不要导出。

想象一个工具,它接受一组文件,将每个文件的内容包装在立即调用的函数表达式中,跟踪每个函数的返回值,并将所有内容连接成一个大文件。结果可能看起来像这样:

const modules = {};
function require(moduleName) { return modules[moduleName]; }

modules["sets.js"] = (function() {
    const exports = {};

    // The contents of the sets.js file go here:
    exports.BitSet = class BitSet { ... };

    return exports;
}());

modules["stats.js"] = (function() {
    const exports = {};

    // The contents of the stats.js file go here:
    const sum = (x, y) => x + y;
    const square = x = > x * x;
    exports.mean = function(data) { ... };
    exports.stddev = function(data) { ... };

    return exports;
}());

将模块捆绑成一个单一文件,就像前面示例中所示的那样,你可以想象编写以下代码来利用这些模块:

// Get references to the modules (or the module content) that we need
const stats = require("stats.js");
const BitSet = require("sets.js").BitSet;

// Now write code using those modules
let s = new BitSet(100);
s.insert(10);
s.insert(20);
s.insert(30);
let average = stats.mean([...s]); // average is 20

这段代码是对代码捆绑工具(如 webpack 和 Parcel)在 web 浏览器中的工作原理的粗略草图,也是对类似于 Node 程序中使用的 require() 函数的简单介绍。

10.2 Node 模块

在 Node 编程中,将程序分割为尽可能多的文件是很正常的。这些 JavaScript 代码文件都假定存在于一个快速的文件系统上。与 web 浏览器不同,后者必须通过相对较慢的网络连接读取 JavaScript 文件,将 Node 程序捆绑成一个单一的 JavaScript 文件既没有必要也没有好处。

在 Node 中,每个文件都是具有私有命名空间的独立模块。在一个文件中定义的常量、变量、函数和类对该文件是私有的,除非文件导出它们。一个模块导出的值只有在另一个模块明确导入它们时才能看到。

Node 模块通过 require() 函数导入其他模块,并通过设置 Exports 对象的属性或完全替换 module.exports 对象来导出它们的公共 API。

10.2.1 Node 导出

Node 定义了一个全局的 exports 对象,它总是被定义。如果你正在编写一个导出多个值的 Node 模块,你可以简单地将它们分配给这个对象的属性:

const sum = (x, y) => x + y;
const square = x => x * x;

exports.mean = data => data.reduce(sum)/data.length;
exports.stddev = function(d) {
    let m = exports.mean(d);
    return Math.sqrt(d.map(x => x - m).map(square).reduce(sum)/(d.length-1));
};

然而,通常情况下,你可能只想定义一个仅导出单个函数或类的模块,而不是一个充满函数或类的对象。为此,你只需将要导出的单个值分配给 module.exports

module.exports = class BitSet extends AbstractWritableSet {
    // implementation omitted
};

module.exports 的默认值是 exports 所指向的相同对象。在之前的 stats 模块中,我们可以将 mean 函数分配给 module.exports.mean 而不是 exports.mean。像 stats 模块这样的模块的另一种方法是在模块末尾导出一个单一对象,而不是在导出函数时逐个导出:

// Define all the functions, public and private
const sum = (x, y) => x + y;
const square = x => x * x;
const mean = data => data.reduce(sum)/data.length;
const stddev = d => {
    let m = mean(d);
    return Math.sqrt(d.map(x => x - m).map(square).reduce(sum)/(d.length-1));
};

// Now export only the public ones
module.exports = { mean, stddev };

10.2.2 Node 导入

一个 Node 模块通过调用 require() 函数来导入另一个模块。这个函数的参数是要导入的模块的名称,返回值是该模块导出的任何值(通常是一个函数、类或对象)。

如果你想导入 Node 内置的系统模块或通过包管理器在系统上安装的模块,那么你只需使用模块的未限定名称,不需要任何将其转换为文件系统路径的“/”字符:

// These modules are built in to Node
const fs = require("fs");           // The built-in filesystem module
const http = require("http");       // The built-in HTTP module

// The Express HTTP server framework is a third-party module.
// It is not part of Node but has been installed locally
const express = require("express");

当您想要导入自己代码的模块时,模块名称应该是包含该代码的文件的路径,相对于当前模块文件。使用以*/字符开头的绝对路径是合法的,但通常,当导入属于您自己程序的模块时,模块名称将以./ 或有时是../ *开头,以指示它们相对于当前目录或父目录。例如:

const stats = require('./stats.js');
const BitSet = require('./utils/bitset.js');

(您也可以省略导入文件的*.js*后缀,Node 仍然可以找到这些文件,但通常会看到这些文件扩展名明确包含在内。)

当一个模块只导出一个函数或类时,您只需导入它。当一个模块导出一个具有多个属性的对象时,您可以选择:您可以导入整个对象,或者只导入您打算使用的对象的特定属性(使用解构赋值)。比较这两种方法:

// Import the entire stats object, with all of its functions
const stats = require('./stats.js');

// We've got more functions than we need, but they're neatly
// organized into a convenient "stats" namespace.
let average = stats.mean(data);

// Alternatively, we can use idiomatic destructuring assignment to import
// exactly the functions we want directly into the local namespace:
const { stddev } = require('./stats.js');

// This is nice and succinct, though we lose a bit of context
// without the 'stats' prefix as a namspace for the stddev() function.
let sd = stddev(data);

10.2.3 Web 上的 Node 风格模块

具有 Exports 对象和require()函数的模块内置于 Node 中。但是,如果您愿意使用像 webpack 这样的捆绑工具处理您的代码,那么也可以将这种模块样式用于旨在在 Web 浏览器中运行的代码。直到最近,这是一种非常常见的做法,您可能会看到许多仍在这样做的基于 Web 的代码。

现在 JavaScript 有了自己的标准模块语法,然而,使用捆绑工具的开发人员更有可能使用带有importexport语句的官方 JavaScript 模块。

10.3 ES6 中的模块

ES6 为 JavaScript 添加了importexport关键字,最终将真正的模块化支持作为核心语言特性。ES6 的模块化在概念上与 Node 的模块化相同:每个文件都是自己的模块,文件中定义的常量、变量、函数和类除非明确导出,否则都是私有于该模块。从一个模块导出的值可以在明确导入它们的模块中使用。ES6 模块在导出和导入的语法以及在 Web 浏览器中定义模块的方式上与 Node 模块不同。接下来的部分将详细解释这些内容。

首先,请注意,ES6 模块在某些重要方面也与常规 JavaScript“脚本”不同。最明显的区别是模块化本身:在常规脚本中,变量、函数和类的顶级声明进入由所有脚本共享的单个全局上下文中。使用模块后,每个文件都有自己的私有上下文,并且可以使用importexport语句,这毕竟是整个重点。但模块和脚本之间还有其他区别。ES6 模块中的代码(就像 ES6 class定义内的代码一样)自动处于严格模式(参见§5.6.3)。这意味着,当您开始使用 ES6 模块时,您将永远不必再编写"use strict"。这意味着模块中的代码不能使用with语句或arguments对象或未声明的变量。ES6 模块甚至比严格模式稍微严格:在严格模式中,作为函数调用的函数中,thisundefined。在模块中,即使在顶级代码中,this也是undefined。(相比之下,Web 浏览器和 Node 中的脚本将this设置为全局对象。)

Web 上和 Node 中的 ES6 模块

多年来,借助像 webpack 这样的代码捆绑工具,ES6 模块已经在 Web 上得到了应用,这些工具将独立的 JavaScript 代码模块组合成大型、非模块化的捆绑包,适合包含在网页中。然而,在撰写本文时,除了 Internet Explorer 之外,所有 Web 浏览器终于原生支持 ES6 模块。在原生支持时,ES6 模块通过特殊的<script type="module">标签添加到 HTML 页面中,本章后面将对此进行描述。

与此同时,作为 JavaScript 模块化的先驱,Node 发现自己处于一个尴尬的位置,必须支持两种不完全兼容的模块系统。Node 13 支持 ES6 模块,但目前,绝大多数 Node 程序仍然使用 Node 模块。

10.3.1 ES6 导出

要从 ES6 模块中导出常量、变量、函数或类,只需在声明之前添加关键字export

export const PI = Math.PI;

export function degreesToRadians(d) { return d * PI / 180; }

export class Circle {
    constructor(r) { this.r = r; }
    area() { return PI * this.r * this.r; }
}

作为在模块中散布export关键字的替代方案,你可以像通常一样定义常量、变量、函数和类,不写任何export语句,然后(通常在模块的末尾)写一个单独的export语句,声明在一个地方精确地导出了什么。因此,与在前面的代码中写三个单独的导出相反,我们可以在末尾写一行等效的代码:

export { Circle, degreesToRadians, PI };

这个语法看起来像是export关键字后跟一个对象字面量(使用简写表示法)。但在这种情况下,花括号实际上并没有定义一个对象字面量。这种导出语法只需要在花括号内写一个逗号分隔的标识符列表。

编写只导出一个值(通常是函数或类)的模块很常见,在这种情况下,我们通常使用export default而不是export

export default class BitSet {
    // implementation omitted
}

默认导出比非默认导出稍微容易导入,因此当只有一个导出值时,使用export default会使使用你导出值的模块更容易。

使用export进行常规导出只能用于具有名称的声明。使用export default进行默认导出可以导出任何表达式,包括匿名函数表达式和匿名类表达式。这意味着如果你使用export default,你可以导出对象字面量。因此,与export语法不同,如果你在export default后看到花括号,那么实际上导出的是一个对象字面量。

模块既有一组常规导出又有默认导出是合法的,但有些不太常见。如果一个模块有默认导出,它只能有一个。

最后,请注意export关键字只能出现在你的 JavaScript 代码的顶层。你不能从类、函数、循环或条件语句中导出值。(这是 ES6 模块系统的一个重要特性,它实现了静态分析:模块的导出在每次运行时都是相同的,并且可以在模块实际运行之前确定导出的符号。)

10.3.2 ES6 导入

你可以使用import关键字导入其他模块导出的值。最简单的导入形式用于定义默认导出的模块:

import BitSet from './bitset.js';

这是import关键字,后面跟着一个标识符,然后是from关键字,后面是一个字符串字面量,命名了我们要导入默认导出的模块。指定模块的默认导出值成为当前模块中指定标识符的值。

导入值分配给的标识符是一个常量,就好像它已经用const关键字声明过一样。与导出一样,导入只能出现在模块的顶层,不允许在类、函数、循环或条件语句中。根据普遍惯例,模块所需的导入应放在模块的开头。然而有趣的是,这并非必须:像函数声明一样,导入被“提升”到顶部,所有导入的值在模块的任何代码运行时都可用。

导入值的模块被指定为一个常量字符串文字,用单引号或双引号括起来。(您不能使用值为字符串的变量或其他表达式,也不能在反引号内使用字符串,因为模板文字可以插入变量并且不总是具有常量值。)在 Web 浏览器中,此字符串被解释为相对于执行导入操作的模块的位置的 URL。(在 Node 中,或者使用捆绑工具时,该字符串被解释为相对于当前模块的文件名,但在实践中这几乎没有区别。)模块规范符字符串必须是以“/”开头的绝对路径,或以“./”或“../”开头的相对路径,或具有协议和主机名的完整 URL。ES6 规范不允许未经限定的模块规范符字符串,如“util.js”,因为不清楚这是否意味着要命名与当前目录中的模块或某种安装在某个特殊位置的系统模块。 (这个对“裸模块规范符”的限制不被像 webpack 这样的代码捆绑工具所遵守,它可以很容易地配置为在您指定的库目录中找到裸模块。)语言的未来版本可能允许“裸模块规范符”,但目前不允许。如果要从与当前目录相同的目录导入模块,只需在模块名称前加上“./”,并从“./util.js”而不是“util.js”导入。

到目前为止,我们只考虑了从使用export default的模块导入单个值的情况。要从导出多个值的模块导入值,我们使用稍微不同的语法:

import { mean, stddev } from "./stats.js";

请记住,默认导出在定义它们的模块中不需要名称。相反,当我们导入这些值时,我们提供一个本地名称。但是,模块的非默认导出在导出模块中有名称,当我们导入这些值时,我们通过这些名称引用它们。导出模块可以导出任意数量的命名值。引用该模块的import语句可以通过在花括号内列出它们的名称来导入这些值的任意子集。花括号使这种import语句看起来有点像解构赋值,实际上,解构赋值是这种导入样式在做的事情的一个很好的类比。花括号内的标识符都被提升到导入模块的顶部,并且行为像常量。

样式指南有时建议您明确导入模块将使用的每个符号。但是,当从定义许多导出的模块导入时,您可以轻松地使用像这样的import语句导入所有内容:

import * as stats from "./stats.js";

像这样的import语句会创建一个对象,并将其赋值给名为stats的常量。被导入模块的每个非默认导出都成为这个stats对象的属性。非默认导出始终有名称,并且这些名称在对象内部用作属性名称。这些属性实际上是常量:它们不能被覆盖或删除。在前面示例中显示的通配符导入中,导入模块将通过stats对象使用导入的mean()stddev()函数,调用它们为stats.mean()stats.stddev()

模块通常定义一个默认导出或多个命名导出。一个模块同时使用exportexport default是合法的,但有点不常见。但是当一个模块这样做时,您可以使用像这样的import语句同时导入默认值和命名值:

import Histogram, { mean, stddev } from "./histogram-stats.js";

到目前为止,我们已经看到了如何从具有默认导出的模块和具有非默认或命名导出的模块导入。但是还有一种import语句的形式,用于没有任何导出的模块。要将没有任何导出的模块包含到您的程序中,只需使用import关键字与模块规范符:

import "./analytics.js";

这样的模块在第一次导入时运行。(随后的导入不会执行任何操作。)一个仅定义函数的模块只有在导出其中至少一个函数时才有用。但是,如果一个模块运行一些代码,那么即使没有符号,导入它也是有用的。一个用于 Web 应用程序的分析模块可能会运行代码来注册各种事件处理程序,然后在适当的时候使用这些事件处理程序将遥测数据发送回服务器。该模块是自包含的,不需要导出任何内容,但我们仍然需要import它,以便它实际上作为我们程序的一部分运行。

请注意,即使有导出的模块,您也可以使用这种导入空内容的import语法。如果一个模块定义了独立于其导出值的有用行为,并且您的程序不需要任何这些导出值,您仍然可以导入该模块。只是为了那个默认行为。

10.3.3 导入和重命名导出

如果两个模块使用相同名称导出两个不同的值,并且您想要导入这两个值,那么在导入时您将需要重命名其中一个或两个值。同样,如果您想要导入一个名称已在您的模块中使用的值,那么您将需要重命名导入的值。您可以使用带有命名导入的as关键字来重命名它们:

import { render as renderImage } from "./imageutils.js";
import { render as renderUI } from "./ui.js";

这些行将两个函数导入当前模块。这两个函数在定义它们的模块中都被命名为render(),但在导入时使用更具描述性和消除歧义的名称renderImage()renderUI()

请记住,默认导出没有名称。导入模块在导入默认导出时总是选择名称。因此,在这种情况下不需要特殊的重命名语法。

话虽如此,但在导入时重命名的可能性提供了另一种从定义默认导出和命名导出的模块中导入的方式。回想一下前一节中的“./histogram-stats.js”模块。以下是导入该模块的默认导出和命名导出的另一种方式:

import { default as Histogram, mean, stddev } from "./histogram-stats.js";

在这种情况下,JavaScript 关键字default充当占位符,并允许我们指示我们要导入并为模块的默认导出提供名称。

也可以在导出时重命名值,但只能在使用花括号变体的export语句时。通常不需要这样做,但如果您在模块内选择了简短、简洁的名称,您可能更喜欢使用更具描述性的名称导出值,这样就不太可能与其他模块发生冲突。与导入一样,您可以使用as关键字来执行此操作:

export {
    layout as calculateLayout,
    render as renderLayout
};

请记住,尽管花括号看起来有点像对象文字,但它们并不是,export关键字在as之前期望一个标识符,而不是一个表达式。这意味着不幸的是,您不能像这样使用导出重命名:

export { Math.sin as sin, Math.cos as cos }; // SyntaxError

10.3.4 重新导出

在本章中,我们讨论了一个假设的“./stats.js”模块,该模块导出mean()stddev()函数。如果我们正在编写这样一个模块,并且我们认为该模块的许多用户只想要其中一个函数,那么我们可能希望在“./stats/mean.js”模块中定义mean(),在“./stats/stddev.js”中定义stddev()。这样,程序只需要导入它们需要的函数,而不会因导入不需要的代码而臃肿。

即使我们将这些统计函数定义在单独的模块中,我们可能仍然希望有很多程序需要这两个函数,并且希望有一个方便的“./stats.js”模块,可以在一行中导入这两个函数。

鉴于现在实现在单独的文件中,定义这个“./stat.js”模块很简单:

import { mean } from "./stats/mean.js";
import { stddev } from "./stats/stddev.js";
export { mean, stdev };

ES6 模块预期这种用法并为其提供了特殊的语法。不再简单地导入一个符号再导出它,你可以将导入和导出步骤合并为一个单独的“重新导出”语句,使用export关键字和from关键字:

export { mean } from "./stats/mean.js";
export { stddev } from "./stats/stddev.js";

请注意,此代码中实际上没有使用meanstddev这两个名称。如果我们不选择性地重新导出并且只想从另一个模块导出所有命名值,我们可以使用通配符:

export * from "./stats/mean.js";
export * from "./stats/stddev.js";

重新导出语法允许使用as进行重命名,就像常规的importexport语句一样。假设我们想要重新导出mean()函数,但同时为该函数定义average()作为另一个名称。我们可以这样做:

export { mean, mean as average } from "./stats/mean.js";
export { stddev } from "./stats/stddev.js";

该示例中的所有重新导出都假定“./stats/mean.js”和“./stats/stddev.js”模块使用export而不是export default导出它们的函数。实际上,由于这些是只有一个导出的模块,定义为export default是有意义的。如果我们这样做了,那么重新导出语法会稍微复杂一些,因为它需要为未命名的默认导出定义一个名称。我们可以这样做:

export { default as mean } from "./stats/mean.js";
export { default as stddev } from "./stats/stddev.js";

如果你想要将另一个模块的命名符号重新导出为你的模块的默认导出,你可以进行import,然后进行export default,或者你可以将这两个语句结合起来,像这样:

// Import the mean() function from ./stats.js and make it the
// default export of this module
export { mean as default } from "./stats.js"

最后,要将另一个模块的默认导出重新导出为你的模块的默认导出(尽管不清楚为什么要这样做,因为用户可以直接导入另一个模块),你可以这样写:

// The average.js module simply re-exports the stats/mean.js default export
export { default } from "./stats/mean.js"

10.3.5 Web 上的 JavaScript 模块

前面的章节以一种相对抽象的方式描述了 ES6 模块及其importexport声明。在本节和下一节中,我们将讨论它们在 Web 浏览器中的实际工作方式,如果你还不是一名经验丰富的 Web 开发人员,你可能会发现在阅读第十五章之后更容易理解本章的其余内容。

截至 2020 年初,使用 ES6 模块的生产代码仍然通常与类似 webpack 的工具捆绑在一起。这样做存在一些权衡之处,¹但总体上,代码捆绑往往能提供更好的性能。随着网络速度的增长和浏览器厂商继续优化他们的 ES6 模块实现,这种情况可能会发生变化。

尽管捆绑工具在生产中仍然可取,但在开发中不再需要,因为所有当前的浏览器都提供了对 JavaScript 模块的原生支持。请记住,模块默认使用严格模式,this不指向全局对象,并且顶级声明默认情况下不会在全局范围内共享。由于模块必须以与传统非模块代码不同的方式执行,它们的引入需要对 HTML 和 JavaScript 进行更改。如果你想在 Web 浏览器中原生使用import指令,你必须通过使用<script type="module">标签告诉 Web 浏览器你的代码是一个模块。

ES6 模块的一个很好的特性是每个模块都有一个静态的导入集合。因此,给定一个起始模块,Web 浏览器可以加载所有导入的模块,然后加载第一批模块导入的所有模块,依此类推,直到完整的程序被加载。我们已经看到import语句中的模块指示符可以被视为相对 URL。<script type="module">标签标记了模块化程序的起点。然而,它导入的模块都不应该在<script>标签中,而是按需作为常规 JavaScript 文件加载,并像常规 ES6 模块一样以严格模式执行。使用<script type="module">标签来定义模块化 JavaScript 程序的主入口点可以像这样简单:

<script type="module">import "./main.js";</script>

内联<script type="module">标签中的代码是 ES6 模块,因此可以使用export语句。然而,这样做没有任何意义,因为 HTML <script>标签语法没有提供任何定义内联模块名称的方式,因此,即使这样的模块导出一个值,也没有办法让另一个模块导入它。

带有type="module"属性的脚本会像带有defer属性的脚本一样加载和执行。代码加载会在 HTML 解析器遇到<script>标签时开始(在模块的情况下,这个代码加载步骤可能是一个递归过程,加载多个 JavaScript 文件)。但是代码执行直到 HTML 解析完成才开始。一旦 HTML 解析完成,脚本(模块和非模块)将按照它们在 HTML 文档中出现的顺序执行。

您可以使用async属性修改模块的执行时间,这对于模块和常规脚本的工作方式是相同的。async模块将在代码加载后立即执行,即使 HTML 解析尚未完成,即使这会改变脚本的相对顺序。

支持<script type="module">的 Web 浏览器也必须支持<script nomodule>。了解模块的浏览器会忽略带有nomodule属性的任何脚本并且不会执行它。不支持模块的浏览器将不识别nomodule属性,因此它们会忽略它并运行脚本。这为处理浏览器兼容性问题提供了一个强大的技术。支持 ES6 模块的浏览器还支持其他现代 JavaScript 特性,如类、箭头函数和for/of循环。如果您编写现代 JavaScript 并使用<script type="module">加载它,您知道它只会被支持的浏览器加载。作为 IE11 的备用方案(在 2020 年,实际上是唯一一个不支持 ES6 的浏览器),您可以使用类似 Babel 和 webpack 的工具将您的代码转换为非模块化的 ES5 代码,然后通过<script nomodule>加载这些效率较低的转换代码。

常规脚本和模块脚本之间的另一个重要区别与跨域加载有关。常规的<script>标签将从互联网上的任何服务器加载 JavaScript 代码文件,互联网的广告、分析和跟踪代码基础设施依赖于这一事实。但是<script type="module">提供了一种加强这一点的机会,模块只能从包含 HTML 文档的同一源加载,或者在适当的 CORS 标头放置以安全地允许跨源加载时才能加载。这种新的安全限制的一个不幸副作用是,它使得使用file: URL 在开发模式下测试 ES6 模块变得困难。使用 ES6 模块时,您可能需要设置一个静态 Web 服务器进行测试。

一些程序员喜欢使用文件扩展名.mjs来区分他们的模块化 JavaScript 文件和传统.js扩展名的常规非模块化 JavaScript 文件。对于 Web 浏览器和<script>标签来说,文件扩展名实际上是无关紧要的。(但 MIME 类型是相关的,因此如果您使用.mjs文件,您可能需要配置您的 Web 服务器以相同的 MIME 类型提供它们,如.js文件。)Node 对 ES6 的支持确实使用文件扩展名作为提示来区分它加载的每个文件使用的模块系统。因此,如果您编写 ES6 模块并希望它们能够在 Node 中使用,采用.mjs命名约定可能会有所帮助。

10.3.6 使用 import()进行动态导入

我们已经看到 ES6 的 importexport 指令是完全静态的,并且使 JavaScript 解释器和其他 JavaScript 工具能够在加载模块时通过简单的文本分析确定模块之间的关系,而无需实际执行模块中的任何代码。使用静态导入的模块,你可以确保导入到模块中的值在你的模块中的任何代码开始运行之前就已经准备好供使用。

在 Web 上,代码必须通过网络传输,而不是从文件系统中读取。一旦传输,该代码通常在相对较慢的移动设备上执行。这不是静态模块导入(需要在任何代码运行之前加载整个程序)有很多意义的环境。

Web 应用程序通常只加载足够的代码来渲染用户看到的第一页。然后,一旦用户有一些初步内容可以交互,它们就可以开始加载通常需要更多的代码来完成网页应用程序的其余部分。Web 浏览器通过使用 DOM API 将新的 <script> 标签注入到当前 HTML 文档中,使动态加载代码变得容易,而 Web 应用程序多年来一直在这样做。

尽管动态加载已经很久了,但它并不是语言本身的一部分。这在 ES2020 中发生了变化(截至 2020 年初,支持 ES6 模块的所有浏览器都支持动态导入)。你将一个模块规范传递给 import(),它将返回一个代表加载和运行指定模块的异步过程的 Promise 对象。当动态导入完成时,Promise 将“完成”(请参阅 第十三章 了解有关异步编程和 Promise 的完整细节),并产生一个对象,就像你使用静态导入语句的 import * as 形式一样。

因此,我们可以像这样静态导入“./stats.js”模块:

import * as stats from "./stats.js";

我们可以像这样动态导入并使用它:

import("./stats.js").then(stats => {
    let average = stats.mean(data);
})

或者,在一个 async 函数中(再次,你可能需要在理解这段代码之前阅读 第十三章),我们可以用 await 简化代码:

async analyzeData(data) {
    let stats = await import("./stats.js");
    return {
        average: stats.mean(data),
        stddev: stats.stddev(data)
    };
}

import() 的参数应该是一个模块规范,就像你会在静态 import 指令中使用的那样。但是使用 import(),你不受限于使用常量字符串文字:任何表达式只要以正确形式评估为字符串即可。

动态 import() 看起来像一个函数调用,但实际上不是。相反,import() 是一个操作符,括号是操作符语法的必需部分。这种不寻常的语法之所以存在是因为 import() 需要能够将模块规范解析为相对于当前运行模块的 URL,这需要一些实现魔法,这是不合法的放在 JavaScript 函数中的。在实践中,函数与操作符的区别很少有影响,但如果尝试编写像 console.log(import);let require = import; 这样的代码,你会注意到这一点。

最后,请注意动态 import() 不仅适用于 Web 浏览器。代码打包工具如 webpack 也可以很好地利用它。使用代码捆绑器的最简单方法是告诉它程序的主入口点,让它找到所有静态 import 指令并将所有内容组装成一个大文件。然而,通过策略性地使用动态 import() 调用,你可以将这个单一的庞大捆绑拆分成一组可以按需加载的较小捆绑。

10.3.7 import.meta.url

ES6 模块系统的最后一个特性需要讨论。在 ES6 模块中(但不在常规的 <script> 或使用 require() 加载的 Node 模块中),特殊语法 import.meta 指的是一个包含有关当前执行模块的元数据的对象。该对象的 url 属性是加载模块的 URL。(在 Node 中,这将是一个 file:// URL。)

import.meta.url 的主要用例是能够引用存储在与模块相同目录中(或相对于模块)的图像、数据文件或其他资源。URL() 构造函数使得相对 URL 相对于绝对 URL(如 import.meta.url)容易解析。例如,假设你编写了一个模块,其中包含需要本地化的字符串,并且本地化文件存储在与模块本身相同目录中的 l10n/ 目录中。你的模块可以使用类似这样的函数创建的 URL 加载其字符串:

function localStringsURL(locale) {
    return new URL(`l10n/${locale}.json`, import.meta.url);
}

10.4 总结

模块化的目标是允许程序员隐藏其代码的实现细节,以便来自各种来源的代码块可以组装成大型程序,而不必担心一个代码块会覆盖另一个的函数或变量。本章已经解释了三种不同的 JavaScript 模块系统:

  • 在 JavaScript 的早期,模块化只能通过巧妙地使用立即调用的函数表达式来实现。

  • Node 在 JavaScript 语言之上添加了自己的模块系统。Node 模块通过 require() 导入,并通过设置 Exports 对象的属性或设置 module.exports 属性来定义它们的导出。

  • 在 ES6 中,JavaScript 终于拥有了自己的模块系统,使用 importexport 关键字,而 ES2020 正在添加对使用 import() 进行动态导入的支持。

¹ 例如:经常进行增量更新并且用户频繁返回访问的 Web 应用程序可能会发现,使用小模块而不是大捆绑包可以更好地利用用户浏览器缓存,从而导致更好的平均加载时间。

第十一章:JavaScript 标准库

一些数据类型,如数字和字符串(第三章)、对象(第六章)和数组(第七章)对于 JavaScript 来说是如此基础,以至于我们可以将它们视为语言本身的一部分。本章涵盖了其他重要但不太基础的 API,可以被视为 JavaScript 的“标准库”:这些是内置于 JavaScript 中的有用类和函数,可供所有 Web 浏览器和 Node 中的 JavaScript 程序使用。¹

本章的各节相互独立,您可以按任意顺序阅读它们。它们涵盖了:

  • Set 和 Map 类,用于表示值的集合和从一个值集合到另一个值集合的映射。

  • 类型化数组(TypedArrays)等类似数组的对象,表示二进制数据的数组,以及用于从非数组二进制数据中提取值的相关类。

  • 正则表达式和 RegExp 类,定义文本模式,对文本处理很有用。本节还详细介绍了正则表达式语法。

  • Date 类用于表示和操作日期和时间。

  • Error 类及其各种子类的实例,当 JavaScript 程序发生错误时抛出。

  • JSON 对象,其方法支持对由对象、数组、字符串、数字和布尔值组成的 JavaScript 数据结构进行序列化和反序列化。

  • Intl 对象及其定义的类,可帮助您本地化 JavaScript 程序。

  • Console 对象,其方法以特别有用的方式输出字符串,用于调试程序和记录程序的行为。

  • URL 类简化了解析和操作 URL 的任务。本节还涵盖了用于对 URL 及其组件进行编码和解码的全局函数。

  • setTimeout()及相关函数用于指定在经过指定时间间隔后执行的代码。

本章中的一些部分,尤其是关于类型化数组和正则表达式的部分,由于您需要理解的重要背景信息较多,因此相当长。然而,其他许多部分很短:它们只是介绍一个新的 API 并展示其使用示例。

11.1 集合和映射

JavaScript 的 Object 类型是一种多功能的数据结构,可以用来将字符串(对象的属性名称)映射到任意值。当被映射的值是像true这样固定的值时,那么对象实际上就是一组字符串。

在 JavaScript 编程中,对象实际上经常被用作映射和集合,但由于限制为字符串并且对象通常继承具有诸如“toString”之类名称的属性,这使得使用起来有些复杂,通常这些属性并不打算成为映射或集合的一部分。

出于这个原因,ES6 引入了真正的 Set 和 Map 类,我们将在接下来的子章节中介绍。

11.1.1 Set 类

集合是一组值,类似于数组。但与数组不同,集合没有顺序或索引,并且不允许重复:一个值要么是集合的成员,要么不是成员;无法询问一个值在集合中出现多少次。

使用Set()构造函数创建一个 Set 对象:

let s = new Set();       // A new, empty set
let t = new Set([1, s]); // A new set with two members

Set()构造函数的参数不一定是数组:任何可迭代对象(包括其他 Set 对象)都是允许的:

let t = new Set(s);                  // A new set that copies the elements of s.
let unique = new Set("Mississippi"); // 4 elements: "M", "i", "s", and "p"

集合的size属性类似于数组的length属性:它告诉你集合包含多少个值:

unique.size        // => 4

创建集合时无需初始化。您可以随时使用add()delete()clear()添加和删除元素。请记住,集合不能包含重复项,因此向集合添加已包含的值不会产生任何效果:

let s = new Set();  // Start empty
s.size              // => 0
s.add(1);           // Add a number
s.size              // => 1; now the set has one member
s.add(1);           // Add the same number again
s.size              // => 1; the size does not change
s.add(true);        // Add another value; note that it is fine to mix types
s.size              // => 2
s.add([1,2,3]);     // Add an array value
s.size              // => 3; the array was added, not its elements
s.delete(1)         // => true: successfully deleted element 1
s.size              // => 2: the size is back down to 2
s.delete("test")    // => false: "test" was not a member, deletion failed
s.delete(true)      // => true: delete succeeded
s.delete([1,2,3])   // => false: the array in the set is different
s.size              // => 1: there is still that one array in the set
s.clear();          // Remove everything from the set
s.size              // => 0

关于这段代码有几个重要的要点需要注意:

  • add()方法接受一个参数;如果传递一个数组,它会将数组本身添加到集合中,而不是单独的数组元素。但是,add()始终返回调用它的集合,因此如果要向集合添加多个值,可以使用链式方法调用,如s.add('a').add('b').add('c');

  • delete()方法也仅一次删除单个集合元素。但是,与add()不同,delete()返回一个布尔值。如果您指定的值实际上是集合的成员,则delete()会将其删除并返回true。否则,它不执行任何操作并返回false

  • 最后,非常重要的是要理解集合成员是基于严格的相等性检查的,就像===运算符执行的那样。集合可以包含数字1和字符串"1",因为它认为它们是不同的值。当值为对象(或数组或函数)时,它们也被视为使用===进行比较。这就是为什么我们无法从此代码中的集合中删除数组元素的原因。我们向集合添加了一个数组,然后尝试通过向delete()方法传递一个不同的数组(尽管具有相同元素)来删除该数组。为了使其工作,我们必须传递对完全相同的数组的引用。

注意

Python 程序员请注意:这是 JavaScript 和 Python 集合之间的一个重要区别。Python 集合比较成员的相等性,而不是身份,但这样做的代价是 Python 集合只允许不可变成员,如元组,并且不允许将列表和字典添加到集合中。

在实践中,我们与集合最重要的事情不是向其中添加和删除元素,而是检查指定的值是否是集合的成员。我们使用has()方法来实现这一点:

let oneDigitPrimes = new Set([2,3,5,7]);
oneDigitPrimes.has(2)    // => true: 2 is a one-digit prime number
oneDigitPrimes.has(3)    // => true: so is 3
oneDigitPrimes.has(4)    // => false: 4 is not a prime
oneDigitPrimes.has("5")  // => false: "5" is not even a number

关于集合最重要的一点是它们被优化用于成员测试,无论集合有多少成员,has()方法都会非常快。数组的includes()方法也执行成员测试,但所需时间与数组的大小成正比,使用数组作为集合可能比使用真正的 Set 对象慢得多。

Set 类是可迭代的,这意味着您可以使用for/of循环枚举集合的所有元素:

let sum = 0;
for(let p of oneDigitPrimes) { // Loop through the one-digit primes
    sum += p;                  // and add them up
}
sum                            // => 17: 2 + 3 + 5 + 7

因为 Set 对象是可迭代的,您可以使用...扩展运算符将它们转换为数组和参数列表:

[...oneDigitPrimes]         // => [2,3,5,7]: the set converted to an Array
Math.max(...oneDigitPrimes) // => 7: set elements passed as function arguments

集合经常被描述为“无序集合”。然而,对于 JavaScript Set 类来说,这并不完全正确。JavaScript 集合是无索引的:您无法像数组那样请求集合的第一个或第三个元素。但是 JavaScript Set 类始终记住元素插入的顺序,并且在迭代集合时始终使用此顺序:插入的第一个元素将是首个迭代的元素(假设您尚未首先删除它),最近插入的元素将是最后一个迭代的元素。²

除了可迭代外,Set 类还实现了类似于数组同名方法的forEach()方法:

let product = 1;
oneDigitPrimes.forEach(n => { product *= n; });
product     // => 210: 2 * 3 * 5 * 7

数组的forEach()将数组索引作为第二个参数传递给您指定的函数。集合没有索引,因此 Set 类的此方法简单地将元素值作为第一个和第二个参数传递。