JavaScript 模块

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

¥This guide gives you all you need to get started with JavaScript module syntax.

模块背景

¥A background on modules

JavaScript 程序一开始相当小 - 早期的大部分用途是执行独立的脚本任务,在需要时为网页提供一些交互性,因此通常不需要大型脚本。快进几年,我们现在已经有了在带有大量 JavaScript 的浏览器中运行的完整应用,以及在其他上下文中使用的 JavaScript(例如 Node.js)。

¥JavaScript programs started off pretty small — most of its usage in the early days was to do isolated scripting tasks, providing a bit of interactivity to your web pages where needed, so large scripts were generally not needed. Fast forward a few years and we now have complete applications being run in browsers with a lot of JavaScript, as well as JavaScript being used in other contexts (Node.js, for example).

因此,近年来开始考虑提供将 JavaScript 程序分割成单独模块的机制,以便在需要时可以导入,这是有意义的。Node.js 很早就具备这种能力,并且有许多 JavaScript 库和框架支持模块使用(例如,其他基于 CommonJSAMD 的模块系统,如 RequireJS,以及最近的 WebpackBabel)。

¥It has therefore made sense in recent years to start thinking about providing mechanisms for splitting JavaScript programs up into separate modules that can be imported when needed. Node.js has had this ability for a long time, and there are a number of JavaScript libraries and frameworks that enable module usage (for example, other CommonJS and AMD-based module systems like RequireJS, and more recently Webpack and Babel).

好消息是现代浏览器已经开始原生支持模块功能,这就是本文的主题。这只能是一件好事 - 浏览器可以优化模块的加载,使其比必须使用库并执行所有额外的客户端处理和额外的往返更有效。

¥The good news is that modern browsers have started to support module functionality natively, and this is what this article is all about. This can only be a good thing — browsers can optimize loading of modules, making it more efficient than having to use a library and do all of that extra client-side processing and extra round trips.

原生 JavaScript 模块的使用取决于 importexport 语句;这些在浏览器中受支持,如下兼容性表所示。

¥Use of native JavaScript modules is dependent on the import and export statements; these are supported in browsers as shown in the compatibility table below.

浏览器兼容性

¥Browser compatibility

javascript.statements.import

BCD tables only load in the browser

javascript.statements.export

BCD tables only load in the browser

介绍一个例子

¥Introducing an example

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

¥To demonstrate usage of modules, we've created a simple set of examples that you can find on GitHub. These examples demonstrate a simple set of modules that create a <canvas> element on a webpage, and then draw (and report information about) different shapes on the canvas.

这些都是相当琐碎的,但为了清楚地演示模块而故意保持简单。

¥These are fairly trivial, but have been kept deliberately simple to demonstrate modules clearly.

注意:如果你想下载示例并在本地运行它们,则需要通过本地 Web 服务器运行它们。

¥Note: If you want to download the examples and run them locally, you'll need to run them through a local web server.

基本示例结构

¥Basic example structure

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

¥In our first example (see basic-modules) we have a file structure as follows:

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

注意:本指南中的所有示例都具有基本相同的结构;上面的内容应该开始变得非常熟悉了。

¥Note: All of the examples in this guide have basically the same structure; the above should start getting pretty familiar.

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

¥The modules directory's two modules are described below:

  • canvas.js — 包含与设置画布相关的函数:
    • create() — 在具有指定 ID 的封装器 <div> 内创建具有指定 widthheight 的画布,该封装器本身附加在指定的父元素内。返回一个包含画布的 2D 上下文和封装器 ID 的对象。
    • createReportList() — 创建一个附加在指定封装元素内的无序列表,可用于将报告数据输出到其中。返回列表的 ID。
  • square.js — 包含:
    • name — 包含字符串 'square' 的常量。
    • draw() — 在指定画布上绘制一个具有指定大小、位置和颜色的正方形。返回一个包含正方形大小、位置和颜色的对象。
    • reportArea() — 将一个正方形的面积写入特定的报告列表(给定其长度)。
    • reportPerimeter() — 将一个正方形的周长写入特定的报告列表(给定其长度)。

旁白 — .mjs 与 .js

¥Aside — .mjs versus .js

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

¥Throughout this article, we've used .js extensions for our module files, but in other resources you may see the .mjs extension used instead. V8's documentation recommends this, for example. The reasons given are:

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

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

¥However, we decided to keep using .js, at least for the moment. To get modules to work correctly in a browser, you need to make sure that your server is serving them with a Content-Type header that contains a JavaScript MIME type such as text/javascript. If you don't, you'll get a strict MIME type checking error along the lines of "The server responded with a non-JavaScript MIME type" and the browser won't run your JavaScript. Most servers already set the correct type for .js files, but not yet for .mjs files. Servers that already serve .mjs files correctly include GitHub Pages and http-server for Node.js.

