JavaScript 模块

本指南将为你提供开始使用 JavaScript 模块语法所需的一切知识。

模块背景

JavaScript 程序最初都相当小——早期的大部分用途是处理独立的脚本任务,在需要时为网页提供一些交互性,因此通常不需要大型脚本。快进几年,我们现在有了在浏览器中运行的完整应用程序,其中包含大量 JavaScript,同时 JavaScript 也被用于其他环境(例如 Node.js)。

复杂的项目需要一种机制来将 JavaScript 程序拆分成独立的模块,这些模块可以在需要时导入。Node.js 很早就具备了这种能力,并且有许多 JavaScript 库和框架支持模块化使用(例如,其他基于 CommonJSAMD 的模块系统,如 RequireJSwebpackBabel)。

所有现代浏览器都原生支持模块功能,无需转译。这只会是一件好事——浏览器可以优化模块的加载,使其比使用库并进行所有额外的客户端处理和额外的往返更有效率。但这并不会让 webpack 这样的打包工具过时——打包工具在将代码分割成合理大小的块方面仍然做得很好,并且能够进行其他优化,如代码压缩、死代码消除和摇树优化(tree-shaking)。

示例介绍

为了演示模块的用法,我们创建了一组示例,你可以在 GitHub 上找到。这些示例演示了一组模块,它们在网页上创建一个 <canvas> 元素,然后在画布上绘制不同的形状(并报告相关信息)。

这些示例相当简单,但为了清晰地演示模块,我们特意保持了简洁。

备注: 如果你想下载这些示例并在本地运行它们,你需要通过一个本地 Web 服务器来运行。

基本示例结构

在我们的第一个示例中(参见 basic-modules),文件结构如下:

index.html
main.js
modules/
    canvas.js
    square.js

备注: 本指南中的所有示例都基本采用相同的结构;你应该会开始对上述结构感到熟悉。

modules 目录下的两个模块描述如下:

  • canvas.js — 包含与设置 canvas 相关的功能

    • create() — 在一个具有指定 ID 的包装器 <div> 内部,创建一个具有指定 widthheight 的 canvas,该包装器本身被附加到一个指定的父元素内。返回一个包含 canvas 的 2D 上下文和包装器 ID 的对象。
    • createReportList() — 创建一个无序列表,附加到指定的包装元素内,可用于输出报告数据。返回列表的 ID。
  • square.js — 包含

    • name — 一个包含字符串“square”的常量。
    • draw() — 在指定的 canvas 上绘制一个正方形,具有指定的大小、位置和颜色。返回一个包含正方形大小、位置和颜色的对象。
    • reportArea() — 根据给定的边长,将正方形的面积写入指定的报告列表。
    • reportPerimeter() — 根据给定的边长,将正方形的周长写入指定的报告列表。

题外话 — .mjs 与 .js

在本文中,我们对模块文件使用了 .js 扩展名,但在其他资源中,你可能会看到使用 .mjs 扩展名。例如,V8 的文档就推荐这样做。给出的理由是:

  • 这有利于清晰度,即清楚地表明哪些文件是模块,哪些是常规 JavaScript。
  • 它能确保你的模块文件被 Node.js 等运行时和 Babel 等构建工具解析为模块。

然而,我们决定继续使用 .js,至少目前是这样。为了让模块在浏览器中正常工作,你需要确保你的服务器在提供这些文件时,带有一个包含 JavaScript MIME 类型的 Content-Type 标头,例如 text/javascript。否则,你会收到一个严格的 MIME 类型检查错误,类似于“服务器响应了非 JavaScript MIME 类型”,浏览器将不会运行你的 JavaScript。大多数服务器已经为 .js 文件设置了正确的类型,但尚未为 .mjs 文件设置。已经能正确提供 .mjs 文件的服务器包括 GitHub Pages 和 Node.js 的 http-server

如果你已经在使用这样的环境,或者你虽然没有使用但清楚自己在做什么并且有权限(即你可以配置你的服务器为 .mjs 文件设置正确的 Content-Type),这是可以的。但是,如果你无法控制提供文件的服务器,或者像我们在这里一样发布文件供公众使用,这可能会引起困惑。

出于学习和可移植性的目的,我们决定坚持使用 .js

如果你真的看重使用 .mjs 来区分模块和“普通”JavaScript 文件的清晰度,但又不想遇到上述问题,你可以在开发过程中使用 .mjs,并在构建步骤中将它们转换为 .js

同样值得注意的是:

  • 有些工具可能永远不会支持 .mjs
  • 如下文所示,<script type="module"> 属性用于指明指向的是一个模块。

导出模块功能

要访问模块功能,你首先要做的是导出它们。这是通过 export 语句完成的。

最简单的方法是将其放在任何你想从模块中导出的项目前面,例如:

js
export const name = "square";

export function draw(ctx, length, x, y, color) {
  ctx.fillStyle = color;
  ctx.fillRect(x, y, length, length);

  return { length, x, y, color };
}

你可以导出函数、varletconst,以及——我们稍后会看到的——类。它们必须是顶层项目:例如,你不能在函数内部使用 export

