第 10 章 模块

模块化编程的目标是允许使用来自不同作者和源的代码模块来组装大型程序,并且即使出现了不同模块作者没有预料到的代码,所有这些代码也能正确运行。作为一个实际问题,模块化主要是关于封装或隐藏私有实现细节和保持全局名称空间整洁,以便模块不会意外地修改其他模块定义的变量、函数和类。

直到最近,JavaScript 还没有对模块的内置支持,在大型代码库上工作的程序员尽力使用类、对象和闭包的弱模块性。基于闭包的模块化在代码捆绑工具的支持下,实际使用中形成了一种基于 require() 函数的模块化形式,Node 采用了这种形式。基于 require() 的模块是 Node 编程环境的基本部分,但从未被作为 JavaScript 语言的正式部分采用。相反,ES6 使用 import 和 import 关键字定义模块。虽然 import 和 export 已经成为语言的一部分很多年了,但是它们只是最近才被 web 浏览器和 Node 实现。而且,作为一个实际问题,JavaScript 模块化仍然依赖于代码捆绑工具。

以下各节包括:

  • 使用类、对象和闭包自己做模块
  • 使用 require() 的 Node 模块
  • 使用 export、import 和 import() 的ES6模块

10.1 类、对象和闭包充当模块

尽管这可能很明显,但值得指出的是,类的重要特性之一是它们充当其方法的模块。回想一下示例 9-8。该示例定义了许多不同的类,所有这些类都有一个名为 has() 的方法。但是,在编写使用该示例中的多个 set 类的程序时没有问题:例如,SingletonSet 的 has() 实现不会覆盖 BitSet 的 has() 方法。

一个类的方法独立于其他不相关类的方法的原因是,每个类的方法都被定义为独立原型对象的属性。类是模块化的原因是对象是模块化的:在 JavaScript 对象中定义属性非常类似于声明变量,但是向对象添加属性不会影响程序的全局命名空间,也不会影响其他对象的属性。JavaScript 定义了很多数学函数和常量,但它们不是全局定义的,而是分组为 Math 全局对象的单个属性。同样的技术也可以用在示例 9-8 中。不使用 SingletonSet 和 BitSet 这样的名称定义全局类,这个示例可以编写为只有一个 Sets 全局对象,Sets 的属性引用各种类。然后,用户可以使用这个 Sets 库通过 Sets.Singleton 和 Sets.Bit 的名称来获取类的引用。

使用类和对象实现模块化是 JavaScript 编程中常见而有用的技术,但这还不够。特别是,它没有提供任何方法来隐藏模块内部的实现细节。再次考虑示例 9-8。如果我们将该示例作为一个模块来编写,也许我们会希望将各种抽象类保留在模块内部,只让具体的子类对模块的用户可用。同样,在 BitSet 类中,_valid() 和 _has() 方法是内部实用程序,不应该向类的用户公开它们。BitSet.bits 和 BitSet.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 代码文件转换为这种模块是相当机械的过程。所需要做的只是为 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)的工作方式,并且是对 require() 函数(如 Node 程序中使用的函数)的简单介绍。

10.2 Node 中的模块

In Node programming, it is normal to split programs into as many files as seems natural. These files of JavaScript code are assumed to all live on a fast filesystem. Unlike web browsers, which have to read files of JavaScript over a relatively slow network connection, there is no need or benefit to bundling a Node program into a single JavaScript file.

In Node, each file is an independent module with a private namespace. Constants, variables, functions, and classes defined in one file are private to that file unless the file exports them. And values exported by one module are only visible in another module if that module explicitly imports them.

Node modules import other modules with the require() function and export their public API by setting properties of the Exports object or by replacing the module.exportsobject entirely.

10.2.1 Node Exports

Node defines a global exports object that is always defined. If you are writing a Node module that exports multiple values, you can simply assign them to the properties of this object:

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));
};

