JavaScript 模块
本指南提供了开始使用 JavaScript 模块语法所需的一切。
模块背景
JavaScript 程序最初规模很小——它在早期的主要用途是执行孤立的脚本任务,根据需要为网页提供一些交互性,因此通常不需要大型脚本。快进几年,我们现在在浏览器中运行完整的应用程序,其中包含大量 JavaScript,并且 JavaScript 也被用于其他环境(例如 Node.js)。
因此,近年来开始考虑提供机制来将 JavaScript 程序拆分为单独的模块,这些模块可以在需要时导入,这是有意义的。Node.js 很早就具备了这种能力,并且有许多 JavaScript 库和框架支持模块使用(例如,其他 CommonJS 和 AMD 基于模块的系统,如 RequireJS,以及最近的 Webpack 和 Babel)。
好消息是,现代浏览器已开始原生支持模块功能,这就是本文的重点。这只能是一件好事——浏览器可以优化模块加载,使其比使用库并执行所有额外的客户端处理和额外往返更有效。
原生 JavaScript 模块的使用依赖于 import
和 export
语句;这些语句在浏览器中受支持,如下面的兼容性表所示。
浏览器兼容性
javascript.statements.import
BCD 表仅在启用 JavaScript 的浏览器中加载。
javascript.statements.export
BCD 表仅在启用 JavaScript 的浏览器中加载。
示例介绍
基本示例结构
在我们的第一个示例(请参阅 basic-modules)中,我们的文件结构如下所示
index.html main.js modules/ canvas.js square.js
注意:本指南中的所有示例基本上都具有相同的结构;上面应该开始变得非常熟悉。
模块目录的两个模块如下所述
canvas.js
— 包含与设置画布相关的函数create()
— 在具有指定 ID 的包装<div>
内创建具有指定width
和height
的画布,该包装本身附加在指定的父元素内。返回一个包含画布的 2D 上下文和包装 ID 的对象。createReportList()
— 创建一个附加在指定包装元素内的无序列表,该列表可用于输出报告数据。返回列表的 ID。
square.js
— 包含name
— 包含字符串“square”的常量。draw()
— 在指定的画布上绘制一个正方形,具有指定的尺寸、位置和颜色。返回一个包含正方形的尺寸、位置和颜色的对象。reportArea()
— 将正方形的面积写入特定报告列表,给定其长度。reportPerimeter()
— 将正方形的周长写入特定报告列表,给定其长度。
旁注—.mjs 与 .js
在本文中,我们为模块文件使用了 .js
扩展名,但在其他资源中,您可能会看到使用 .mjs
扩展名。例如,V8 的文档建议这样做。给出的原因是
但是,我们决定至少目前继续使用 .js
。为了使模块在浏览器中正确工作,您需要确保您的服务器使用包含 JavaScript MIME 类型(如 text/javascript
)的 Content-Type
标头提供它们。如果不这样做,您将收到类似“服务器响应了非 JavaScript MIME 类型”的严格 MIME 类型检查错误,并且浏览器将不会运行您的 JavaScript。大多数服务器已为 .js
文件设置了正确的类型,但尚未为 .mjs
文件设置。已正确提供 .mjs
文件的服务器包括 GitHub Pages 和 http-server
(用于 Node.js)。
如果您已经在使用此类环境,或者您没有使用但知道自己在做什么并且有权访问(即,您可以配置您的服务器以设置 .mjs
文件的正确 Content-Type
),则这没问题。但是,如果您不控制从中提供文件的服务器或正在发布供公众使用的文件(就像我们在这里做的那样),这可能会造成混淆。
出于学习和可移植性目的,我们决定坚持使用 .js
。
如果您确实重视为模块使用 .mjs
而为“普通”JavaScript 文件使用 .js
的清晰度,但不想遇到上述问题,则可以在开发过程中始终使用 .mjs
,并在构建步骤中将其转换为 .js
。
还值得注意的是
- 某些工具可能永远不支持
.mjs
。 <script type="module">
属性用于表示何时指向模块,如下所示。
导出模块功能
要访问模块的功能,首先需要导出它们。这可以通过使用 export
语句来实现。
最简单的使用方法是在要导出的任何项目前面加上它,例如
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 };
}
您可以导出函数、var
、let
、const
以及稍后将介绍的类。它们需要是顶级项目:例如,您不能在函数内部使用 export
。
另一种更方便的方法是,在模块文件末尾使用单个 export
语句,后跟要导出的功能的逗号分隔列表(用花括号括起来)。例如
export { name, draw, reportArea, reportPerimeter };
将功能导入您的脚本
导出模块中的某些功能后,需要将它们导入脚本中才能使用。最简单的方法如下所示
import { name, draw, reportArea, reportPerimeter } from "./modules/square.js";
使用 import
语句,后跟要导入的功能的逗号分隔列表(用花括号括起来),然后是关键字 from
,最后是模块说明符。
模块说明符提供一个字符串,JavaScript 环境可以使用该字符串解析到模块文件的路径。在浏览器中,这可能是相对于站点根目录的路径,对于我们的 basic-modules
示例,该路径将为 /js-examples/module-examples/basic-modules
。但是,这里我们改为使用点 (.
) 语法表示“当前位置”,后跟要查找文件的相对路径。这比每次都写出完整的绝对路径要好得多,因为相对路径更短,并且使 URL 可移植——如果您将其移动到站点层次结构中的其他位置,该示例仍然有效。
例如
/js-examples/module-examples/basic-modules/modules/square.js
变为
./modules/square.js
您可以在 main.js
中看到这些行的实际应用。
注意:在某些模块系统中,您可以使用类似于 modules/square
的模块说明符,它不是相对路径或绝对路径,并且没有文件扩展名。如果您首先定义了 导入映射,则可以在浏览器环境中使用这种说明符。
将功能导入脚本后,就可以像在同一文件中定义它们一样使用它们。以下内容在 main.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
参考。
使用导入映射导入模块
上面我们看到了浏览器如何使用模块说明符导入模块,该说明符要么是绝对 URL,要么是使用文档的基本 URL 解析的相对 URL
import { name as squareName, draw } from "./shapes/square.js";
import { name as circleName } from "https://example.com/shapes/circle.js";
导入映射 允许开发人员在导入模块时在模块说明符中指定几乎任何他们想要的文本;该映射提供一个相应的 value,该 value 将在解析模块 URL 时替换该文本。
例如,下面导入映射中的 imports
键定义了一个“模块说明符映射”JSON 对象,其中属性名称可以用作模块说明符,并且相应的 value 将在浏览器解析模块 URL 时被替换。这些 value 必须是绝对 URL 或相对 URL。相对 URL 使用包含导入映射的文档的 基本 URL 解析为绝对 URL 地址。
<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
。文档中只能有一个导入映射,并且因为它用于解析在静态导入和动态导入中加载哪些模块,所以它必须在任何导入模块的 <script>
元素之前声明。请注意,导入映射仅适用于文档——规范没有涵盖如何在 worker 或 worklet 上下文中应用导入映射。
使用此映射,您现在可以使用上述属性名称作为模块说明符。如果模块说明符键上没有尾随正斜杠,则匹配整个模块说明符键并进行替换。例如,下面我们匹配裸模块名称,并将 URL 重新映射到另一个路径。
// 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";
如果模块说明符具有尾随正斜杠,则 value 也必须具有尾随正斜杠,并且键将匹配为“路径前缀”。这允许重新映射整个 URL 类。
// 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()
静态方法(它本身得到了广泛支持)检查对导入映射的支持
if (HTMLScriptElement.supports?.("importmap")) {
console.log("Browser supports import maps.");
}
将模块作为裸名称导入
在某些 JavaScript 环境(例如 Node.js)中,您可以对模块说明符使用裸名称。这是因为环境可以将模块名称解析到文件系统中的标准位置。例如,您可以使用以下语法导入“square”模块。
import { name, draw, reportArea, reportPerimeter } from "square";
要在浏览器上使用裸名称,您需要一个导入映射,该映射提供浏览器解析模块说明符到 URL 所需的信息(如果 JavaScript 尝试导入无法解析到模块位置的模块说明符,它将抛出 TypeError
)。
下面您可以看到一个映射,它定义了一个 square
模块说明符键,在本例中,该键映射到一个相对地址 value。
<script type="importmap">
{
"imports": {
"square": "./shapes/square.js"
}
}
</script>
使用此映射,我们现在可以在导入模块时使用裸名称
import { name as squareName, draw } from "square";
重新映射模块路径
模块说明符映射条目(其中说明符键及其关联的 value 都有尾随正斜杠 (/
))可以用作路径前缀。这允许将一组完整的导入 URL 从一个位置重新映射到另一个位置。它还可以用于模拟使用“包和模块”,例如您可能在 Node 生态系统中看到的那样。
注意:尾随 /
表示模块说明符键可以作为模块说明符的一部分进行替换。如果没有此项,浏览器将只匹配(并替换)整个模块说明符键。
模块包
以下 JSON 导入映射定义将 lodash
映射为裸名称,并将模块说明符前缀 lodash/
映射到路径 /node_modules/lodash-es/
(解析到文档基本 URL)
{
"imports": {
"lodash": "/node_modules/lodash-es/lodash.js",
"lodash/": "/node_modules/lodash-es/"
}
}
使用此映射,您可以导入整个“包”(使用裸名称)以及其中的模块(使用路径映射)
import _ from "lodash";
import fp from "lodash/fp.js";
可以导入上面的 fp
而无需 .js
文件扩展名,但您需要为此文件创建一个裸模块说明符键,例如 lodash/fp
,而不是使用路径。对于仅一个模块,这可能是合理的,但如果您希望导入许多模块,则扩展性很差。
通用 URL 重新映射
模块说明符键不必是路径——它也可以是绝对 URL(或类似 URL 的相对路径,如 ./
、../
、/
)。如果您想将具有绝对路径的模块重新映射到您自己的本地资源,这可能很有用。
{
"imports": {
"https://www.unpkg.com/moment/": "/node_modules/moment/"
}
}
用于版本管理的作用域模块
像 Node 这样的生态系统使用 npm 等包管理器来管理模块及其依赖项。包管理器确保每个模块与其他模块及其依赖项分离。因此,虽然复杂的应用程序可能在模块图的不同部分包含多次相同模块的多个不同版本,但用户无需考虑这种复杂性。
注意:您还可以使用相对路径实现版本管理,但这效果不佳,因为除其他事项外,这会强制对项目进行特定结构,并阻止您使用裸模块名称。
导入映射同样允许您在应用程序中拥有多个版本的依赖项,并使用相同的模块说明符引用它们。您可以使用 scopes
键实现此功能,该键允许您提供根据执行导入的脚本路径使用的模块说明符映射。以下示例演示了这一点。
{
"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
中的版本。
请注意,用于选择作用域的路径不会影响地址的解析方式。映射路径中的 value 不必与作用域路径匹配,并且相对路径仍将解析到包含导入映射的脚本的基本 URL。
与模块说明符映射一样,您可以拥有多个作用域键,并且这些键可能包含重叠的路径。如果多个作用域匹配引用 URL,则首先检查最具体的作用域路径(最长的作用域键)是否存在匹配的说明符。如果不存在匹配的说明符,浏览器将回退到下一个最具体的匹配作用域路径,依此类推。如果任何匹配的作用域中都没有匹配的说明符,浏览器将在 imports
键中的模块说明符映射中检查匹配项。
通过映射掉哈希文件名来改进缓存
网站使用的脚本文件通常具有哈希文件名以简化缓存。这种方法的缺点是,如果模块发生更改,则使用其哈希文件名导入该模块的任何模块也需要更新/重新生成。这可能会导致级联更新,从而浪费网络资源。
导入映射为此问题提供了一个方便的解决方案。应用程序和脚本不是依赖于特定的哈希文件名,而是依赖于模块名称(地址)的非哈希版本。然后,如下所示的导入映射提供到实际脚本文件的映射。
{
"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 或其他类型的资源,请使用导入属性语法。
import colors from "./colors.json" with { type: "json" };
import styles from "./styles.css" with { type: "css" };
浏览器还会对模块类型进行验证,如果例如./data.json
未解析为 JSON 文件,则会失败。这确保了您在只想导入数据时不会意外执行代码。成功导入后,您现在可以将导入的值用作普通的 JavaScript 对象或CSSStyleSheet
对象。
console.log(colors.map((color) => color.value));
document.adoptedStyleSheets = [styles];
将模块应用于您的 HTML
现在我们只需要将main.js
模块应用到我们的 HTML 页面中。这与我们将常规脚本应用到页面上的方式非常相似,但有一些值得注意的差异。
首先,您需要在<script>
元素中包含type="module"
,以声明此脚本为模块。要导入main.js
脚本,我们使用以下代码:
<script type="module" src="main.js"></script>
您还可以通过将 JavaScript 代码放在<script>
元素的主体中,将模块的脚本直接嵌入到 HTML 文件中。
<script type="module">
/* JavaScript module code here */
</script>
您只能在模块内部使用import
和export
语句,而不能在常规脚本中使用。如果您的<script>
元素没有type="module"
属性并尝试导入其他模块,则会抛出错误。例如:
<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)。
注意:可以通过在具有rel="modulepreloaded"
的<link>
元素中指定模块及其依赖项来预加载它们。当使用这些模块时,这可以显著减少加载时间。
模块与标准脚本的其他差异
- 您需要注意本地测试——如果您尝试在本地加载 HTML 文件(即使用
file://
URL),则由于 JavaScript 模块的安全要求,您会遇到 CORS 错误。您需要通过服务器进行测试。 - 此外,请注意,与标准脚本相比,在模块内定义的脚本部分可能会产生不同的行为。这是因为模块会自动使用严格模式。
- 加载模块脚本时,无需使用
defer
属性(请参阅<script>
属性);模块会自动延迟。 - 即使模块已在多个
<script>
标签中引用,每个模块也只执行一次。 - 最后但并非最不重要的一点,让我们明确一点——模块功能被导入到单个脚本的范围内——它们在全局范围内不可用。因此,您只能在导入它们的脚本中访问导入的功能,例如,您将无法从 JavaScript 控制台访问它们。您仍然会在 DevTools 中看到语法错误,但您将无法使用您可能期望使用的一些调试技巧。
除非显式附加到全局对象,否则模块定义的变量的作用域限定为该模块。另一方面,全局定义的变量在模块内可用。例如,给定以下代码:
<!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>
/* render.js */
document.getElementById("main").innerText = text;
页面仍将呈现Hello
,因为全局变量text
和document
在模块中可用。(从这个例子中还可以注意到,模块不一定需要 import/export 语句——唯一需要的是入口点具有type="module"
。)
默认导出与命名导出
到目前为止,我们导出的功能都是具名导出——每个项目(无论是函数、const
等)在导出时都以其名称引用,并且该名称也用于在导入时引用它。
还有一种称为默认导出的导出类型——这旨在简化模块提供的默认函数,并帮助 JavaScript 模块与现有的 CommonJS 和 AMD 模块系统互操作(如 Jason Orendorff 在ES6 In Depth: Modules中很好地解释的那样;搜索“默认导出”)。
让我们在解释其工作原理时看一个示例。在我们的 basic-modules square.js
中,您可以找到一个名为randomSquare()
的函数,该函数会创建一个具有随机颜色、大小和位置的正方形。我们希望将其导出为默认值,因此在文件底部我们编写以下代码:
export default randomSquare;
请注意缺少花括号。
我们也可以在函数前面加上export default
并将其定义为匿名函数,如下所示:
export default function (ctx) {
// …
}
在我们的main.js
文件中,我们使用以下行导入默认函数:
import randomSquare from "./modules/square.js";
同样,请注意缺少花括号。这是因为每个模块只允许一个默认导出,并且我们知道randomSquare
就是它。以上代码基本上是以下代码的简写:
import { default as randomSquare } from "./modules/square.js";
注意:重命名导出项的 as 语法在下面的重命名导入和导出部分进行了解释。
避免命名冲突
到目前为止,我们的画布形状绘制模块似乎运行良好。但是,如果我们尝试添加一个处理绘制其他形状(如圆形或三角形)的模块会发生什么情况?这些形状可能也会有相关的函数,如draw()
、reportArea()
等;如果我们尝试将相同名称的不同函数导入到同一个顶级模块文件中,最终会导致冲突和错误。
幸运的是,有很多方法可以解决这个问题。我们将在以下部分介绍这些方法。
重命名导入和导出
在您的import
和export
语句的花括号内,您可以使用关键字as
以及一个新的功能名称来更改您在顶级模块中使用的功能的标识名称。
例如,以下两种方法都能完成相同的工作,尽管方式略有不同:
// inside module.js
export { function1 as newFunctionName, function2 as anotherNewFunctionName };
// inside main.js
import { newFunctionName, anotherNewFunctionName } from "./modules/module.js";
// inside module.js
export { function1, function2 };
// inside main.js
import {
function1 as newFunctionName,
function2 as anotherNewFunctionName,
} from "./modules/module.js";
让我们看一个真实的例子。在我们的renaming目录中,您将看到与上一个示例中相同的模块系统,只是我们添加了circle.js
和triangle.js
模块来绘制和报告圆形和三角形。
在每个模块内部,我们都有具有相同名称的功能被导出,因此每个模块在底部都有相同的export
语句:
export { name, draw, reportArea, reportPerimeter };
在将这些导入到main.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)。
相反,我们需要重命名导入,以便它们是唯一的:
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";
请注意,您可以在模块文件中解决此问题,例如:
// in square.js
export {
name as squareName,
draw as drawSquare,
reportArea as reportSquareArea,
reportPerimeter as reportSquarePerimeter,
};
// in main.js
import {
squareName,
drawSquare,
reportSquareArea,
reportSquarePerimeter,
} from "./modules/square.js";
它也会正常工作。您使用哪种风格取决于您自己,但是可以说最好保持模块代码不变,并在导入中进行更改。当您从无法控制的第三方模块导入时,这尤其有意义。
创建模块对象
以上方法可以正常工作,但有点混乱且冗长。更好的解决方案是将每个模块的功能导入模块对象中。以下语法形式可以做到这一点:
import * as Module from "./modules/module.js";
这会获取module.js
内部可用的所有导出,并将它们作为对象Module
的成员提供,有效地为其提供了自己的命名空间。例如:
Module.function1();
Module.function2();
同样,让我们看一个真实的例子。如果您访问我们的module-objects目录,您将看到相同的示例,但已重写以利用此新语法。在模块中,导出都采用以下简单形式:
export { name, draw, reportArea, reportPerimeter };
另一方面,导入看起来像这样:
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";
在每种情况下,您现在都可以访问指定对象名称下的模块导入,例如:
const square1 = Square.draw(myCanvas.ctx, 50, 50, 100, "blue");
Square.reportArea(square1.length, reportList);
Square.reportPerimeter(square1.length, reportList);
因此,您现在可以像以前一样编写代码(只要在需要的地方包含对象名称),并且导入更加简洁。
模块和类
正如我们之前暗示的那样,您还可以导出和导入类;这是避免代码冲突的另一种选择,如果您已经以面向对象的方式编写了模块代码,则尤其有用。
您可以在我们的classes目录中看到使用 ES 类重写的形状绘制模块的示例。例如,square.js
文件现在在其单个类中包含了所有功能:
class Square {
constructor(ctx, listId, length, x, y, color) {
// …
}
draw() {
// …
}
// …
}
然后我们导出它:
export { Square };
在main.js
中,我们像这样导入它:
import { Square } from "./modules/square.js";
然后使用该类绘制我们的正方形:
const square1 = new Square(myCanvas.ctx, myCanvas.listId, 50, 50, 100, "blue");
square1.draw();
square1.reportArea();
square1.reportPerimeter();
聚合模块
有时您需要将模块聚合在一起。您可能有多个级别的依赖项,您希望简化这些依赖项,将多个子模块组合到一个父模块中。这可以使用父模块中以下形式的导出语法来实现:
export * from "x.js";
export { name } from "x.js";
例如,请参阅我们的module-aggregation目录。在这个示例(基于我们之前的类示例)中,我们有一个名为shapes.js
的额外模块,它将circle.js
、square.js
和triangle.js
的所有功能聚合在一起。我们还将子模块移动到modules
目录内的名为shapes
的子目录中。因此,此示例中的模块结构为:
modules/ canvas.js shapes.js shapes/ circle.js square.js triangle.js
在每个子模块中,导出都采用相同形式,例如:
export { Square };
接下来是聚合部分。在shapes.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
文件中,我们可以通过替换以下代码:
import { Square } from "./modules/square.js";
import { Circle } from "./modules/circle.js";
import { Triangle } from "./modules/triangle.js";
使用以下单行代码来访问所有三个模块类:
import { Square, Circle, Triangle } from "./modules/shapes.js";
动态模块加载
JavaScript 模块功能的最新添加是动态模块加载。这允许您仅在需要时动态加载模块,而不是必须预先加载所有内容。这有一些明显的性能优势;让我们继续阅读并了解其工作原理。
此新功能允许您将import()
作为函数调用,并将模块路径作为参数传递给它。它返回一个Promise
,该 Promise 会返回一个模块对象(请参阅创建模块对象),使您可以访问该对象的导出内容。例如
import("./modules/myModule.js").then((module) => {
// Do something with the module.
});
注意:动态导入在浏览器主线程以及共享和专用工作线程中是允许的。但是,如果在服务工作线程或工作线程中调用import()
,则会抛出错误。
让我们来看一个例子。在dynamic-module-imports 目录中,我们还有另一个基于类示例的示例。但是,这次我们在示例加载时不会在画布上绘制任何内容。相反,我们包含三个按钮——“圆形”、“正方形”和“三角形”——当按下这些按钮时,会动态加载所需的模块,然后使用它来绘制相关的形状。
在此示例中,我们仅对index.html
和main.js
文件进行了更改——模块导出与之前相同。
在main.js
中,我们使用document.querySelector()
调用获取了每个按钮的引用,例如
const squareBtn = document.querySelector(".square");
然后,我们为每个按钮附加一个事件监听器,以便在按下时,动态加载相关的模块并使用它来绘制形状
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 的 fulfilled 返回一个模块对象,因此类成为该对象的子特性,因此现在需要使用Module.
作为前缀来访问构造函数,例如Module.Square( /* … */ )
。
动态导入的另一个优点是它们始终可用,即使在脚本环境中也是如此。因此,如果您在 HTML 中有一个现有的<script>
标签,该标签没有type="module"
,您仍然可以通过动态导入来重用作为模块分发的代码。
<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
文件中声明我们的调色板
{
"yellow": "#F4D03F",
"green": "#52BE80",
"blue": "#5499C7",
"red": "#CD6155",
"orange": "#F39C12"
}
然后,我们将创建一个名为getColors.js
的模块,该模块使用 fetch 请求加载colors.json
文件并将数据作为对象返回。
// fetch request
const colors = fetch("../data/colors.json").then((response) => response.json());
export default await colors;
请注意此处最后一行导出。
我们在指定要导出的常量colors
之前使用了关键字await
。这意味着包含此模块的任何其他模块都将等待下载和解析colors
,然后再使用它。
让我们在main.js
文件中包含此模块
import colors from "./modules/getColors.js";
import { Canvas } from "./modules/canvas.js";
const circleBtn = document.querySelector(".circle");
// …
在调用形状函数时,我们将使用colors
代替以前使用的字符串
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
的同时继续加载。
导入声明被提升
导入声明被提升。在这种情况下,这意味着导入的值在模块代码中可用,甚至在声明它们的位置之前,并且导入模块的副作用在模块其余代码开始运行之前产生。
例如,在main.js
中,在代码中间导入Canvas
仍然可以工作
// …
const myCanvas = new Canvas("myCanvas", document.body, 480, 320);
myCanvas.create();
import { Canvas } from "./modules/canvas.js";
myCanvas.createReportList();
// …
尽管如此,将所有导入放在代码顶部仍然被认为是一种良好的实践,这使得分析依赖项变得更容易。
循环导入
模块可以导入其他模块,而这些模块可以导入其他模块,依此类推。这形成了一个称为“依赖关系图”的有向图。在理想情况下,此图是无环的。在这种情况下,可以使用深度优先遍历来评估该图。
但是,循环通常是不可避免的。如果模块a
导入模块b
,但b
直接或间接依赖于a
,则会出现循环导入。例如
// -- a.js --
import { b } from "./b.js";
// -- b.js --
import { a } from "./a.js";
// Cycle:
// a.js ───> b.js
// ^ │
// └─────────┘
循环导入并不总是失败。仅在实际使用变量时才会检索导入变量的值(因此允许实时绑定),并且仅当变量在此时仍未初始化时,才会抛出ReferenceError
。
// -- 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;
在此示例中,a
和b
都是异步使用的。因此,在评估模块时,实际上既没有读取b
也没有读取a
,因此代码的其余部分按正常方式执行,并且两个export
声明产生a
和b
的值。然后,在超时之后,a
和b
都可用,因此两个console.log
语句也按正常方式执行。
如果将代码更改为同步使用a
,则模块评估将失败
// -- 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
,则模块评估将成功
// -- 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
提供的 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 的互操作性。
故障排除
如果您在使模块工作时遇到问题,以下是一些可能有帮助的提示。如果您发现更多内容,请随时添加到列表中!
- 我们之前提到过这一点,但需要重申:
.mjs
文件需要使用text/javascript
的 MIME 类型加载(或其他与 JavaScript 兼容的 MIME 类型,但建议使用text/javascript
),否则您将收到严格的 MIME 类型检查错误,例如“服务器响应了一个非 JavaScript MIME 类型”。 - 如果您尝试在本地加载 HTML 文件(即使用
file://
URL),则由于 JavaScript 模块安全要求,您将遇到 CORS 错误。您需要通过服务器进行测试。GitHub Pages 是理想之选,因为它还使用正确的 MIME 类型提供.mjs
文件。 - 由于
.mjs
是非标准的文件扩展名,因此某些操作系统可能无法识别它,或者尝试将其替换为其他内容。例如,我们发现 macOS 正在静默地将.js
添加到.mjs
文件的末尾,然后自动隐藏文件扩展名。因此,我们所有的文件实际上都变成了x.mjs.js
。在我们关闭自动隐藏文件扩展名并训练它接受.mjs
后,它就正常了。
另请参阅
- JavaScript 模块 on v8.dev (2018)
- ES 模块:卡通深度解读 on hacks.mozilla.org (2018)
- ES6 深入:模块 on hacks.mozilla.org (2015)
- 探索 JS,第 16 章:模块 by Dr. Axel Rauschmayer