一个更方便的导出所有项目的方法是在模块文件末尾使用单个 export 语句,后跟一个用大括号包裹的、逗号分隔的你想导出的功能列表。例如:

js
export { name, draw, reportArea, reportPerimeter };

将功能导入脚本

一旦你从模块中导出了一些功能,你就需要将它们导入到你的脚本中才能使用。最简单的方法如下:

js
import { name, draw, reportArea, reportPerimeter } from "./modules/square.js";

使用 import 语句,后跟一个用大括号包裹的、逗号分隔的你想导入的功能列表,然后是关键字 from,最后是模块说明符

模块说明符提供一个字符串,JavaScript 环境可以将其解析为模块文件的路径。在浏览器中,这可能是一个相对于站点根目录的路径,对于我们的 basic-modules 示例来说,就是 /js-examples/module-examples/basic-modules。然而,我们在这里使用了点(.)语法来表示“当前位置”,后跟我们要查找的文件的相对路径。这比每次都写出完整的绝对路径要好得多,因为相对路径更短,并且使 URL 具有可移植性——即使你将示例移动到站点层次结构中的不同位置,它仍然可以工作。

所以例如:

bash
/js-examples/module-examples/basic-modules/modules/square.js

变成了:

bash
./modules/square.js

你可以在 main.js 中看到这样的代码行。

备注: 在某些模块系统中,你可以使用像 modules/square 这样的模块说明符,它既不是相对路径也不是绝对路径,并且没有文件扩展名。如果预先定义了导入映射表,这种类型的说明符可以在浏览器环境中使用。

一旦你将功能导入到你的脚本中,你就可以像它们在同一个文件中定义的一样使用它们。以下内容位于 main.js 的 import 行之后:

js
const myCanvas = create("myCanvas", document.body, 480, 320);
const reportList = createReportList(myCanvas.id);

const square = draw(myCanvas.ctx, 50, 50, 100, "blue");
reportArea(square.length, reportList);
reportPerimeter(square.length, reportList);

备注: 导入的值是所导出功能的只读视图。与 const 变量类似,你不能重新分配导入的变量,但仍然可以修改对象值的属性。该值只能由导出它的模块重新分配。有关示例,请参阅 import 参考

使用导入映射表导入模块

上面我们看到了浏览器如何使用模块说明符导入模块,该说明符可以是一个绝对 URL,也可以是使用文档的基础 URL 解析的相对 URL:

js
import { name as circleName } from "https://example.com/shapes/circle.js";
import { name as squareName, draw } from "./shapes/square.js";

导入映射表允许开发者在导入模块时,在模块说明符中指定几乎任何他们想要的文本;映射表提供了一个相应的值,当模块 URL 被解析时,该值将替换该文本。

例如,下面导入映射表中的 imports 键定义了一个“模块说明符映射”JSON 对象,其中属性名可以用作模块说明符,当浏览器解析模块 URL 时,相应的值将被替换。这些值必须是绝对或相对 URL。相对 URL 将使用包含导入映射表的文档的基础 URL解析为绝对 URL 地址。

html
<script type="importmap">
  {
    "imports": {
      "shapes": "./shapes/square.js",
      "shapes/square": "./modules/shapes/square.js",
      "https://example.com/shapes/square.js": "./shapes/square.js",
      "https://example.com/shapes/": "/shapes/square/",
      "../shapes/square": "./shapes/square.js"
    }
  }
</script>

导入映射表使用一个 JSON 对象<script> 元素中定义,该元素的 type 属性设置为 importmap。请注意,导入映射表仅适用于该文档——规范并未涵盖如何在 worker 或 worklet 上下文中应用导入映射表。

有了这个映射表,你现在就可以使用上面的属性名作为模块说明符。如果模块说明符键的末尾没有斜杠,则会匹配并替换整个模块说明符键。例如,下面我们匹配了裸模块名,并将一个 URL 重新映射到另一个路径。

js
// Bare module names as module specifiers
import { name as squareNameOne } from "shapes";
import { name as squareNameTwo } from "shapes/square";

// Remap a URL to another URL
import { name as squareNameThree } from "https://example.com/shapes/square.js";

如果模块说明符的末尾有斜杠,那么其值也必须有斜杠,并且该键被匹配为“路径前缀”。这允许重映射一整类 URL。

js
// Remap a URL as a prefix ( https://example.com/shapes/)
import { name as squareNameFour } from "https://example.com/shapes/moduleshapes/square.js";

一个导入映射表中的多个键可能都能有效匹配一个模块说明符。例如,一个模块说明符 shapes/circle/ 可能与模块说明符键 shapes/shapes/circle/ 都匹配。在这种情况下,浏览器将选择最具体(最长)的匹配模块说明符键。

导入映射表允许使用裸模块名(如在 Node.js 中)导入模块,也可以模拟从包中导入模块,无论是否带有文件扩展名。虽然上面没有展示,但它们还允许根据导入模块的脚本路径来导入特定版本的库。总的来说,它们让开发者能够编写更符合人体工程学的导入代码,并使管理网站使用的模块的不同版本和依赖关系变得更容易。这可以减少在浏览器和服务器上使用相同 JavaScript 库所需的工作量。