Often, however, you want to define a module that exports only a single function or class rather than an object full of functions or classes. To do this, you simply assign the single value you want to export to module.exports:

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

The default value of module.exports is the same object that exports refers to. In the previous stats module, we could have assigned the mean function to module.exports.mean instead of exports.mean. Another approach with modules like the stats module is to export a single object at the end of the module rather than exporting functions one by one as you go:

// 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 Imports

A Node module imports another module by calling the require() function. The argument to this function is the name of the module to be imported, and the return value is whatever value (typically a function, class, or object) that module exports.

If you want to import a system module built in to Node or a module that you have installed on your system via a package manager, then you simply use the unqualified name of the module, without any “/” characters that would turn it into a filesystem path:

// 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");

When you want to import a module of your own code, the module name should be the path to the file that contains that code, relative to the current module’s file. It is legal to use absolute paths that begin with a / character, but typically, when importing modules that are part of your own program, the module names will begin with ./ or sometimes ../ to indicate that they are relative to the current directory or the parent directory. For example:

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

(You can also omit the .js suffix on the files you’re importing and Node will still find the files, but it is common to see these file extensions explicitly included.)

When a module exports just a single function or class, all you have to do is require it. When a module exports an object with multiple properties, you have a choice: you can import the entire object, or just import the specific properties (using destructuring assignment) of the object that you plan to use. Compare these two approaches:

// 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 风格模块

Node 中内置了带有 Exports 对象和 require() 函数的模块。 但是,如果要使用诸如 webpack 之类的捆绑工具来处理代码,则也可以将这种样式的模块用于要在网络浏览器中运行的代码。直到最近,这还是一种非常常用的做法,可能会看到很多基于 Web 的代码仍然这样做。

现在,JavaScript 具有自己的标准模块语法,但是使用捆绑程序的开发人员更喜欢将正式的 JavaScript 模块与 import 和 export 语句一起使用。

10.3 ES6 中的模块

ES6 给 JavaScript 添加了 import 和 export 关键字,并且最终支持真正的模块化,将其作为核心语言特性。ES6 模块化概念上和 Node 的模块化相同:每个文件是它们自己的模块,定义在文件中的常量、变量、函数和类是模块私有成员,除非它们是被显示导出。

首先,请注意,ES6 模块在某些重要方面也与常规 JavaScript “脚本”不同。最明显的区别是模块化本身:在常规脚本中,变量,函数和类的顶级声明在所有脚本共享的一个全局上下文中。模块每个文件都有其自己的专用上下文,并且可以使用 import 和 export 语句。但是模块和脚本之间也存在其他差异。ES6 模块内的代码(类似 ES6 类内定义的代码)将自动进入严格模式(请参见 §5.6.3)。这意味着,当使用 ES6 模块时,无需再写“use strict”。这意味着模块中的代码不能使用 with 语句或 arguments 对象或未声明的变量。ES6 模块甚至比严格模式稍微严格:在严格模式下,在作为函数调用的函数中,this 是 undefined。在模块中,this 在顶层代码中也是 undefined。(相比之下,Web 浏览器和 Node 中的脚本将 this 设置为全局对象。)

WEB 和 NODE 中的 ES6 模块

ES6模块在诸如 webpack 之类的代码打包器的帮助下已在 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 { Circle, degreesToRadians, PI };

这种语法看起来像导出关键字,后跟对象字面量(使用速记符号)。但在这种情况下,花括号实际上并不是定义对象字面量。此导出语法只需要在大括号中的标识符列表用逗号分隔。

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

export default class BitSet {
    // implementation omitted
}

默认导出比非默认导出更容易导入,因此,当只有一个导出值时,使用默认导出可以使模块的使用更容易。

带 export 的常规导出只能在具有名称的声明上进行。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 之类的代码捆绑工具不能满足对“裸模块说明符”的这种限制,可以将其轻松配置为在指定的库中查找裸模块。)未来版本 JavaScript 可能允许使用“裸模块说明符”,但目前不允许这样做。 如果要从与当前目录相同的目录中导入模块,只需在模块名称前放置“./”,然后从“./util.js”而不是“ util.js”导入。

