第10章 模块

  • 10.1 基于类、对象和闭包的模块
  • 10.1.1 基于闭包的自动化模块
  • 10.2 Node中的模块
  • 10.2.1 Node导出
  • 10.2.2 Node导入
  • 10.2.3 Web上Node风格的模块
  • 10.3 ES6中的模块
  • 10.3.1 ES6导出
  • 10.3.2 ES6导入
  • 10.3.3 导入导出重命名
  • 10.3.4 重导出
  • 10.3.5 Web上的JavaScript模块
  • 10.3.6 使用import()进行动态导入
  • 10.3.7 import.meta.url
  • 10.4 总结


模块化编程的目标是允许使用来自不同作者和源代码的模块来组装大型程序,并且即使有些代码在某些模块作者并不能预料到的情况下不存在时,仍然都能正确运行。实际上,模块化主要是封装或隐藏私有实现细节,并保持全局命名空间整洁,这样模块就不会意外地修改由其他模块定义的变量、函数和类。

直到最近,JavaScript还没有对模块的内置支持,在大型代码库中工作的程序员尽力利用类、对象和闭包提供的弱模块性。基于闭包的模块化,在代码打包工具的支持下,形成了一种实用的基于require()函数的模块化形式,Node采用了这种方法。基于require()的模块是Node编程环境的基本组成部分,但从未作为JavaScript语言的正式部分被采用。相反,ES6使用import和export关键字定义模块。尽管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.Singleton和Sets.Bit。

在JavaScript编程中,使用类和对象实现模块化是一种常见且有用的技术,但这还远远不够。特别是,它没有为我们提供任何隐藏模块内部实现细节的方法。再考虑一下例9-8,如果我们将该示例作为一个模块来编写,那么我们可能希望将各种抽象类保留在模块内部,只让模块的用户可以使用具体的子类。类似地,在BitSet类中,_valid()和_has()方法是内部实用程序,不应该真正向类的用户公开。以及BitSet.bits以及BitSet.masks的实现细节最好隐藏起来。

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

const BitSet = (function () { // 将BitSet设置为此函数的返回值
    // 此处显示私有实现详细信息
    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]);
    // 模块的公共API只是BitSet类,我们在这里定义并返回。
    // 类可以使用上面定义的私有函数和常量,但是它们将对类的用户隐藏
    return class BitSet extends AbstractWritableSet {
        // ... 实现省略 ...
    };
}());

当模块中有一个以上的项时,这种模块化方法就变得更有意思了。例如,以下代码定义了一个小型统计模块,该模块导出mean()和stddev()函数,同时隐藏实现细节:

// 这就是我们如何定义stats模块的方法
const stats = (function () {
    // 模块专用的实用程序函数
    const sum = (x, y) => x + y;
    const square = x => x * x;
    // 将要导出的公共函数
    function mean(data) {
        return data.reduce(sum) / data.length;
    }
    // 将要导出的公共函数
    function stddev(data) {
        let m = mean(data);
        return Math.sqrt(
            data.map(x => x - m).map(square).reduce(sum) / (data.length - 1)
        );
    }
    // 我们将公共函数导出为对象的属性
    return { mean, stddev };
}());
// 下面是我们如何使用这个模块
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 = {};
    // sets.js文件的内容如下:
    exports.BitSet = class BitSet { ... };
    return exports;
}());
modules["stats.js"] = (function () {
    const exports = {};
    // stats.js文件的内容如下:
    const sum = (x, y) => x + y;
    const square = x = > x * x;
    exports.mean = function (data) { ... };
    exports.stddev = function (data) { ... };
    return exports;
}());

将模块打包到一个文件中(如前一个示例所示),您可以想象编写如下代码来使用这些模块:

// 获取我们需要的模块(或模块内容)的引用
const stats = require("stats.js");
const BitSet = require("sets.js").BitSet;
// 现在使用这些模块编写代码
let s = new BitSet(100);
s.insert(10);
s.insert(20);
s.insert(30);
let average = stats.mean([...s]); // 平均值是20

这段代码是web浏览器的代码打包工具(如webpack和packet)如何工作的一个粗略的草图,同时也是对require()函数的简单介绍,就像在Node程序中使用的函数一样。