以下各节将详细介绍上述的各种功能。

特性检测

你可以使用静态方法 HTMLScriptElement.supports()(该方法本身得到了广泛支持)来检查对导入映射表的支持情况:

js
if (HTMLScriptElement.supports?.("importmap")) {
  console.log("Browser supports import maps.");
}

以裸名称导入模块

在一些 JavaScript 环境中,比如 Node.js,你可以对模块说明符使用裸名称。这是因为环境可以将模块名称解析到文件系统中的一个标准位置。例如,你可能会使用以下语法来导入“square”模块。

js
import { name, draw, reportArea, reportPerimeter } from "square";

要在浏览器上使用裸名称,你需要一个导入映射表,它为浏览器提供了将模块说明符解析为 URL 所需的信息(如果 JavaScript 尝试导入一个无法解析为模块位置的模块说明符,它将抛出一个 TypeError)。

下面你可以看到一个定义了 square 模块说明符键的映射表,在这种情况下,它映射到一个相对地址值。

html
<script type="importmap">
  {
    "imports": {
      "square": "./shapes/square.js"
    }
  }
</script>

有了这个映射表,我们现在可以在导入模块时使用裸名称了:

js
import { name as squareName, draw } from "square";

重映射模块路径

模块说明符映射条目中,如果说明符键及其关联值都以斜杠(/)结尾,则可以作为路径前缀使用。这允许将一整套导入 URL 从一个位置重映射到另一个位置。它还可以用来模拟处理“包和模块”,就像你在 Node 生态系统中可能看到的那样。

备注: 末尾的 / 表示模块说明符键可以作为模块说明符的一部分被替换。如果没有这个斜杠,浏览器将只匹配(和替换)整个模块说明符键。

模块包

下面的 JSON 导入映射表定义将 lodash 映射为一个裸名称,并将模块说明符前缀 lodash/ 映射到路径 /node_modules/lodash-es/(相对于文档基础 URL 解析):

json
{
  "imports": {
    "lodash": "/node_modules/lodash-es/lodash.js",
    "lodash/": "/node_modules/lodash-es/"
  }
}

通过这个映射,你可以使用裸名称导入整个“包”,也可以使用路径映射导入其中的模块:

js
import _ from "lodash";
import fp from "lodash/fp.js";

可以不带 .js 文件扩展名导入上面的 fp,但你需要为该文件创建一个裸模块说明符键,例如 lodash/fp,而不是使用路径。对于一个模块来说,这可能是合理的,但如果你想导入许多模块,扩展性就很差。

通用 URL 重映射