到目前为止,我们仅考虑了从使用默认导出的模块中导入单个值的情况。要从导出多个值的模块导入值,我们使用略有不同的语法:

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

回想一下,默认导出不需要在定义它们的模块中具有名称。而是在导入这些值时提供一个本地名称。但是,模块的非默认导出在导出模块中具有名称,并且当我们导入这些值时,我们通过这些名称来引用它们。导出模块可以导出任意数量的命名值。导入语句引用模块,可以简单地通过在花括号中列出它们的名称来导入这些名称对应的值的任何子集。花括号使这种导入语句看起来像是一个解构赋值,而解构赋值实际上是很好的案例来解释导入的这种风格。花括号中的标识符都被提前到导入模块的顶部,并且行为如同常量。

有时,风格指南建议显示导入模块将使用的每个符号。但是,从定义许多导出的模块进行导入时,可以使用如下的 import 语句轻松导入所有内容:

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

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

模块通常定义一个默认导出或者多个命名导出。模块内同时使用导出和默认导出是合法的,但是不常见。但是当一个模块这样做了,可以通过下面这种方式将默认值和命名值通过一个导入语句导入:

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

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

import "./analytics.js";

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

请注意,即使对于具有导出的模块,也可以使用不导入任何内容的导入语法。如果模块定义的有用行为与导出的值无关,并且程序不需要任何导出的值,则仍可以用 . 导入模块,这仅用于默认导出。

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";

请注意,此代码中并未实际使用名称 mean 和 stddev。如果我们不选择再导出,而只是想从另一个模块中导出所有命名值,则可以使用通配符:

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

再导出语法允许重命名,就像常规的导入和导出语句一样。 假设我们要再导出 mean() 函数,但还要将该函数定义为 average()。 我们可以这样做:

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

所有的再导出示例中,“./stats/mean.js”和“./stats/stddev.js”模块都使用导出而不是默认导出来导出它们的函数。但是,实际上,由于这些模块仅具有单个导出,因此使用导出默认定义它们是更明智的。如果我们这样做,那么再导出语法会稍微复杂一点,因为它需要为未命名的默认导出定义一个名称。 我们可以这样做:

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

如果要从另一个模块中再导出命名符号作为模块的默认导出,则可以先进行导入,然后再默认导出,或者可以将以下两个语句组合在一起:

// 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 JavaScript Modules on the Web

The preceding sections have described ES6 modules and their import and export declarations in a somewhat abstract manner. In this section and the next, we’ll be discussing how they actually work in web browsers, and if you are not already an experienced web developer, you may find the rest of this chapter easier to understand after you have read Chapter 15.

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

As of early 2020, production code using ES6 modules is still generally bundled with a tool like webpack. There are trade-offs to doing this, [1] but on the whole, code bundling tends to give better performance. That may well change in the future as network speeds grow and browser vendors continue to optimize their ES6 module implementations.

截至 2020 年初,使用 ES6 模块的生产代码通常仍与 webpack 之类的工具捆绑在一起。 这样做是有折衷的 [1],但是总的来说,代码捆绑往往会提供更好的性能。随着网络速度的增长以及浏览器供应商继续优化其 ES6 模块的实现,将来这种情况可能会发生很大的变化。

Even though bundling tools may still be desirable in production, they are no longer required in development since all current browsers provide native support for JavaScript modules. Recall that modules use strict mode by default, this does not refer to a global object, and top-level declarations are not shared globally by default. Since modules must be executed differently than legacy non-module code, their introduction requires changes to HTML as well as JavaScript. If you want to natively use import directives in a web browser, you must tell the web browser that your code is a module by using a <script type="module"> tag.