10.2 Node中的模块

在Node编程中,通常会将程序拆分成看起来很自然的多个文件。假设这些JavaScript代码文件都位于一个访问快速的文件系统中。与web浏览器不同,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 {
	// 实现省略
};

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

// 定义所有的函数,公共的和私有的
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));
};
// 现在只导出公有的
module.exports = { mean, stddev };

10.2.2 Node导入

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

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

// 这些模块内置在Node中
const fs = require("fs"); // 内置的文件系统模块
const http = require("http"); // 内置的HTTP模块

// Express HTTP服务器框架是一个第三方模块。
// 它不是Node的一部分,而是在本地安装的
const express = require("express");

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

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

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

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

// 导入整个stats对象及其所有函数
const stats = require('./stats.js');

// 我们虽然导入了多于我们需要的函数,
// 但是它们被整齐地组织到一个方便的“stats”名称空间中。
let average = stats.mean(data);

// 或者,我们可以使用惯用的解构赋值来直接将
// 我们想要的函数导入到本地名称空间中:
const { stddev } = require('./stats.js');

// 这很好,也很简洁,但是如果没有‘stats’前缀作为stddev()函数的名称空间,
// 就会丢失一些上下文。
let sd = stddev(data);

10.2.3 Web上Node风格的模块

带有Exports对象和require()函数的模块被内置于Node中。但是如果您愿意使用像webpack这样的打包工具来处理代码,那么也可以使用这种风格的模块来处理那些在web浏览器中运行的代码。直到最近,这还是一件很常见的事情,您可能会看到许多基于web的代码仍然这样做。

现在JavaScript有了自己的标准模块语法,但是,使用打包工具的开发人员更可能使用带有import和export语句的官方JavaScript模块。

10.3 ES6中的模块

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

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

Web和Node中的ES6模块
在webpack等代码打包器的帮助下,ES6模块已经在web上使用了多年,这些代码打包器将JavaScript代码的独立模块组合成适合包含在web页面中的大型非模块化包。然而,在撰写本文时,ES6模块最终被Internet Explorer以外的所有web浏览器原生支持。当本机使用时,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 {
	// 实现省略
}

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

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

模块有一组常规导出和一个默认导出是合法的,但不太常见。如果模块具有默认导出,则只能有一个。

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

10.3.2 ES6导入

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

import BitSet from './bitset.js';

这是import关键字,后跟一个标识符,接着是from关键字,然后是一个字符串文本,它命名了要导入其默认导出的模块。指定模块的默认导出值将成为当前模块中指定标识符的值。

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