模块说明符键不必是路径——它也可以是绝对 URL(或像 ./..// 这样的类 URL 相对路径)。如果你想将一个具有绝对路径的模块重映射到你自己的本地资源,这可能会很有用。

json
{
  "imports": {
    "https://www.unpkg.com/moment/": "/node_modules/moment/"
  }
}

用于版本管理的范围化模块

像 Node 这样的生态系统使用 npm 等包管理器来管理模块及其依赖。包管理器确保每个模块都与其他模块及其依赖项分离开来。因此,虽然一个复杂的应用程序可能在模块图的不同部分多次包含同一模块的不同版本,但用户无需考虑这种复杂性。

备注: 你也可以使用相对路径来实现版本管理,但这并不是最佳选择,因为除其他外,这会强制你的项目采用特定结构,并阻止你使用裸模块名。

导入映射表同样允许你在应用程序中拥有多个版本的依赖项,并使用相同的模块说明符来引用它们。你可以通过 scopes 键来实现这一点,它允许你提供根据执行导入的脚本路径来使用的模块说明符映射。下面的示例演示了这一点。

json
{
  "imports": {
    "cool-module": "/node_modules/cool-module/index.js"
  },
  "scopes": {
    "/node_modules/dependency/": {
      "cool-module": "/node_modules/some/other/location/cool-module/index.js"
    }
  }
}

有了这个映射,如果一个 URL 包含 /node_modules/dependency/ 的脚本导入了 cool-module,那么将使用位于 /node_modules/some/other/location/cool-module/index.js 的版本。如果在范围化映射中没有匹配的范围,或者匹配的范围不包含匹配的说明符,则会使用 imports 中的映射作为备用。例如,如果从一个非匹配范围路径的脚本中导入 cool-module,那么将使用 imports 中的模块说明符映射,映射到位于 /node_modules/cool-module/index.js 的版本。

请注意,用于选择范围的路径不影响地址的解析方式。映射路径中的值不必与范围路径匹配,相对路径仍然是相对于包含导入映射表的脚本的基础 URL 进行解析的。

与模块说明符映射一样,你可以有多个范围键,这些键可能包含重叠的路径。如果多个范围与引用者 URL 匹配,则首先检查最具体的范围路径(最长的范围键)以查找匹配的说明符。如果没有匹配的说明符,浏览器将回退到下一个最具体的匹配范围路径,依此类推。如果在任何匹配的范围中都没有匹配的说明符,浏览器将在 imports 键中的模块说明符映射中检查匹配项。

通过映射哈希文件名来改善缓存

网站使用的脚本文件通常带有哈希文件名以简化缓存。这种方法的缺点是,如果一个模块发生变化,任何使用其哈希文件名导入它的模块也需要被更新/重新生成。这可能会导致一系列的更新,浪费网络资源。

导入映射表为这个问题提供了一个方便的解决方案。应用程序和脚本不再依赖于特定的哈希文件名,而是依赖于模块名的非哈希版本(地址)。然后,像下面这样的导入映射表提供了到实际脚本文件的映射。

json
{
  "imports": {
    "main_script": "/node/srcs/application-fg7744e1b.js",
    "dependency_script": "/node/srcs/dependency-3qn7e4b1q.js"
  }
}

如果 dependency_script 发生变化,那么其文件名中包含的哈希值也会改变。在这种情况下,我们只需要更新导入映射表来反映模块名称的变更。我们不必更新任何依赖于它的 JavaScript 代码的源文件,因为 import 语句中的说明符没有改变。

加载非 JavaScript 资源

统一模块架构带来的一个令人兴奋的特性是能够将非 JavaScript 资源作为模块加载。例如,你可以将 JSON 作为 JavaScript 对象导入,或者将 CSS 作为 CSSStyleSheet 对象导入。

你必须明确声明你正在导入的资源类型。默认情况下,浏览器假定资源是 JavaScript,如果解析出的资源是其他类型,则会抛出错误。要导入 JSON、CSS 或其他类型的资源,请使用导入属性语法:

js
import colors from "./colors.json" with { type: "json" };
import styles from "./styles.css" with { type: "css" };

浏览器也会对模块类型进行验证,如果例如 ./data.json 没有解析为一个 JSON 文件,验证就会失败。这确保了你不会在你只想导入数据时意外地执行代码。成功导入后,你就可以像使用普通的 JavaScript 对象或 CSSStyleSheet 对象一样使用导入的值了。

js
console.log(colors.map((color) => color.value));
document.adoptedStyleSheets = [styles];

在 HTML 中应用模块

现在我们只需要将 main.js 模块应用到我们的 HTML 页面上。这与我们将常规脚本应用到页面上的方式非常相似,但有几个显著的区别。

首先,你需要在 <script> 元素中包含 type="module",以声明此脚本为一个模块。要导入 main.js 脚本,我们使用这个:

html
<script type="module" src="main.js"></script>

你也可以通过将 JavaScript 代码放在 <script> 元素的主体中,将模块的脚本直接嵌入到 HTML 文件中:

html
<script type="module">
  /* JavaScript module code here */
</script>

你只能在模块内部使用 importexport 语句,而不能在常规脚本中使用。如果你的 <script> 元素没有 type="module" 属性并试图导入其他模块,将会抛出错误。例如:

html
<script>
  import _ from "lodash"; // SyntaxError: import declarations may only appear at top level of a module
  // …
</script>
<script src="a-module-using-import-statements.js"></script>
<!-- SyntaxError: import declarations may only appear at top level of a module -->

你通常应该将所有模块定义在单独的文件中。在 HTML 中内联声明的模块只能导入其他模块,但它们导出的任何内容都无法被其他模块访问(因为它们没有 URL)。

备注: 模块及其依赖项可以通过在 <link> 元素中指定 rel="modulepreload" 来进行预加载。这可以在模块被使用时显著减少加载时间。

模块与传统脚本的其他区别

  • 你需要注意本地测试——如果你尝试在本地加载 HTML 文件(即,使用 file:// URL),由于 JavaScript 模块的安全要求,你会遇到 CORS 错误。你需要通过服务器进行测试。
  • 另外,请注意,在模块内部定义的脚本部分与在经典脚本中的行为可能会有所不同。这是因为模块会自动使用严格模式
  • 加载模块脚本时,不需要使用 defer 属性(参见 <script> 属性);模块会自动延迟加载。
  • 模块只执行一次,即使它们在多个 <script> 标签中被引用。
  • 最后但同样重要的是,我们要明确一点——模块功能被导入到单个脚本的作用域中——它们在全局作用域中是不可用的。因此,你只能在导入它们的脚本中访问导入的功能,并且你将无法从 JavaScript 控制台中访问它们,例如。你仍然会在开发者工具中看到语法错误,但你将无法使用一些你可能期望使用的调试技术。

模块中定义的变量的作用域限于该模块,除非显式地附加到全局对象上。另一方面,全局定义的变量在模块内部是可用的。例如,给定以下代码:

html
<!doctype html>
<html lang="en-US">
  <head>
    <meta charset="UTF-8" />
    <title></title>
    <link rel="stylesheet" href="" />
  </head>
  <body>
    <div id="main"></div>
    <script>
      // A var statement creates a global variable.
      var text = "Hello";
    </script>
    <script type="module" src="./render.js"></script>
  </body>
</html>
js
/* render.js */
document.getElementById("main").innerText = text;

页面仍然会渲染 Hello,因为全局变量 textdocument 在模块中是可用的。(从这个例子中还请注意,一个模块不一定需要 import/export 语句——唯一需要的是入口点具有 type="module"。)

默认导出与命名导出

到目前为止,我们所导出的功能都是由命名导出组成的——每个项目(无论是函数、const 等)在导出时都通过其名称来引用,并且在导入时也使用该名称来引用它。

还有一种类型的导出叫做默认导出——这旨在使模块提供一个默认函数变得容易,并且还有助于 JavaScript 模块与现有的 CommonJS 和 AMD 模块系统互操作(正如 Jason Orendorff 在 ES6 深入:模块 中很好地解释的那样;搜索“默认导出”)。

让我们来看一个例子,解释它是如何工作的。在我们的 basic-modules square.js 文件中,你可以找到一个名为 randomSquare() 的函数,它创建一个具有随机颜色、大小和位置的正方形。我们想将它作为我们的默认导出,所以在文件底部我们这样写:

js
export default randomSquare;

注意这里没有大括号。

我们也可以在函数前加上 export default,并将其定义为一个匿名函数,像这样:

js
export default function (ctx) {
  // …
}

在我们的 main.js 文件中,我们使用下面这行代码导入默认函数:

js
import randomSquare from "./modules/square.js";

再次注意,这里没有大括号。这是因为每个模块只允许一个默认导出,我们知道 randomSquare 就是那个默认导出。上面这行代码基本上是下面这行的简写:

js
import { default as randomSquare } from "./modules/square.js";

备注: 用于重命名导出项的 as 语法在下面的重命名导入和导出部分有解释。

避免命名冲突

到目前为止,我们的 canvas 图形绘制模块似乎工作正常。但如果我们尝试添加一个处理绘制其他形状(如圆形或三角形)的模块会发生什么?这些形状可能也会有关联的函数,如 draw()reportArea() 等;如果我们试图将不同但同名的函数导入到同一个顶层模块文件中,我们就会遇到冲突和错误。

幸运的是,有多种方法可以解决这个问题。我们将在接下来的部分中探讨这些方法。

重命名导入和导出

importexport 语句的大括号内,你可以使用关键字 as 加上一个新的特性名称,来改变你在顶层模块中将使用的特性标识名称。

所以,例如,以下两种方式都会做同样的事情,尽管方式略有不同:

js
// -- module.js --
export { function1 as newFunctionName, function2 as anotherNewFunctionName };

// -- main.js --
import { newFunctionName, anotherNewFunctionName } from "./modules/module.js";
js
// -- module.js --
export { function1, function2 };

// -- main.js --
import {
  function1 as newFunctionName,
  function2 as anotherNewFunctionName,
} from "./modules/module.js";

让我们来看一个实际的例子。在我们的 renaming 目录中,你会看到与前一个示例相同的模块系统,不同的是我们添加了 circle.jstriangle.js 模块来绘制和报告圆形和三角形。

在这些模块的每一个中,我们都有同名的功能被导出,因此每个模块的底部都有相同的 export 语句:

js
export { name, draw, reportArea, reportPerimeter };

当将这些导入到 main.js 时,如果我们尝试使用:

js
import { name, draw, reportArea, reportPerimeter } from "./modules/square.js";
import { name, draw, reportArea, reportPerimeter } from "./modules/circle.js";
import { name, draw, reportArea, reportPerimeter } from "./modules/triangle.js";

浏览器会抛出一个错误,例如“SyntaxError: redeclaration of import name”(Firefox)。

相反,我们需要重命名导入,使它们是唯一的:

js
import {
  name as squareName,
  draw as drawSquare,
  reportArea as reportSquareArea,
  reportPerimeter as reportSquarePerimeter,
} from "./modules/square.js";

import {
  name as circleName,
  draw as drawCircle,
  reportArea as reportCircleArea,
  reportPerimeter as reportCirclePerimeter,
} from "./modules/circle.js";

import {
  name as triangleName,
  draw as drawTriangle,
  reportArea as reportTriangleArea,
  reportPerimeter as reportTrianglePerimeter,
} from "./modules/triangle.js";

请注意,你也可以在模块文件中解决这个问题,例如:

js
// in square.js
export {
  name as squareName,
  draw as drawSquare,
  reportArea as reportSquareArea,
  reportPerimeter as reportSquarePerimeter,
};
js
// in main.js
import {
  squareName,
  drawSquare,
  reportSquareArea,
  reportSquarePerimeter,
} from "./modules/square.js";

这样做的效果是完全一样的。你使用哪种风格取决于你自己,但可以说,保持你的模块代码不变,而在导入时进行更改更有意义。当你从你无法控制的第三方模块导入时,这一点尤其重要。

创建模块对象

上述方法可行,但有点凌乱和冗长。一个更好的解决方案是将每个模块的功能导入到一个模块对象中。以下语法形式可以做到这一点:

js
import * as Module from "./modules/module.js";

这会获取 module.js 中所有可用的导出,并将它们作为对象 Module 的成员提供,从而有效地为它创建了自己的命名空间。例如:

js
Module.function1();
Module.function2();

再次,让我们来看一个实际的例子。如果你去我们的 module-objects 目录,你会再次看到相同的例子,但这次是重写以利用这种新语法。在模块中,所有的导出都采用以下简单的形式:

js
export { name, draw, reportArea, reportPerimeter };

另一方面,导入则如下所示:

js
import * as Canvas from "./modules/canvas.js";

import * as Square from "./modules/square.js";
import * as Circle from "./modules/circle.js";
import * as Triangle from "./modules/triangle.js";

在每种情况下,你现在都可以通过指定的对象名访问模块的导入,例如:

js
const square = Square.draw(myCanvas.ctx, 50, 50, 100, "blue");
Square.reportArea(square.length, reportList);
Square.reportPerimeter(square.length, reportList);

所以你现在可以像以前一样编写代码(只要在需要的地方包含对象名),并且导入语句变得更加整洁。

模块与类

正如我们前面提到的,你也可以导出和导入类;这是避免代码冲突的另一个选择,特别是如果你的模块代码已经采用面向对象的风格编写,这将非常有用。

你可以在我们的 classes 目录中看到一个用 ES 类重写的形状绘制模块示例。举个例子,square.js 文件现在将所有功能都包含在一个单独的类中:

js
class Square {
  constructor(ctx, listId, length, x, y, color) {
    // …
  }

  draw() {
    // …
  }

  // …
}

然后我们导出它:

js
export { Square };

main.js 中,我们像这样导入它:

js
import { Square } from "./modules/square.js";

然后使用该类来绘制我们的正方形:

js
const square = new Square(myCanvas.ctx, myCanvas.listId, 50, 50, 100, "blue");
square.draw();
square.reportArea();
square.reportPerimeter();

模块聚合

有时,你会想要将多个模块聚合在一起。你可能会有多层级的依赖关系,而你希望简化这些关系,将几个子模块合并到一个父模块中。这可以通过在父模块中使用以下形式的导出语法来实现:

js
export * from "x.js";
export { name } from "x.js";

例如,请参阅我们的 module-aggregation 目录。在这个示例中(基于我们之前的类示例),我们有一个名为 shapes.js 的额外模块,它将 circle.jssquare.jstriangle.js 的所有功能聚合在一起。我们还将我们的子模块移动到了 modules 目录内一个名为 shapes 的子目录中。因此,这个示例中的模块结构是:

modules/
  canvas.js
  shapes.js
  shapes/
    circle.js
    square.js
    triangle.js

在每个子模块中,导出的形式都是相同的,例如:

js
export { Square };

接下来是聚合部分。在 shapes.js 文件内部,我们包含了以下代码行:

js
export { Square } from "./shapes/square.js";
export { Triangle } from "./shapes/triangle.js";
export { Circle } from "./shapes/circle.js";

这些代码行从各个子模块中获取导出,并有效地使它们可以从 shapes.js 模块中获得。

备注:shapes.js 中引用的导出基本上是通过该文件进行重定向,并没有真正在那里存在,所以你将无法在同一个文件中编写任何有用的相关代码。

所以现在在 main.js 文件中,我们可以通过替换

js
import { Square } from "./modules/square.js";
import { Circle } from "./modules/circle.js";
import { Triangle } from "./modules/triangle.js";

用下面这一行代码来访问所有三个模块类:

js
import { Square, Circle, Triangle } from "./modules/shapes.js";

动态模块加载

JavaScript 模块功能最近增加了一个新特性,即动态模块加载。这允许你只在需要时动态加载模块,而不必预先加载所有内容。这有一些明显的性能优势;让我们继续阅读,看看它是如何工作的。

这个新功能允许你将 import() 作为函数调用,并将模块的路径作为参数传递给它。它返回一个 Promise,该 Promise 会兑现为一个模块对象(参见创建模块对象),从而让你能够访问该对象的导出。例如:

js
import("./modules/myModule.js").then((module) => {
  // Do something with the module.
});

备注: 动态导入在浏览器主线程、共享 worker 和专用 worker 中是允许的。但是,如果在 service worker 或 worklet 中调用 import(),将会抛出错误。

让我们来看一个例子。在 dynamic-module-imports 目录中,我们有另一个基于我们类示例的例子。但这次,当示例加载时,我们不在画布上绘制任何东西。相反,我们包含了三个按钮——“Circle”、“Square”和“Triangle”——当按下时,会动态加载所需的模块,然后用它来绘制相关的形状。

在这个示例中,我们只修改了我们的 index.htmlmain.js 文件——模块的导出与之前保持不变。

main.js 中,我们通过 document.querySelector() 调用获取了每个按钮的引用,例如:

js
const squareBtn = document.querySelector(".square");

然后我们为每个按钮附加一个事件监听器,以便在按下时,动态加载相关模块并用它来绘制形状:

js
squareBtn.addEventListener("click", () => {
  import("./modules/square.js").then((Module) => {
    const square = new Module.Square(
      myCanvas.ctx,
      myCanvas.listId,
      50,
      50,
      100,
      "blue",
    );
    square.draw();
    square.reportArea();
    square.reportPerimeter();
  });
});

请注意,因为 promise 的兑现返回一个模块对象,所以类成为了该对象的子特性,因此我们现在需要通过在前面加上 Module. 来访问构造函数,例如 Module.Square( /* … */ )

动态导入的另一个优点是它们始终可用,即使在脚本环境中也是如此。因此,如果你在 HTML 中有一个现有的 <script> 标签,且没有 type="module",你仍然可以通过动态导入来重用以模块形式分发的代码。

html
<script>
  import("./modules/square.js").then((module) => {
    // Do something with the module.
  });
  // Other code that operates on the global scope and is not
  // ready to be refactored into modules yet.
  var btn = document.querySelector(".square");
</script>

顶层 await

顶层 await 是模块内可用的一个特性。这意味着 await 关键字可以在顶层使用。它允许模块像大型异步函数一样工作,这意味着代码可以在父模块使用前被求值,但不会阻塞兄弟模块的加载。

我们来看一个例子。你可以在 top-level-await 目录中找到本节描述的所有文件和代码,该目录是基于之前示例扩展的。

首先,我们在一个单独的 colors.json 文件中声明我们的调色板:

json
{
  "yellow": "#F4D03F",
  "green": "#52BE80",
  "blue": "#5499C7",
  "red": "#CD6155",
  "orange": "#F39C12"
}

然后,我们将创建一个名为 getColors.js 的模块,它使用 fetch 请求加载 colors.json 文件并以对象形式返回数据。

js
// fetch request
const colors = fetch("../data/colors.json").then((response) => response.json());

export default await colors;

请注意这里的最后一行导出语句。

我们在指定要导出的常量 colors 之前使用了关键字 await。这意味着任何包含此模块的其他模块都会等到 colors 下载并解析完毕后才会使用它。

让我们将这个模块包含在我们的 main.js 文件中:

js
import colors from "./modules/getColors.js";
import { Canvas } from "./modules/canvas.js";

const circleBtn = document.querySelector(".circle");

// …

在调用我们的形状函数时,我们将使用 colors 而不是之前使用的字符串:

js
const square = new Module.Square(
  myCanvas.ctx,
  myCanvas.listId,
  50,
  50,
  100,
  colors.blue,
);

const circle = new Module.Circle(
  myCanvas.ctx,
  myCanvas.listId,
  75,
  200,
  100,
  colors.green,
);

const triangle = new Module.Triangle(
  myCanvas.ctx,
  myCanvas.listId,
  100,
  75,
  190,
  colors.yellow,
);

这很有用,因为 main.js 中的代码在 getColors.js 中的代码运行完成之前不会执行。但它不会阻塞其他模块的加载。例如,我们的 canvas.js 模块在 colors 被获取时会继续加载。

导入声明会被提升

导入声明会被提升。在这种情况下,这意味着导入的值在模块代码中是可用的,即使在声明它们的位置之前也是如此,并且导入模块的副作用会在模块其余代码开始运行之前产生。

因此,例如,在 main.js 中,在代码中间导入 Canvas 仍然可以正常工作:

js
// …
const myCanvas = new Canvas("myCanvas", document.body, 480, 320);
myCanvas.create();
import { Canvas } from "./modules/canvas.js";
myCanvas.createReportList();
// …

尽管如此,将所有导入放在代码顶部仍然被认为是良好实践,这样可以更容易地分析依赖关系。

循环导入

模块可以导入其他模块,而这些模块又可以导入其他模块,以此类推。这形成了一个称为“依赖图”的有向图。在理想世界中,这个图是无环的。在这种情况下,可以使用深度优先遍历来对图进行求值。

然而,循环通常是不可避免的。如果模块 a 导入了模块 b,但 b 又直接或间接地依赖于 a,就会产生循环导入。例如:

js
// -- a.js --
import { b } from "./b.js";

// -- b.js --
import { a } from "./a.js";

// Cycle:
// a.js ───> b.js
//  ^         │
//  └─────────┘

循环导入并不总是会失败。导入的变量值只有在变量实际被使用时才会被检索(因此允许实时绑定),并且只有当变量在该时间点仍未初始化时,才会抛出 ReferenceError

js
// -- a.js --
import { b } from "./b.js";

setTimeout(() => {
  console.log(b); // 1
}, 10);

export const a = 2;

// -- b.js --
import { a } from "./a.js";

setTimeout(() => {
  console.log(a); // 2
}, 10);

export const b = 1;

在这个例子中,ab 都是异步使用的。因此,在模块求值时,ba 都没有被实际读取,所以其余代码正常执行,两个 export 声明产生了 ab 的值。然后,在超时之后,ab 都可用了,所以两个 console.log 语句也正常执行。

如果你将代码更改为同步使用 a,模块求值会失败:

js
// -- a.js (entry module) --
import { b } from "./b.js";

export const a = 2;

// -- b.js --
import { a } from "./a.js";

console.log(a); // ReferenceError: Cannot access 'a' before initialization
export const b = 1;

这是因为当 JavaScript 对 a.js 求值时,它需要先对 a.js 的依赖 b.js 求值。然而,b.js 使用了 a,而 a 此时还不可用。

另一方面,如果你将代码更改为同步使用 b,但异步使用 a,模块求值会成功:

js
// -- a.js (entry module) --
import { b } from "./b.js";

console.log(b); // 1
export const a = 2;

// -- b.js --
import { a } from "./a.js";

setTimeout(() => {
  console.log(a); // 2
}, 10);
export const b = 1;

这是因为 b.js 的求值正常完成,所以在对 a.js 进行求值时,b 的值是可用的。

在你的项目中通常应该避免循环导入,因为它们会使你的代码更容易出错。一些常见的消除循环的技术有:

  • 将两个模块合并成一个。
  • 将共享代码移动到第三个模块中。
  • 将一些代码从一个模块移动到另一个模块。

然而,如果库之间相互依赖,也可能出现循环导入,这种情况更难修复。

编写“同构”模块

模块的引入鼓励 JavaScript 生态系统以模块化的方式分发和重用代码。然而,这并不一定意味着一段 JavaScript 代码可以在任何环境下运行。假设你发现一个模块可以为用户密码生成 SHA 哈希值。你能在浏览器前端使用它吗?你能在你的 Node.js 服务器上使用它吗?答案是:视情况而定。

如前所述,模块仍然可以访问全局变量。如果模块引用了像 window 这样的全局变量,它可以在浏览器中运行,但在你的 Node.js 服务器上会抛出错误,因为那里没有 window。同样,如果代码需要访问 process 才能正常工作,它只能在 Node.js 中使用。

为了最大化模块的可重用性,通常建议使代码“同构”——即在每个运行时中表现出相同的行为。这通常通过三种方式实现:

  • 将你的模块分为“核心”和“绑定”。对于“核心”,专注于纯 JavaScript 逻辑,如计算哈希,不涉及任何 DOM、网络、文件系统访问,并暴露实用函数。对于“绑定”部分,你可以从全局上下文中读取和写入。例如,“浏览器绑定”可能会选择从输入框中读取值,而“Node 绑定”可能会从 process.env 中读取,但从任何一处读取的值都将被传递给同一个核心函数并以相同的方式处理。核心可以在每个环境中导入并以相同的方式使用,而只有通常是轻量级的绑定需要是平台特定的。

  • 在使用特定全局变量之前检测它是否存在。例如,如果你测试 typeof window === "undefined",你就知道你可能处在 Node.js 环境中,不应该读取 DOM。

    js
    // myModule.js
    let password;
    if (typeof process !== "undefined") {
      // We are running in Node.js; read it from `process.env`
      password = process.env.PASSWORD;
    } else if (typeof window !== "undefined") {
      // We are running in the browser; read it from the input box
      password = document.getElementById("password").value;
    }
    

    如果两个分支最终确实具有相同的行为(“同构”),这种方式是更可取的。如果无法提供相同的功能,或者这样做涉及加载大量代码而其中一大部分都未使用,那么最好使用不同的“绑定”。

  • 使用 polyfill 为缺失的功能提供回退。例如,如果你想使用 fetch 函数,该函数在 Node.js v18 之后才被支持,你可以使用一个类似的 API,比如 node-fetch 提供的那个。你可以通过动态导入有条件地这样做:

    js
    // myModule.js
    if (typeof fetch === "undefined") {
      // We are running in Node.js; use node-fetch
      globalThis.fetch = (await import("node-fetch")).default;
    }
    // …
    

    globalThis 变量是一个在所有环境中都可用的全局对象,如果你想在模块内读取或创建全局变量,它会很有用。

这些实践并非模块独有。然而,随着代码重用和模块化的趋势,我们鼓励你使你的代码跨平台,以便尽可能多的人能够享用它。像 Node.js 这样的运行时也在积极地尽可能实现 Web API,以提高与 Web 的互操作性。

故障排除

如果你在让模块正常工作时遇到困难,这里有一些可能会有帮助的提示。如果你发现了更多,欢迎随时添加到列表中!

  • 我们之前提到过,但再次重申:.mjs 文件需要以 text/javascript 的 MIME 类型(或其他与 JavaScript 兼容的 MIME 类型,但推荐使用 text/javascript)加载,否则你会收到一个严格的 MIME 类型检查错误,如“服务器响应了非 JavaScript MIME 类型”。
  • 如果你尝试在本地加载 HTML 文件(即,使用 file:// URL),由于 JavaScript 模块的安全要求,你会遇到 CORS 错误。你需要通过服务器进行测试。GitHub Pages 是理想的选择,因为它还会以正确的 MIME 类型提供 .mjs 文件。
  • 因为 .mjs 是一个非标准的文件扩展名,一些操作系统可能无法识别它,或者会尝试用其他东西替换它。例如,我们发现 macOS 会在 .mjs 文件的末尾静默地添加 .js,然后自动隐藏文件扩展名。所以我们所有的文件实际上都变成了 x.mjs.js。一旦我们关闭了自动隐藏文件扩展名,并让它接受 .mjs,问题就解决了。

另见