即使在生产中仍可能需要捆绑工具,但由于当前所有的浏览器都为 JavaScript 模块提供了本机支持,因此在开发中不再需要捆绑工具。回想一下,默认情况下模块使用严格模式,this 不引用全局对象,并且顶级声明默认不全局共享。由于模块的执行方式必须不同于传统的非模块代码,因此其引入 requires 对 HTML 和 JavaScript 进行更改。如果要在 Web 浏览器本地使用 import 指令,则必须通过使用 <script type =“ module”> 标记来告知 Web 浏览器您的代码是模块。

One of the nice features of ES6 modules is that each module has a static set of imports. So given a single starting module, a web browser can load all of its imported modules and then load all of the modules imported by that first batch of modules, and so on, until a complete program has been loaded. We’ve seen that the module specifier in an import statement can be treated as a relative URL. A <script type="module"> tag marks the starting point of a modular program. None of the modules it imports are expected to be in <script> tags, however: instead, they are loaded on demand as regular JavaScript files and are executed in strict mode as regular ES6 modules. Using a <script type="module"> tag to define the main entry point for a modular JavaScript program can be as simple as this:

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

Code inside an inline <script type="module"> tag is an ES6 module, and as such can use the export statement. There is not any point in doing so, however, because the HTML <script> tag syntax does not provide any way to define a name for inline modules, so even if such a module does export a value, there is no way for another module to import it.

Scripts with the type="module" attribute are loaded and executed like scripts with the defer attribute. Loading of the code begins as soon as the HTML parser encounters the <script> tag (in the case of modules, this code-loading step may be a recursive process that loads multiple JavaScript files). But code execution does not begin until HTML parsing is complete. And once HTML parsing is complete, scripts (both modular and non) are executed in the order in which they appear in the HTML document.

You can modify the execution time of modules with the async attribute, which works the same way for modules that it does for regular scripts. An async module will execute as soon as the code is loaded, even if HTML parsing is not complete and even if this changes the relative ordering of the scripts.

Web browsers that support <script type="module"> must also support <script nomodule>. Browsers that are module-aware ignore any script with the nomodule attribute and will not execute it. Browsers that do not support modules will not recognize the nomodule attribute, so they will ignore it and run the script. This provides a powerful technique for dealing with browser compatibility issues. Browsers that support ES6 modules also support other modern JavaScript features like classes, arrow functions, and the for/of loop. If you write modern JavaScript and load it with <script type="module">, you know that it will only be loaded by browsers that can support it. And as a fallback for IE11 (which, in 2020, is effectively the only remaining browser that does not support ES6), you can use tools like Babel and webpack to transform your code into non-modular ES5 code, then load that less-efficient transformed code via <script nomodule>.

Another important difference between regular scripts and module scripts has to do with cross-origin loading. A regular <script> tag will load a file of JavaScript code from any server on the internet, and the internet’s infrastructure of advertising, analytics, and tracking code depends on that fact. But <script type="module"> provides an opportunity to tighten this up, and modules can only be loaded from the same origin as the containing HTML document or when proper CORS headers are in place to securely allow cross-origin loads. An unfortunate side effect of this new security restriction is that it makes it difficult to test ES6 modules in development mode using file: URLs. When using ES6 modules, you will likely need to set up a static web server for testing.

Some programmers like to use the filename extension .mjs to distinguish their modular JavaScript files from their regular, non-modular JavaScript files with the traditional .js extension. For the purposes of web browsers and <script> tags, the file extension is actually irrelevant. (The MIME type is relevant, however, so if you use .mjs files, you may need to configure your web server to serve them with the same MIME type as .js files.) Node’s support for ES6 does use the filename extension as a hint to distinguish which module system is used by each file it loads. So if you are writing ES6 modules and want them to be usable with Node, then it may be helpful to adopt the .mjs naming convention.

10.3.6 Dynamic Imports with import()

We’ve seen that the ES6 import and export directives are completely static and enable JavaScript interpreters and other JavaScript tools to determine the relationships between modules with simple text analysis while the modules are being loaded without having to actually execute any of the code in the modules. With statically imported modules, you are guaranteed that the values you import into a module will be ready for use before any of the code in your module begins to run.