如果你已经在使用这样的环境,或者如果你没有使用这样的环境,但你知道自己在做什么并且具有访问权限(即你可以配置服务器为 .mjs 文件设置正确的 Content-Type),那么这是可以的。但是,如果你无法控制提供文件的服务器,或者发布文件供公共使用(就像我们在这里一样),则可能会导致混乱。

¥This is OK if you are using such an environment already, or if you aren't but you know what you are doing and have access (i.e. you can configure your server to set the correct Content-Type for .mjs files). It could however cause confusion if you don't control the server you are serving files from, or are publishing files for public use, as we are here.

出于学习和可移植性的目的,我们决定保留 .js

¥For learning and portability purposes, we decided to keep to .js.

如果你确实重视对模块使用 .mjs 与对 "normal" JavaScript 文件使用 .js 的清晰度,但又不想遇到上述问题,那么你始终可以在开发过程中使用 .mjs,并在构建步骤中将它们转换为 .js

¥If you really value the clarity of using .mjs for modules versus using .js for "normal" JavaScript files, but don't want to run into the problem described above, you could always use .mjs during development and convert them to .js during your build step.

还值得注意的是:

¥It is also worth noting that:

  • 有些工具可能永远不支持 .mjs
  • <script type="module"> 属性用于表示何时指向模块,如下所示。

导出模块功能

¥Exporting module features

要访问模块功能,你要做的第一件事就是导出它们。这是使用 export 语句完成的。

¥The first thing you do to get access to module features is export them. This is done using the export statement.

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

¥The easiest way to use it is to place it in front of any items you want exported out of the module, for example:

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

¥You can export functions, var, let, const, and — as we'll see later — classes. They need to be top-level items: for example, you can't use export inside a function.

导出要导出的所有项目的更方便的方法是在模块文件末尾使用单个导出语句,后跟用大括号括起来的以逗号分隔的要导出的功能列表。例如:

¥A more convenient way of exporting all the items you want to export is to use a single export statement at the end of your module file, followed by a comma-separated list of the features you want to export wrapped in curly braces. For example:

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

将功能导入到脚本中

¥Importing features into your script

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

¥Once you've exported some features out of your module, you need to import them into your script to be able to use them. The simplest way to do this is as follows:

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

你使用 import 语句,后跟要导入的以逗号分隔的功能列表(用大括号括起来),然后是关键字 from,最后是模块说明符。

¥You use the import statement, followed by a comma-separated list of the features you want to import wrapped in curly braces, followed by the keyword from, followed by the module specifier.

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

¥The module specifier provides a string that the JavaScript environment can resolve to a path to the module file. In a browser, this could be a path relative to the site root, which for our basic-modules example would be /js-examples/module-examples/basic-modules. However, here we are instead using the dot (.) syntax to mean "the current location", followed by the relative path to the file we are trying to find. This is much better than writing out the entire absolute path each time, as relative paths are shorter and make the URL portable — the example will still work if you move it to a different location in the site hierarchy.

例如:

¥So for example:

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

becomes

bash
./modules/square.js

你可以在 main.js 中看到这些行的运行情况。

¥You can see such lines in action in main.js.

注意:在某些模块系统中,你可以使用像 modules/square 这样的模块说明符,它不是相对或绝对路径,并且没有文件扩展名。如果你首先定义 导入地图.0,则可以在浏览器环境中使用这种说明符。

¥Note: In some module systems, you can use a module specifier like modules/square that isn't a relative or absolute path, and that doesn't have a file extension. This kind of specifier can be used in a browser environment if you first define an import map.

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

¥Once you've imported the features into your script, you can use them just like they were defined inside the same file. The following is found in main.js, below the import lines:

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

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

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

¥Note: The imported values are read-only views of the features that were exported. Similar to const variables, you cannot re-assign the variable that was imported, but you can still modify properties of object values. The value can only be re-assigned by the module exporting it. See the import reference for an example.

使用导入映射导入模块

¥Importing modules using import maps

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

¥Above we saw how a browser can import a module using a module specifier that is either an absolute URL, or a relative URL that is resolved using the base URL of the document:

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

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

¥Import maps allow developers to instead specify almost any text they want in the module specifier when importing a module; the map provides a corresponding value that will replace the text when the module URL is resolved.

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

¥For example, the imports key in the import map below defines a "module specifier map" JSON object where the property names can be used as module specifiers, and the corresponding values will be substituted when the browser resolves the module URL. The values must be absolute or relative URLs. Relative URLs are resolved to absolute URL addresses using the base URL of the document containing the import map.

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>

导入映射是使用 <script> 元素内的 JSON 对象 定义的,并且 type 属性设置为 importmap。文档中只能有一个导入映射,并且由于它用于解析在静态导入和动态导入中加载哪些模块,因此必须在导入模块的任何 <script> 元素之前声明它。请注意,导入映射仅适用于文档 - 规范不涵盖如何在工作线程或工作集上下文中应用导入映射。