导入的模块名是由单引号或双引号中的常量字符串文本来指定。(不能使用值为字符串的变量或其他表达式,也不能在反撇号内使用字符串,因为模板文本可以插入变量,并且不总是有常量值。)在web浏览器中,此字符串被解释为相对于执行导入的模块位置的URL。(在Node中,或者在使用打包工具时,字符串被解释为相对于当前模块的文件名,但这在实践中几乎没有区别。)模块说明符字符串必须是以“/”开头的绝对路径,或者是以“./”或“…/”开头的相对路径,或者是包含协议和主机名的完整URL。ES6规范不允许不合格的模块说明符字符串如“util.js“,因为它是用来命名与当前模块处于相同目录的模块还是安装在某个特定位置的某种系统模块,这是不明确的。(像webpack这样的代码打包工具不支持这种针对“裸模块说明符”的限制,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()。

模块通常定义一个默认导出或多个命名导出。模块同时使用export和export default是合法的,但不常见。但当模块执行此操作时,您可以使用如下import语句导入默认值和命名值:

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

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

import "./analytics.js";

这样的模块在第一次导入时运行。(以及随后的导入不起任何作用)一个只定义函数的模块只有在导出其中至少一个函数时才有用。但是如果一个模块运行一些代码,那么即使没有符号也可以导入。web应用程序的分析模块可以运行代码来注册各种事件处理程序,然后使用这些事件处理程序在适当的时间将测量数据发送回服务器。这个模块是自包含的,不需要导出任何东西,但是我们仍然需要导入它,这样它才能真正作为程序的一部分运行。

请注意,您可以使用此无导入的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 重导出

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

然而,即使我们已经在各个模块中定义了这些统计函数,我们也可能期望有很多程序同时需要这两个函数,并希望使用一个方便的“./stats.js“模块,他们可以在一行中同时导入这两个模块。

考虑到各自实现都在单独的文件中,定义了”./stats.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";

请注意,名称means和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而不是export default导出它们的函数。然而,实际上,由于这些模块只有一个导出,所以用export default定义它们是有意义的。如果我们已经这样做了,那么重新导出语法就稍微复杂一些,因为它需要为未命名的默认导出定义一个名称。我们可以这样做:

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

如果要将另一个模块中的命名符号重新导出为模块的默认导出,可以先执行导入,然后再执行导出默认值,或者可以将这两个语句组合起来,如下所示:

//从./stats.js导入mean()函数,导出并使其成为默认模块
export { mean as default } from "./stats.js"

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

// 这个average.js模块只需重新导出stats/mean.js的默认导出
export { default } from "./stats/mean.js"

10.3.5 Web上的JavaScript模块

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

到2020年初,使用ES6模块的生产代码通常仍与webpack等工具打包在一起。这样做是有权衡的1,但总的来说,代码打包往往会提供更好的性能。随着网络速度的增长和浏览器供应商不断优化其ES6模块的实现,这种情况在未来可能会发生变化。

尽管打包工具在生产中可能仍然是可取的,但在开发中不再需要它们,因为当前所有浏览器都提供对JavaScript模块的本地支持。回想一下,模块默认使用严格模式,this不引用全局对象,顶级声明默认情况下不全局共享。由于模块的执行方式必须与传统的非模块代码不同,因此引入模块需要对HTML和JavaScript进行更改。如果要在web浏览器中以本地方式使用导入指令,则必须使用<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属性修改模块的执行时间,该属性对模块的工作方式与对常规脚本的工作方式相同。一旦加载代码完成,异步模块就会执行,即使HTML解析没有完成,即使这改变了脚本的相对顺序。

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

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

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

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

我们已经看到,ES6导入和导出指令是完全静态的,它使JavaScript解释器和其他JavaScript工具能够在加载模块时通过简单的文本分析来确定模块之间的关系,而不必实际执行模块中的任何代码。对于静态导入的模块,可以保证导入到模块中的值在模块中的任何代码开始运行之前就可以使用了。

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

web应用程序通常只加载足够的代码来呈现显示给用户的第一个页面。然后,一旦用户有了一些要交互的初步内容,他们就可以开始加载web应用程序其余部分所需的大量代码。Web浏览器通过使用DOM API将一个新的<script>标签注入到当前的HTML文档中,从而使动态加载代码变得容易,Web应用程序多年来一直在这样做。

虽然动态加载已经有很长一段时间了,但它并不是语言本身的一部分。这种情况随着ES2020中import()的引入而改变(到2020年初,所有支持ES6模块的浏览器都支持动态导入)。将模块说明符传递给import()并返回一个Promise对象,该对象表示加载和运行指定模块的异步过程。当动态导入完成后,Promise被“实现”(有关异步编程和Promise的完整详细信息,请参阅第13章),并生成一个对象,该对象与import*作为静态导入语句的形式一样。

所以不要像这样静态地导入"./stats.js"模块:

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

我们可以导入并动态使用它,如下所示:

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

或者,在异步函数中(在您理解此代码之前,您可能需要阅读第13章),我们可以通过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()调用,您可以将一个整体包分解成一组更小的包,这些包可以按需加载。

10.3.7 import.meta.url

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

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

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

10.4 总结

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

  • 在JavaScript的早期,模块化只能通过巧妙地使用立即调用的函数表达式来实现。
  • Node在JavaScript语言的基础上添加了自己的模块系统。使用require()导入Node模块,并通过设置Exports对象的属性或通过设置module.exports属性定义导出。
  • 在ES6中,JavaScript终于有了自己的带有import和export关键字的模块系统,ES2020也增加了对import()动态导入的支持。

  1. 例如:具有频繁增量更新的web应用程序和频繁回访的用户可能会发现,使用小模块而不是大捆绑包可以获得更好的平均加载时间,因为用户的浏览器缓存利用率更好。 ↩︎