On the web, code has to be transferred over a network instead of being read from the filesystem. And once transfered, that code is often executed on mobile devices with relatively slow CPUs. This is not the kind of environment where static module imports—which require an entire program to be loaded before any of it runs—make a lot of sense.

It is common for web applications to initially load only enough of their code to render the first page displayed to the user. Then, once the user has some preliminary content to interact with, they can begin to load the often much larger amount of code needed for the rest of the web app. Web browsers make it easy to dynamically load code by using the DOM API to inject a new <script> tag into the current HTML document, and web apps have been doing this for many years.

Although dynamic loading has been possible for a long time, it has not been part of the language itself. That changes with the introduction of import() in ES2020 (as of early 2020, dynamic import is supported by all browsers that support ES6 modules). You pass a module specifier to import() and it returns a Promise object that represents the asynchronous process of loading and running the specified module. When the dynamic import is complete, the Promise is “fulfilled” (see Chapter 13 for complete details on asynchronous programming and Promises) and produces an object like the one you would get with the import * as form of the static import statement.

So instead of importing the “./stats.js” module statically, like this:

import * as stats from "./stats.js";
we might import it and use it dynamically, like this:

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

Or, in an async function (again, you may need to read Chapter 13 before you’ll understand this code), we can simplify the code with await:

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

The argument to import() should be a module specifier, exactly like one you’d use with a static import directive. But with import(), you are not constrained to use a constant string literal: any expression that evaluates to a string in the proper form will do.

Dynamic import() looks like a function invocation, but it actually is not. Instead, import() is an operator and the parentheses are a required part of the operator syntax. The reason for this unusual bit of syntax is that import() needs to be able to resolve module specifiers as URLs relative to the currently running module, and this requires a bit of implementation magic that would not be legal to put in a JavaScript function. The function versus operator distinction rarely makes a difference in practice, but you’ll notice it if you try writing code like console.log(import); or let require = import;.

Finally, note that dynamic import() is not just for web browsers. Code-packaging tools like webpack can also make good use of it. The most straightforward way to use a code bundler is to tell it the main entry point for your program and let it find all the static import directives and assemble everything into one large file. By strategically using dynamic import() calls, however, you can break that one monolithic bundle up into a set of smaller bundles that can be loaded on demand.

10.3.7 import.meta.url

There is one final feature of the ES6 module system to discuss. Within an ES6 module (but not within a regular <script>or a Node module loaded with require()), the special syntax import.meta refers to an object that contains metadata about the currently executing module. The url property of this object is the URL from which the module was loaded. (In Node, this will be a file:// URL.)

The primary use case of import.meta.url is to be able to refer to images, data files, or other resources that are stored in the same directory as (or relative to) the module. The URL() constructor makes it easy to resolve a relative URL against an absolute URL like import.meta.url. Suppose, for example, that you have written a module that includes strings that need to be localized and that the localization files are stored in an l10n/ directory, which is in the same directory as the module itself. Your module could load its strings using a URL created with a function, like this:

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

10.4 Summary

The goal of modularity is to allow programmers to hide the implementation details of their code so that chunks of code from various sources can be assembled into large programs without worrying that one chunk will overwrite functions or variables of another. This chapter has explained three different JavaScript module systems:

  • In the early days of JavaScript, modularity could only be achieved through the clever use of immediately invoked function expressions.
  • Node added its own module system on top of the JavaScript language. Node modules are imported with require() and define their exports by setting properties of the Exports object, or by setting the module.exports property.
  • In ES6, JavaScript finally got its own module system with import and export keywords, and ES2020 is adding support for dynamic imports with import().

  1. For example: web apps that have frequent incremental updates and users who make frequent return visits may find that using small modules instead of large bundles can result in better average load times because of better utilization of the user’s browser cache.