¥The import map is defined using a JSON object inside a <script> element with the type attribute set to importmap. There can only be one import map in the document, and because it is used to resolve which modules are loaded in both static and dynamic imports, it must be declared before any <script> elements that import modules. Note that the import map only applies to the document — the specification does not cover how to apply an import map in a worker or worklet context.

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

¥With this map you can now use the property names above as module specifiers. If there is no trailing forward slash on the module specifier key then the whole module specifier key is matched and substituted. For example, below we match bare module names, and remap a URL to another path.

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 类别。

¥If the module specifier has a trailing forward slash then the value must have one as well, and the key is matched as a "path prefix". This allows remapping of whole classes of URLs.

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/。在这种情况下,浏览器将选择最具体(最长)的匹配模块说明符键。

¥It is possible for multiple keys in an import map to be valid matches for a module specifier. For example, a module specifier of shapes/circle/ could match the module specifier keys shapes/ and shapes/circle/. In this case the browser will select the most specific (longest) matching module specifier key.

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

¥Import maps allow modules to be imported using bare module names (as in Node.js), and can also simulate importing modules from packages, both with and without file extensions. While not shown above, they also allow particular versions of a library to be imported, based on the path of the script that is importing the module. Generally they let developers write more ergonomic import code, and make it easier to manage the different versions and dependencies of modules used by a site. This can reduce the effort required to use the same JavaScript libraries in both browser and server.

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

¥The following sections expand on the various features outlined above.

特性检测

¥Feature detection

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

¥You can check support for import maps using the HTMLScriptElement.supports() static method (which is itself broadly supported):

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

将模块作为裸名称导入

¥Importing modules as bare names

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

¥In some JavaScript environments, such as Node.js, you can use bare names for the module specifier. This works because the environment can resolve module names to a standard location in the file system. For example, you might use the following syntax to import the "square" module.

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

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

¥To use bare names on a browser you need an import map, which provides the information needed by the browser to resolve module specifiers to URLs (JavaScript will throw a TypeError if it attempts to import a module specifier that can't be resolved to a module location).

下面你可以看到定义 square 模块说明符键的映射,在本例中映射到相对地址值。

¥Below you can see a map that defines a square module specifier key, which in this case maps to a relative address value.

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

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

¥With this map we can now use a bare name when we import the module:

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

重新映射模块路径

¥Remapping module paths

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

¥Module specifier map entries, where both the specifier key and its associated value have a trailing forward slash (/), can be used as a path-prefix. This allows the remapping of a whole set of import URLs from one location to another. It can also be used to emulate working with "packages and modules", such as you might see in the Node ecosystem.

注意:尾随 / 表示模块说明符键可以替换为模块说明符的一部分。如果不存在,浏览器将仅匹配(并替换)整个模块说明符键。

¥Note: The trailing / indicates that the module specifier key can be substituted as part of a module specifier. If this is not present, the browser will only match (and substitute) the whole module specifier key.

模块包

¥Packages of modules

以下 JSON 导入映射定义将 lodash 映射为裸名称,并将模块说明符前缀 lodash/ 映射到路径 /node_modules/lodash-es/(解析为文档基本 URL):

¥The following JSON import map definition maps lodash as a bare name, and the module specifier prefix lodash/ to the path /node_modules/lodash-es/ (resolved to the document base URL):

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

通过此映射,你可以使用裸名称导入整个 "package" 以及其中的模块(使用路径映射):

¥With this mapping you can import both the whole "package", using the bare name, and modules within it (using the path mapping):

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

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

¥It is possible to import fp above without the .js file extension, but you would need to create a bare module specifier key for that file, such as lodash/fp, rather than using the path. This may be reasonable for just one module, but scales poorly if you wish to import many modules.

通用 URL 重新映射

¥General URL remapping

模块说明符键不必是路径 - 它也可以是绝对 URL(或类似 URL 的相对路径,如 ./..//)。如果你想使用你自己的本地资源重新映射具有指向资源的绝对路径的模块,这可能会很有用。

¥A module specifier key doesn't have to be path — it can also be an absolute URL (or a URL-like relative path like ./, ../, /). This may be useful if you want to remap a module that has absolute paths to a resource with your own local resources.

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

用于版本管理的范围模块

¥Scoped modules for version management

Node 等生态系统使用 npm 等包管理器来管理模块及其依赖。包管理器确保每个模块与其他模块及其依赖分开。因此,虽然复杂的应用可能多次包含相同的模块,并且模块图中的不同部分有多个不同的版本,但用户不需要考虑这种复杂性。

¥Ecosystems like Node use package managers such as npm to manage modules and their dependencies. The package manager ensures that each module is separated from other modules and their dependencies. As a result, while a complex application might include the same module multiple times with several different versions in different parts of the module graph, users do not need to think about this complexity.

注意:你还可以使用相对路径实现版本管理,但这不太理想,因为除其他外,这会在你的项目上强制使用特定的结构,并阻止你使用裸模块名称。

¥Note: You can also achieve version management using relative paths, but this is subpar because, among other things, this forces a particular structure on your project, and prevents you from using bare module names.

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

¥Import maps similarly allow you to have multiple versions of dependencies in your application and refer to them using the same module specifier. You implement this with the scopes key, which allows you to provide module specifier maps that will be used depending on the path of the script performing the import. The example below demonstrates this.

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

通过此映射,如果 URL 包含 /node_modules/dependency/ 的脚本导入 coolmodule,则将使用 /node_modules/some/other/location/coolmodule/index.js 中的版本。如果范围映射中没有匹配范围,或者匹配范围不包含匹配说明符,则 imports 中的映射将用作后备。例如,如果从具有不匹配范围路径的脚本导入 coolmodule,则将使用 imports 中的模块说明符映射,映射到 /node_modules/coolmodule/index.js 中的版本。

¥With this mapping, if a script with an URL that contains /node_modules/dependency/ imports coolmodule, the version in /node_modules/some/other/location/coolmodule/index.js will be used. The map in imports is used as a fallback if there is no matching scope in the scoped map, or the matching scopes don't contain a matching specifier. For example, if coolmodule is imported from a script with a non-matching scope path, then the module specifier map in imports will be used instead, mapping to the version in /node_modules/coolmodule/index.js.

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

¥Note that the path used to select a scope does not affect how the address is resolved. The value in the mapped path does not have to match the scopes path, and relative paths are still resolved to the base URL of the script that contains the import map.

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

¥Just as for module specifier maps, you can have many scope keys, and these may contain overlapping paths. If multiple scopes match the referrer URL, then the most specific scope path is checked first (the longest scope key) for a matching specifier. The browsers will fall back to the next most specific matching scoped path if there is no matching specifier, and so on. If there is no matching specifier in any of the matching scopes, the browser checks for a match in the module specifier map in the imports key.

通过映射散列文件名来改进缓存

¥Improve caching by mapping away hashed filenames

网站使用的脚本文件通常具有哈希文件名以简化缓存。这种方法的缺点是,如果模块发生更改,则任何使用其哈希文件名导入该模块的模块也需要更新/重新生成。这可能会导致级联更新,从而浪费网络资源。

¥Script files used by websites often have hashed filenames to simplify caching. The downside of this approach is that if a module changes, any modules that import it using its hashed filename will also need to be updated/regenerated. This potentially results in a cascade of updates, which is wasteful of network resources.

导入地图为这个问题提供了便捷的解决方案。应用和脚本不依赖于特定的散列文件名,而是依赖于模块名称(地址)的未散列版本。然后,像下面这样的导入映射会提供到实际脚本文件的映射。

¥Import maps provide a convenient solution to this problem. Rather than depending on specific hashed filenames, applications and scripts instead depend on an un-hashed version of the module name (address). An import map like the one below then provides a mapping to the actual script file.

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

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

¥If dependency_script changes, then its hash contained in the file name changes as well. In this case, we only need to update the import map to reflect the changed name of the module. We don't have to update the source of any JavaScript code that depends on it, because the specifier in the import statement does not change.

将模块应用到你的 HTML

¥Applying the module to your HTML

现在我们只需要将 main.js 模块应用到我们的 HTML 页面即可。这与我们将常规脚本应用到页面的方式非常相似,但有一些显着的差异。

¥Now we just need to apply the main.js module to our HTML page. This is very similar to how we apply a regular script to a page, with a few notable differences.

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

¥First of all, you need to include type="module" in the <script> element, to declare this script as a module. To import the main.js script, we use this:

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

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

¥You can also embed the module's script directly into the HTML file by placing the JavaScript code within the body of the <script> element:

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

导入模块功能的脚本基本上充当顶层模块。如果省略它,Firefox 会给出错误“SyntaxError:导入声明只能出现在模块的顶层”。

¥The script into which you import the module features basically acts as the top-level module. If you omit it, Firefox for example gives you an error of "SyntaxError: import declarations may only appear at top level of a module".

你只能在模块内使用 importexport 语句,而不能在常规脚本中使用。

¥You can only use import and export statements inside modules, not regular scripts.

注意:模块及其依赖可以通过在 <link> 元素中用 rel="modulepreloaded" 指定来预加载。这可以显着减少使用模块时的加载时间。

¥Note: Modules and their dependencies can be preloaded by specifying them in <link> elements with rel="modulepreloaded". This can significantly reduce load time when the modules are used.

模块和标准脚本之间的其他差异

¥Other differences between modules and standard scripts

  • 你需要注意本地测试 - 如果你尝试在本地加载 HTML 文件(即使用 file:// URL),由于 JavaScript 模块安全要求,你将遇到 CORS 错误。你需要通过服务器进行测试。
  • 另请注意,你可能会从模块内部定义的脚本部分获得与标准脚本中不同的行为。这是因为模块自动使用 strict mode
  • 加载模块脚本时无需使用 defer 属性(参见 <script> 属性);模块会自动推迟。
  • 模块仅执行一次,即使它们已在多个 <script> 标记中引用。
  • 最后但并非最不重要的一点是,让我们明确这一点 - 模块功能被导入到单个脚本的范围内 - 它们在全局范围内不可用。因此,你只能在导入的脚本中访问导入的功能,而无法从 JavaScript 控制台访问它们。你仍然会收到 DevTools 中显示的语法错误,但你将无法使用你可能期望使用的一些调试技术。

模块定义的变量的作用域为模块,除非显式附加到全局对象。另一方面,全局定义的变量在模块内可用。例如,给出以下代码:

¥Module-defined variables are scoped to the module unless explicitly attached to the global object. On the other hand, globally-defined variables are available within the module. For example, given the following code:

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 在模块中可用。(还请注意,从这个示例中,模块不一定需要导入/导出语句 - 唯一需要的是入口点具有 type="module"。)

¥The page would still render Hello, because the global variables text and document are available in the module. (Also note from this example that a module doesn't necessarily need an import/export statement — the only thing needed is for the entry point to have type="module".)

默认导出与命名导出

¥Default exports versus named exports

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

¥The functionality we've exported so far has been comprised of named exports — each item (be it a function, const, etc.) has been referred to by its name upon export, and that name has been used to refer to it on import as well.

还有一种称为默认导出的导出类型 - 旨在使模块提供的默认功能变得容易,并且还帮助 JavaScript 模块与现有 CommonJS 和 AMD 模块系统进行互操作(如 ES6 深入了解:模块 中很好地解释的那样) Jason Orendorff;搜索 "默认导出")。

¥There is also a type of export called the default export — this is designed to make it easy to have a default function provided by a module, and also helps JavaScript modules to interoperate with existing CommonJS and AMD module systems (as explained nicely in ES6 In Depth: Modules by Jason Orendorff; search for "Default exports").

让我们看一个例子来解释它是如何工作的。在我们的基本模块 square.js 中,你可以找到一个名为 randomSquare() 的函数,它创建一个具有随机颜色、大小和位置的正方形。我们希望将其导出为默认值,因此在文件底部我们编写以下内容:

¥Let's look at an example as we explain how it works. In our basic-modules square.js you can find a function called randomSquare() that creates a square with a random color, size, and position. We want to export this as our default, so at the bottom of the file we write this:

js
export default randomSquare;

请注意缺少大括号。

¥Note the lack of curly braces.

我们可以在函数前面添加 export default 并将其定义为匿名函数,如下所示:

¥We could instead prepend export default onto the function and define it as an anonymous function, like this:

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

main.js 文件中,我们使用以下行导入默认函数:

¥Over in our main.js file, we import the default function using this line:

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

再次注意缺少大括号。这是因为每个模块只允许一个默认导出,而我们知道就是 randomSquare。上面的行基本上是以下内容的简写:

¥Again, note the lack of curly braces. This is because there is only one default export allowed per module, and we know that randomSquare is it. The above line is basically shorthand for:

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

注意:下面的 重命名导入和导出 部分解释了重命名导出项目的 as 语法。

¥Note: The as syntax for renaming exported items is explained below in the Renaming imports and exports section.

避免命名冲突

¥Avoiding naming conflicts

到目前为止,我们的画布形状绘制模块似乎工作正常。但是,如果我们尝试添加一个用于绘制其他形状(例如圆形或三角形)的模块,会发生什么情况?这些形状可能也有相关的功能,如 draw()reportArea() 等;如果我们尝试将同名的不同函数导入到同一个顶层模块文件中,最终会出现冲突和错误。

¥So far, our canvas shape drawing modules seem to be working OK. But what happens if we try to add a module that deals with drawing another shape, like a circle or triangle? These shapes would probably have associated functions like draw(), reportArea(), etc. too; if we tried to import different functions of the same name into the same top-level module file, we'd end up with conflicts and errors.

幸运的是,有很多方法可以解决这个问题。我们将在以下部分中讨论这些内容。

¥Fortunately there are a number of ways to get around this. We'll look at these in the following sections.

重命名导入和导出

¥Renaming imports and exports

importexport 语句的大括号内,你可以使用关键字 as 和新功能名称,以更改将用于顶层模块内功能的标识名称。

¥Inside your import and export statement's curly braces, you can use the keyword as along with a new feature name, to change the identifying name you will use for a feature inside the top-level module.

例如,以下两项将完成相同的工作,尽管方式略有不同:

¥So for example, both of the following would do the same job, albeit in a slightly different way:

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

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

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

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

¥Let's look at a real example. In our renaming directory you'll see the same module system as in the previous example, except that we've added circle.js and triangle.js modules to draw and report on circles and triangles.

在每个模块中,我们都导出了具有相同名称的功能,因此每个模块的底部都有相同的 export 语句:

¥Inside each of these modules, we've got features with the same names being exported, and therefore each has the same export statement at the bottom:

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

当将它们导入 main.js 时,如果我们尝试使用

¥When importing these into main.js, if we tried to use

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:重新声明导入名称”(Firefox)。

¥The browser would throw an error such as "SyntaxError: redeclaration of import name" (Firefox).

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

¥Instead we need to rename the imports so that they are unique:

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

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

¥Note that you could solve the problem in the module files instead, e.g.

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

而且它的工作原理是一样的。你使用什么样式取决于你,但是,保留模块代码并在导入中进行更改可以说更有意义。当你从你无法控制的第三方模块导入时,这尤其有意义。

¥And it would work just the same. What style you use is up to you, however it arguably makes more sense to leave your module code alone, and make the changes in the imports. This especially makes sense when you are importing from third party modules that you don't have any control over.

创建模块对象

¥Creating a module object

上面的方法效果还可以,但是有点乱,啰嗦。更好的解决方案是将每个模块的功能导入模块对象内。以下语法形式可以做到这一点:

¥The above method works OK, but it's a little messy and long-winded. An even better solution is to import each module's features inside a module object. The following syntax form does that:

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

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

¥This grabs all the exports available inside module.js, and makes them available as members of an object Module, effectively giving it its own namespace. So for example:

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

再次,让我们看一个真实的例子。如果你转到我们的 module-objects 目录,你将再次看到相同的示例,但已重写以利用此新语法。在模块中,导出均采用以下简单形式:

¥Again, let's look at a real example. If you go to our module-objects directory, you'll see the same example again, but rewritten to take advantage of this new syntax. In the modules, the exports are all in the following simple form:

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

另一方面,导入看起来像这样:

¥The imports on the other hand look like this:

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

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

¥In each case, you can now access the module's imports underneath the specified object name, for example:

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

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

¥So you can now write the code just the same as before (as long as you include the object names where needed), and the imports are much neater.

模块和类

¥Modules and classes

正如我们之前暗示的,你还可以导出和导入类;这是避免代码冲突的另一种选择,如果你已经以面向对象的风格编写了模块代码,则该选项特别有用。

¥As we hinted at earlier, you can also export and import classes; this is another option for avoiding conflicts in your code, and is especially useful if you've already got your module code written in an object-oriented style.

你可以在 classes 目录中看到用 ES 类重写的形状绘图模块的示例。例如,square.js 文件现在在单个类中包含其所有功能:

¥You can see an example of our shape drawing module rewritten with ES classes in our classes directory. As an example, the square.js file now contains all its functionality in a single class:

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

  draw() {
    // …
  }

  // …
}

然后我们导出:

¥which we then export:

js
export { Square };

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

¥Over in main.js, we import it like this:

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

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

¥And then use the class to draw our square:

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

聚合模块

¥Aggregating modules

有时你会想要将模块聚合在一起。你可能有多个级别的依赖,你希望简化事情,将多个子模块组合到一个父模块中。这可以在父模块中使用以下形式的导出语法:

¥There will be times where you'll want to aggregate modules together. You might have multiple levels of dependencies, where you want to simplify things, combining several submodules into one parent module. This is possible using export syntax of the following forms in the parent module:

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

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

¥For an example, see our module-aggregation directory. In this example (based on our earlier classes example) we've got an extra module called shapes.js, which aggregates all the functionality from circle.js, square.js, and triangle.js together. We've also moved our submodules inside a subdirectory inside the modules directory called shapes. So the module structure in this example is:

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

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

¥In each of the submodules, the export is of the same form, e.g.

js
export { Square };

接下来是聚合部分。在 shapes.js 中,我们包含以下几行:

¥Next up comes the aggregation part. Inside shapes.js, we include the following lines:

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

它们从各个子模块中获取导出,并有效地使它们可以从 shapes.js 模块中使用。

¥These grab the exports from the individual submodules and effectively make them available from the shapes.js module.

注意:shapes.js 中引用的导出基本上通过该文件进行重定向,并且实际上并不存在于该文件中,因此你将无法在同一文件中编写任何有用的相关代码。

¥Note: The exports referenced in shapes.js basically get redirected through the file and don't really exist there, so you won't be able to write any useful related code inside the same file.

所以现在在 main.js 文件中,我们可以通过替换来访问所有三个模块类

¥So now in the main.js file, we can get access to all three module classes by replacing

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

与以下单行:

¥with the following single line:

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

动态模块加载

¥Dynamic module loading

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

¥A recent addition to JavaScript modules functionality is dynamic module loading. This allows you to dynamically load modules only when they are needed, rather than having to load everything up front. This has some obvious performance advantages; let's read on and see how it works.

这个新功能允许你将 import() 作为函数调用,并将模块的路径作为参数传递给它。它返回一个 Promise,它包含一个模块对象(请参阅 创建模块对象),使你可以访问该对象的导出。例如:

¥This new functionality allows you to call import() as a function, passing it the path to the module as a parameter. It returns a Promise, which fulfills with a module object (see Creating a module object) giving you access to that object's exports. For example:

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

注意:浏览器主线程以及共享和专用工作线程中允许动态导入。但是,如果在 Service Worker 或 Worklet 中调用,import() 将会抛出异常。

¥Note: Dynamic import is permitted in the browser main thread, and in shared and dedicated workers. However import() will throw if called in a service worker or worklet.

让我们看一个例子。在 dynamic-module-imports 目录中,我们有另一个基于我们的类示例的示例。然而这一次,当示例加载时,我们没有在画布上绘制任何内容。相反,我们包括三个按钮 - "圆圈"、"正方形" 和 "三角形" - 按下这些按钮时,会动态加载所需的模块,然后用它来绘制相关的形状。

¥Let's look at an example. In the dynamic-module-imports directory we've got another example based on our classes example. This time however we are not drawing anything on the canvas when the example loads. Instead, we include three buttons — "Circle", "Square", and "Triangle" — that, when pressed, dynamically load the required module and then use it to draw the associated shape.

在此示例中,我们仅对 index.htmlmain.js 文件进行了更改 - 模块导出与以前相同。

¥In this example we've only made changes to our index.html and main.js files — the module exports remain the same as before.

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

¥Over in main.js we've grabbed a reference to each button using a document.querySelector() call, for example:

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

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

¥We then attach an event listener to each button so that when pressed, the relevant module is dynamically loaded and used to draw the shape:

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

请注意,由于 Promise 履行返回一个模块对象,因此该类将成为该对象的子功能,因此我们现在需要访问前缀为 Module. 的构造函数,例如 Module.Square( /* … */ )

¥Note that, because the promise fulfillment returns a module object, the class is then made a subfeature of the object, hence we now need to access the constructor with Module. prepended to it, e.g. Module.Square( /* … */ ).

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

¥Another advantage of dynamic imports is that they are always available, even in script environments. Therefore, if you have an existing <script> tag in your HTML that doesn't have type="module", you can still reuse code distributed as modules by dynamically importing it.

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>

顶层等待

¥Top level await

顶层等待是模块内可用的功能。这意味着可以使用 await 关键字。它允许模块充当大 异步函数,这意味着可以在父模块中使用之前对代码进行评估,但不会阻止同级模块的加载。

¥Top level await is a feature available within modules. This means the await keyword can be used. It allows modules to act as big asynchronous functions meaning code can be evaluated before use in parent modules, but without blocking sibling modules from loading.

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

¥Let's take a look at an example. You can find all the files and code described in this section within the top-level-await directory, which extends from the previous examples.

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

¥Firstly we'll declare our color palette in a separate colors.json file:

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

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

¥Then we'll create a module called getColors.js which uses a fetch request to load the colors.json file and return the data as an object.

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

export default await colors;

请注意此处的最后一个导出行。

¥Notice the last export line here.

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

¥We're using the keyword await before specifying the constant colors to export. This means any other modules which include this one will wait until colors has been downloaded and parsed before using it.

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

¥Let's include this module in our main.js file:

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

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

// …

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

¥We'll use colors instead of the previously used strings when calling our shape functions:

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

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

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

这很有用,因为 main.js 中的代码只有在 getColors.js 中的代码运行后才会执行。但是它不会阻止其他模块的加载。例如,我们的 canvas.js 模块将在获取 colors 时继续加载。

¥This is useful because the code within main.js won't execute until the code in getColors.js has run. However it won't block other modules being loaded. For instance our canvas.js module will continue to load while colors is being fetched.

导入报关单被吊起

¥Import declarations are hoisted

导入报关单为 hoisted。在这种情况下,这意味着导入的值甚至在声明它们的位置之前就可以在模块的代码中使用,并且导入模块的副作用是在模块的其余代码开始运行之前产生的。

¥Import declarations are hoisted. In this case, it means that the imported values are available in the module's code even before the place that declares them, and that the imported module's side effects are produced before the rest of the module's code starts running.

例如,在 main.js 中,在代码中间导入 Canvas 仍然有效:

¥So for example, in main.js, importing Canvas in the middle of the code would still work:

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

尽管如此,将所有导入放在代码顶部仍然被认为是一种很好的做法,这样可以更轻松地分析依赖。

¥Still, it is considered good practice to put all your imports at the top of the code, which makes it easier to analyze dependencies.

循环导入

¥Cyclic imports

模块可以导入其他模块,这些模块也可以导入其他模块,等等。这形成了称为 "依赖图" 的 有向图。在理想情况下,该图是 acyclic。在这种情况下,可以使用深度优先遍历来评估图。

¥Modules can import other modules, and those modules can import other modules, and so on. This forms a directed graph called the "dependency graph". In an ideal world, this graph is acyclic. In this case, the graph can be evaluated using a depth-first traversal.

然而,周期往往是不可避免的。如果模块 a 导入模块 b,但 b 直接或间接依赖于 a,则会出现循环导入。例如:

¥However, cycles are often inevitable. Cyclic import arises if module a imports module b, but b directly or indirectly depends on a. For example:

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

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

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

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

¥Cyclic imports don't always fail. The imported variable's value is only retrieved when the variable is actually used (hence allowing live bindings), and only if the variable remains uninitialized at that time will a ReferenceError be thrown.

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 语句也正常执行。

¥In this example, both a and b are used asynchronously. Therefore, at the time the module is evaluated, neither b nor a is actually read, so the rest of the code is executed as normal, and the two export declarations produce the values of a and b. Then, after the timeout, both a and b are available, so the two console.log statements also execute as normal.

如果将代码更改为同步使用 a,模块评估将失败:

¥If you change the code to use a synchronously, the module evaluation fails:

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 时,它需要首先评估 b.js,即 a.js 的依赖。但是,b.js 使用了 a,目前尚不可用。

¥This is because when JavaScript evaluates a.js, it needs to first evaluate b.js, the dependency of a.js. However, b.js uses a, which is not yet available.

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

¥On the other hand, if you change the code to use b synchronously but a asynchronously, the module evaluation succeeds:

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 的值可用。

¥This is because the evaluation of b.js completes normally, so the value of b is available when a.js is evaluated.

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

¥You should usually avoid cyclic imports in your project, because they make your code more error-prone. Some common cycle-elimination techniques are:

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

但是,如果库相互依赖,也可能会发生循环导入,这很难修复。

¥However, cyclic imports can also occur if the libraries depend on each other, which is harder to fix.

编写 "isomorphic" 模块

¥Authoring "isomorphic" modules

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

¥The introduction of modules encourages the JavaScript ecosystem to distribute and reuse code in a modular fashion. However, that doesn't necessarily mean a piece of JavaScript code can run in every environment. Suppose you discovered a module that generates SHA hashes of your user's password. Can you use it in the browser front end? Can you use it on your Node.js server? The answer is: it depends.

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

¥Modules still have access to global variables, as demonstrated previously. If the module references globals like window, it can run in the browser, but will throw an error in your Node.js server, because window is not available there. Similarly, if the code requires access to process to be functional, it can only be used in Node.js.

为了最大限度地提高模块的可重用性,通常建议将代码设置为 "isomorphic",即在每个运行时都表现出相同的行为。这通常通过三种方式实现:

¥In order to maximize the reusability of a module, it is often advised to make the code "isomorphic" — that is, exhibits the same behavior in every runtime. This is commonly achieved in three ways:

  • 将模块分为 "core" 和 "binding"。对于 "core",专注于纯 JavaScript 逻辑,例如计算哈希,没有任何 DOM、网络、文件系统访问和公开实用函数。对于 "binding" 部分,你可以读取和写入全局上下文。例如,"浏览器绑定" 可以选择从输入框读取值,而 "节点绑定" 可以从 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;
    }
    
    如果两个分支实际上最终具有相同的行为 ("isomorphic"),则这是更好的选择。如果无法提供相同的功能,或者这样做涉及加载大量代码而大部分代码未使用,则最好使用不同的 "bindings"。
  • 使用 Polyfill 为缺失的功能提供后备。例如,如果你想使用 fetch 功能,该功能自 v18 起仅在 Node.js 中支持,你可以使用类似的 API,如 node-fetch 提供的 API。你可以通过动态导入有条件地执行此操作:
    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 的互操作性。

¥These practices are not unique to modules. Still, with the trend of code reusability and modularization, you are encouraged to make your code cross-platform so that it can be enjoyed by as many people as possible. Runtimes like Node.js are also actively implementing web APIs where possible to improve interoperability with the web.

故障排除

¥Troubleshooting

如果你在使模块正常工作时遇到问题,这里有一些提示可能会对你有所帮助。如果你发现更多,请随时添加到列表中!

¥Here are a few tips that may help you if you are having trouble getting your modules to work. Feel free to add to the list if you discover more!

  • 我们之前提到过这一点,但重申一下:.mjs 文件需要使用 text/javascript 的 MIME 类型(或其他与 JavaScript 兼容的 MIME 类型,但建议使用 text/javascript)加载,否则你将得到像 "服务器使用非 JavaScript MIME 类型进行响应" 这样严格的 MIME 类型检查错误。
  • 如果你尝试在本地加载 HTML 文件(即使用 file:// URL),由于 JavaScript 模块安全要求,你将遇到 CORS 错误。你需要通过服务器进行测试。GitHub 页面是理想的选择,因为它还提供具有正确 MIME 类型的 .mjs 文件。
  • 由于 .mjs 是非标准文件扩展名,某些操作系统可能无法识别它,或者尝试用其他名称替换它。例如,我们发现 macOS 会默默地将 .js 添加到 .mjs 文件的末尾,然后自动隐藏文件扩展名。所以我们所有的文件实际上都以 x.mjs.js 的形式出现。一旦我们关闭自动隐藏文件扩展名,并训练它接受 .mjs,就 OK 了。

也可以看看

¥See also