ES 模块语法
1、模块化的背景
JavaScript 程序本来很小------在早期,它们大多被用来执行独立的脚本任务,在你的 web 页面需要的地方提供一定交互,所以一般不需要多大的脚本。过了几年,我们现在有了运行大量 JavaScript 脚本的复杂程序,还有一些被用在其他环境(例如 Node.js)。
因此,近年来,有必要开始考虑提供一种将 JavaScript 程序拆分为可按需导入的单独模块的机制 。Node.js 已经提供这个能力很长时间了,还有很多的 JavaScript 库和框架已经开始了模块的使用 (例如,CommonJS 和基于 AMD 的其他模块系统 如 RequireJS,以及最新的 Webpack 和 Babel)。
好消息是,最新的浏览器开始原生支持模块功能了 ,这是本文要重点讲述的。这会是一个好事情 ---- 浏览器能够最优化加载模块,使它比使用库更有效率:使用库通常需要做额外的客户端处理。
2、介绍一个例子
为了演示模块的使用,我们创建了一系列简单的示例 ,你可以在 GitHub 上找到。这个例子演示了一个简单的模块的集合用来在 web 页面上创建了一个 <canvas>
标签,在 canvas 上绘制 (并记录有关的信息) 不同形状。
备注: 如果你想去下载这个例子在本地运行,你需要通过本地 web 服务器去运行。
3、基本的示例文件的结构
在我们的第一个例子(参见 basic-modules)文件结构如下:
javascript
index.html
main.js
modules/
canvas.js
square.js
备注: 在这个指南的全部示例项目的文件结构是基本相同的;需要熟悉上面的内容
modules 目录下的两个模块的描述如下:
canvas.js
--- 包含与设置画布相关的功能:create()
--- 在指定 ID 的包装器<div>
内创建指定 width 和 height 的画布,该 ID 本身附加在指定的父元素内。返回包含画布的 2D 上下文和包装器 ID 的对象。createReportList()
--- 创建一个附加在指定包装器元素内的无序列表,该列表可用于将报告数据输出到。返回列表的 ID。
square.js
--- 包含:name
--- 包含字符串 'square' 的常量。draw()
--- 在指定画布上绘制一个正方形,具有指定的大小,位置和颜色。返回包含正方形大小,位置和颜色的对象。reportArea()
--- 在给定长度的情况下,将正方形区域写入特定报告列表。reportPerimeter()
--- 在给定长度的情况下,将正方形的周长写入特定的报告列表。
备注: 在原生 JavaScript 模块中,扩展名
.mjs
非常重要,因为使用 MIME-type 为javascript/esm
来导入文件(其他的 JavaScript 兼容 MIME-type 像application/javascript
也可以),它避免了严格的 MIME 类型检查错误,像 "The server responded with a non-JavaScript MIME type"。除此之外,.mjs
的扩展名很明了(比如这个就是一个模块,而不是一个传统 JavaScript 文件),还能够和其他工具互相适用。看这个 Google's note for further details。
4、.mjs
与 .js
纵观此文,我们使用 .js
扩展名的模块文件,但在其他一些文章中,你可能会看到.mjs
扩展名的使用。V8 推荐了这样的做法,比如有下列理由:
- 比较清晰,这可以指出哪些文件是模块,哪些是常规的 JavaScript。
- 这能保证你的模块可以被运行时环境和构建工具识别,比如 Node.js 和 Babel。
但是我们决定继续使用 .js
扩展名,未来可能会更改。为了使模块可以在浏览器中正常地工作,你需要确保你的服务器能够正常地处理 Content-Type 头,其应该包含 JavaScript 的 MIME 类型 text/javascript
。如果没有这么做,你可能会得到 一个严格 MIME 类型检查错误:"The server responded with a non-JavaScript MIME type(服务器返回了非 JavaScript MIME 类型)",并且浏览器会拒绝执行相应的 JavaScript 代码。多数服务器可以正确地处理 .js
文件的类型,但是 .mjs
还不行 。已经可以正常响应 .mjs
的服务器有 GitHub 页面 和 Node.js 的 http-server。
如果你已经在使用相应的环境了,那么一切正常。或者如果你还没有,但你知道你在做什么(比如你可以配置服务器以为 .mjs
设置正确的 Content-Type)。但如果你不能控制提供服务,或者用于公开文件发布的服务器,这可能会导致混乱。
为了学习和保证代码的可移植的目的,我们建议使用 .js
。
如果你认为使用 .mjs
仅用于模块带来的清晰性非常重要,但不想引入上面描述的相应问题,你可以仅在开发过程中使用 .mjs
,而在构建过程中将其转换为 .js
。
另注意:
- 一些工具不支持
.mjs
,比如 TypeScript。 <script type="module">
属性用于指示引入的模块,你会在下面看到。
5、导出模块的功能
为了获得模块的功能要做的第一件事是把它们导出来。使用 export 语句来完成。
最简单的方法是把它(指上面的 export 语句)放到你想要导出的项前面,比如:
javascript
export const name = "square";
export function draw(ctx, length, x, y, color) {
ctx.fillStyle = color;
ctx.fillRect(x, y, length, length);
return {
length: length,
x: x,
y: y,
color: color,
};
}
你能够导出函数,var,let,const, 和等会会看到的类。export 要放在最外层;比如你不能够在函数内使用 export。
一个更方便的方法导出所有你想要导出的模块的方法是在模块文件的末尾使用一个 export 语句,语句是用花括号括起来的用逗号分割的列表。比如:
javascript
export { name, draw, reportArea, reportPerimeter };
6、导入功能到你的脚本
你想在模块外面使用一些功能,那你就需要导入他们才能使用。最简单的就像下面这样的:
javascript
import { name, draw, reportArea, reportPerimeter } from '/js-examples/modules/basic-modules/modules/square.js';
使用 import 语句,然后你被花括号包围的用逗号分隔的你想导入的功能列表,然后是关键字 from,然后是模块文件的路径 。模块文件的路径是相对于站点根目录的相对路径,对于我们的 basic-modules 应该是 /js-examples/modules/basic-modules
。
当然,我们写的路径有一点不同------我们使用点语法意味"当前路径" ,跟随着包含我们想要找的文件的路径。这比每次都要写下整个相对路径要好得多,因为它更短,使得 URL 可移植------如果在站点层中你把它移动到不同的路径下面仍然能够工作(修订版 1889482)。
那么看看例子吧:
javascript
/js/examples/modules/basic-modules/modules/square.js
变成了
javascript
./modules/square.js
你可以在 main.js 中看到这些。
备注: 在一些模块系统中你可以忽略文件扩展名(比如 '/model/squre')。这在原生 JavaScript 模块系统中不工作。此外,记住你需要包含最前面的正斜杠。 (修订版 1889482)
因为你导入了这些功能到你的脚本文件,你可以像定义在相同的文件中的一样去使用它。下面展示的是在 main.js
中的 import 语句下面的内容。
javascript
let myCanvas = create("myCanvas", document.body, 480, 320);
let reportList = createReportList(myCanvas.id);
let square1 = draw(myCanvas.ctx, 50, 50, 100, "blue");
reportArea(square1.length, reportList);
reportPerimeter(square1.length, reportList);
7、使用导入映射导入模块
上面我们看到了浏览器如何使用模块说明符导入模块,模块说明符可以是绝对URL,也可以是使用文档的基URL解析的相对URL:
javascript
import { name as squareName, draw } from "./shapes/square.js";
import { name as circleName } from "https://example.com/shapes/circle.js";
导入映射允许开发人员在导入模块时在模块说明符中指定几乎任何他们想要的文本; map提供一个相应的值,该值将在解析模块URL时替换该文本。
例如,下面导入映射中的imports
键定义了一个"模块说明符映射"JSON对象,其中的属性名可以用作模块说明符,当浏览器解析模块URL时,相应的值将被替换 。取值必须是绝对url或相对url。使用包含导入映射的文档的base URL,将相对URL解析为绝对URL地址。
javascript
<script type="importmap">
{
"imports": {
"shapes": "./shapes/square.js",
"shapes/square": "./modules/shapes/square.js",
"https://example.com/shapes/": "/shapes/square/",
"https://example.com/shapes/square.js": "./shapes/square.js",
"../shapes/square": "./shapes/square.js"
}
}
</script>
导入映射是使用<script>
元素中的JSON对象定义的,type 属性设置为 importmap 。文档中只能有一个导入映射 ,因为它用于解析在静态和动态导入中加载哪些模块,所以必须在导入模块的任何<script>
元素之前声明它。
有了这个映射,您现在可以使用上面的属性名作为模块说明符 。如果在模块说明符键上没有尾正斜杠,则匹配并替换整个模块说明符键
。例如,下面我们匹配全部模块名,并将URL重新映射到另一个路径。
javascript
// 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/moduleshapes/square.js";
如果模块说明符有一个尾正斜杠,那么值也必须有一个,并且键被匹配为"路径前缀"。这允许重新映射整个url类。
javascript
// Remap a URL as a prefix ( https://example.com/shapes/)
import { name as squareNameFour } from "https://example.com/shapes/square.js";
导入映射中的多个键可能是模块说明符的有效匹配项 。例如,模块说明符shapes/circle/
可以匹配模块说明符键shapes/
和shapes/circle/
。在这种情况下,浏览器将选择最具体(最长)匹配的模块说明符键。
导入映射允许使用裸模块名导入模块(如Node.js),还可以模拟从包中导入模块,无论是否带文件扩展名。虽然上面没有显示,但它们还允许根据导入模块的脚本的路径导入库的特定版本。通常,它们让开发人员编写更符合人体工程学的导入代码,并使管理站点使用的模块的不同版本和依赖关系变得更容易。这可以减少在浏览器和服务器中使用相同JavaScript库所需的工作量。
下面几节将对上面列出的各种特性进行扩展。
7.1 特征检测
你可以使用HTMLScriptElement.supports()静态方法(它本身被广泛支持)来检查对导入映射的支持:
7.2 以单个名称(bare names)导入模块
在某些JavaScript环境中,例如Node.js,您可以使用裸名作为模块说明符。这是有效的,因为环境可以将模块名称解析到文件系统中的标准位置。例如,您可以使用以下语法导入"square"模块。
javascript
import { name, draw, reportArea, reportPerimeter } from "square";
要在浏览器上使用裸名,你需要一个导入映射,它提供了浏览器将模块说明符解析为url所需的信息 (JavaScript如果试图导入一个无法解析为模块位置的模块说明符,将抛出TypeError)。
下面您可以看到一个映射,它定义了一个square
模块说明符键,在本例中映射到一个相对地址值。
javascript
<script type="importmap">
{
"imports": {
"square": "./shapes/square.js"
}
}
</script>
有了这个映射,我们现在可以在导入模块时使用裸名:
javascript
import { name as squareName, draw } from "square";
7.3 重新映射模块路径
模块说明符映射项,其中说明符键及其关联值都有一个尾斜杠(/
),可以用作路径前缀 。这允许将一整套导入url从一个位置重新映射到另一个位置
。它还可以用于模拟"包和模块"的工作,例如您可能在Node生态系统中看到的。
注意:后面的
/
表示模块说明符键可以被替换为模块说明符的一部分。如果不存在,浏览器将只匹配(并替换)整个模块说明符键。
7.4 模块包
下面的JSON导入映射定义将lodash
映射为一个裸名称,并将模块说明符前缀lodash/
映射到路径/node_modules/lodash-es/
(解析为文档基URL):
javascript
{
"imports": {
"lodash": "/node_modules/lodash-es/lodash.js",
"lodash/": "/node_modules/lodash-es/"
}
}
通过这个映射,你可以导入整个"包"(使用裸名)和其中的模块(使用路径映射):
javascript
import _ from "lodash";
import fp from "lodash/fp.js";
可以在没有.js
文件扩展名的情况下导入上面的fp
,但是您需要为该文件创建一个裸模块说明符键,例如lodash/fp
,而不是使用路径。对于一个模块来说,这可能是合理的,但是如果您希望导入许多模块,则伸缩性很差。
7.5 常规URL重映射
模块说明符键不一定是path------它也可以是绝对URL(或类似URL的相对路径,如./
,../
, /
)。如果您希望将具有绝对路径的模块重新映射到具有您自己的本地资源的资源,这可能会很有用。
javascript
{
"imports": {
"https://www.unpkg.com/moment/": "/node_modules/moment/"
}
}
7.6 用于版本管理的限定范围模块
像Node这样的生态系统使用像npm这样的包管理器来管理模块及其依赖关系。包管理器确保每个模块与其他模块及其依赖关系分离。因此,虽然复杂的应用程序可能在模块图的不同部分多次包含相同的模块,并使用几个不同的版本,但用户不需要考虑这种复杂性。
注意:您也可以使用相对路径来实现版本管理,但这是不合格的,因为,除其他事项外,这会在您的项目中强制使用特定的结构,并阻止您使用裸模块名称。
类似地,导入映射允许您在应用程序中拥有依赖项的多个版本,并使用相同的模块说明符引用它们 。您可以使用scopes
键来实现这一点,它允许您提供模块说明符映射,这些映射将根据执行导入的脚本的路径来使用。下面的示例演示了这一点。
javascript
{
"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
中的模块说明符map将被使用,映射到/node_modules/coolmodule/index.js
中的版本。
请注意,用于选择作用域的路径不会影响地址解析的方式。映射路径中的值不必与作用域路径匹配,相对路径仍然被解析为包含导入映射的脚本的基本URL。
就像模块说明符映射一样,您可以有许多范围键,这些键可能包含重叠的路径 。如果多个作用域与引用者URL匹配,则首先检查匹配说明符的最特定的作用域路径(最长的作用域键)。如果没有匹配说明符,浏览器将退回到下一个最具体的匹配范围路径,依此类推。如果在任何匹配范围中都没有匹配的说明符,浏览器将检查imports
键中的模块说明符映射中的匹配项。
7.7 通过映射散列文件名来改进缓存
网站使用的脚本文件通常有散列文件名,以简化缓存。这种方法的缺点是,如果一个模块发生变化,任何使用其散列文件名导入它的模块也需要更新/重新生成。这可能导致更新的级联,这是网络资源的浪费。
导入映射为这个问题提供了一个方便的解决方案。应用程序和脚本不是依赖于特定的散列文件名,而是依赖于模块名称(地址)的未散列版本。然后,像下面这样的导入映射提供了到实际脚本文件的映射。
javascript
{
"imports": {
"main_script": "/node/srcs/application-fg7744e1b.js",
"dependency_script": "/node/srcs/dependency-3qn7e4b1q.js"
}
}
如果dependency_script
更改,那么文件名中包含的哈希值也会更改。在这种情况下,我们只需要更新导入映射来反映模块的更改名称。我们不需要更新任何依赖于它的JavaScript代码的源代码,因为import语句中的说明符没有改变。
8、应用模块到你的 HTML
现在我们只需要将 main.js
模块应用到我们的 HTML 页面。这与我们将常规脚本应用于页面的方式非常相似,但有一些显着的差异。
首先,你需要把 type="module"
放到 <script>
标签中,来声明这个脚本是一个模块:
javascript
<script type="module" src="main.js"></script>
你导入模块功能的脚本基本是作为顶级模块。如果省略它,Firefox 就会给出错误"SyntaxError: import declarations may only appear at top level of a module。
你只能在模块内部使用 import
和export
语句;不是普通脚本文件。
备注: 你还可以将模块导入内部脚本,只要包含 type="module",例如
<script type="module"> //include script here </script>
。
注意:模块和它们的依赖可以通过在<link>
元素中用rel="modulepreloaded"
指定它们来预加载。这可以显著减少使用模块时的加载时间。
9、其他模块与标准脚本的不同
- 你需要注意本地测试------如果你通过本地加载 HTML 文件(比如一个 file:// 路径的文件),你将会遇到 CORS 错误,因为 JavaScript 模块安全性需要。你需要通过一个服务器来测试。
- 另请注意,你可能会从模块内部定义的脚本部分获得与标准脚本中不同的行为。这是因为模块自动使用严格模式。
- 加载一个模块脚本时不需要使用
defe
r 属性 (see<script>
attributes) 模块会自动延迟加载。 - 最后一个但不是不重要,你需要明白模块功能导入到单独的脚本文件的范围------他们无法在全局获得。因此,你只能在导入这些功能的脚本文件中使用他们,你也无法通过 JavaScript console 中获取到他们,比如,在 DevTools 中你仍然能够获取到语法错误,但是你可能无法像你想的那样使用一些 debug 技术。
模块定义的变量的作用域为模块,除非显式附加到全局对象 。另一方面,全局定义的变量可以在模块中使用。例如,给定以下代码:
javascript
<!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>
javascript
/* render.js */
document.getElementById("main").innerText = text;
页面仍然会呈现Hello
,因为模块中有全局变量text
和document
。(还请注意,从这个例子中,模块不一定需要import/export语句-唯一需要的是入口点具有`type="module"。)
10、默认导出与命名导出
到目前为止,我们导出的功能都是由命名导出(named exports
)组成的 ------每个项(无论是函数、const等)在导出时都通过其名称被引用,在导入时也使用该名称来引用它
。
还有一种类型的导出称为默认导出(default export
) -这是为了让模块提供默认函数变得容易,也有助于JavaScript模块与现有的CommonJS和AMD模块系统进行互操作(正如Jason Orendorff在ES6 in Depth: modules中所做的很好的解释;搜索"默认导出")。
让我们看一个例子来解释它是如何工作的。在我们的基本模块square.js
中,你可以找到一个名为randomSquare()
的函数,它可以创建一个具有随机颜色、大小和位置的正方形。我们想把它作为默认值导出,所以在文件的底部我们这样写:
javascript
export default randomSquare;
注意没有花括号。
我们可以在函数前添加export default
,并将其定义为匿名函数,如下所示:
javascript
export default function (ctx) {
// ...
}
在main.js
文件中,我们用下面这行导入默认函数:
javascript
import randomSquare from "./modules/square.js";
再次注意,这里没有花括号。这是因为每个模块只允许一个默认导出 ,我们知道randomSquare
就是它。上面这行基本上是以下内容的简写:
javascript
import { default as randomSquare } from "./modules/square.js";
注意:重命名导出项的as语法将在下面的重命名导入和导出部分中进行解释。
11、避免命名冲突
到目前为止,我们的画布形状绘制模块似乎工作正常。但是,如果我们尝试添加一个处理绘制另一个形状(如圆形或三角形)的模块,会发生什么呢?这些形状可能也有相关的函数,如draw()
, reportArea()
等;如果我们试图将同名的不同函数导入到相同的顶级模块文件中,就会出现冲突和错误。
幸运的是,有很多方法可以解决这个问题。我们将在下面几节中讨论这些问题。
12、重命名导入和导出
在import
和export
语句的花括号中,您可以将关键字as
与新特性名称一起使用,以更改将用于顶级模块中的特性的标识名称。
例如,下面两个都可以完成相同的工作,尽管方式略有不同:
javascript
// inside module.js
export { function1 as newFunctionName, function2 as anotherNewFunctionName };
// inside main.js
import { newFunctionName, anotherNewFunctionName } from "./modules/module.js";
javascript
// 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
语句:
javascript
export { name, draw, reportArea, reportPerimeter };
当将这些导入到main.js
中时,如果我们尝试使用
javascript
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)。
相反,我们需要重命名导入,使它们是唯一的:
javascript
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";
请注意,您可以在模块文件中解决问题,例如:
javascript
// in square.js
export {
name as squareName,
draw as drawSquare,
reportArea as reportSquareArea,
reportPerimeter as reportSquarePerimeter,
};
javascript
// in main.js
import {
squareName,
drawSquare,
reportSquareArea,
reportSquarePerimeter,
} from "./modules/square.js";
结果是一样的。使用什么风格由您决定,但是保留模块代码并在导入中进行更改可能更有意义。当您从无法控制的第三方模块导入时,这尤其有意义。
13、创建模块对象
上面的方法可以工作,但它有点混乱和冗长。一个更好的解决方案是在模块对象中导入每个模块的特性。下面的语法形式做到了这一点:
javascript
import * as Module from "./modules/module.js";
这会获取module.js
内部可用的所有导出,并使它们作为对象Module
的成员可用,从而有效地为其提供自己的命名空间。举个例子:
javascript
Module.function1();
Module.function2();
让我们再看一个真实的例子。如果您转到我们的module-objects目录,您将再次看到相同的示例,但是为了利用这种新语法而进行了重写。在模块中,导出都采用以下简单形式:
javascript
export { name, draw, reportArea, reportPerimeter };
另一方面,导入是这样的:
javascript
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";
在每种情况下,你现在都可以在指定的对象名称下访问模块的导入,例如:
javascript
const square1 = Square.draw(myCanvas.ctx, 50, 50, 100, "blue");
Square.reportArea(square1.length, reportList);
Square.reportPerimeter(square1.length, reportList);
因此,您现在可以像以前一样编写代码(只要在需要的地方包含对象名称),并且导入更加整洁。
14、模块和类
正如我们前面所暗示的,您还可以导出和导入类;这是避免代码冲突的另一种选择,如果您已经以面向对象的风格编写了模块代码,则特别有用。
你可以在我们的classes目录中看到一个用ES类重写的图形绘制模块的例子。例如,square.js
文件现在在一个类中包含了它的所有功能:
javascript
class Square {
constructor(ctx, listId, length, x, y, color) {
// ...
}
draw() {
// ...
}
// ...
}
然后导出:
javascript
export { Square };
在main.js
中,我们像这样导入它:
javascript
import { Square } from "./modules/square.js";
然后使用类来绘制我们的正方形:
javascript
const square1 = new Square(myCanvas.ctx, myCanvas.listId, 50, 50, 100, "blue");
square1.draw();
square1.reportArea();
square1.reportPerimeter();
15、聚合模块
有时需要将模块聚合在一起。您可能有多个级别的依赖关系,您希望简化事情,将几个子模块组合成一个父模块。这可以在父模块中使用以下表单的export语法:
javascript
export * from "x.js";
export { name } from "x.js";
例如,请参阅我们的模块 module-aggregation。在这个例子中(基于我们之前的类的例子),我们有一个额外的模块叫做shape.js
,它聚集了circle.js
, square.js
和triangle.js
的所有功能。我们还将我们的子模块移到了modules 目录下名为shapes的子目录中。所以这个例子中的模块结构是:
javascript
modules/
canvas.js
shapes.js
shapes/
circle.js
square.js
triangle.js
在每个子模块中,导出都是相同的形式,例如:
javascript
export { Square };
接下来是聚合部分。在shapes.js
中,我们包含了以下几行:
javascript
export { Square } from "./shapes/square.js";
export { Triangle } from "./shapes/triangle.js";
export { Circle } from "./shapes/circle.js";
它们从各个子模块中获取导出,并有效地使它们从shapes.js
模块中可用。
注意:
shape.js
中引用的导出基本上通过文件被重定向,并且实际上不存在,因此您将无法在同一文件中编写任何有用的相关代码。
现在在main.js
文件中,我们可以通过用下面这一行:
javascript
import { Square, Circle, Triangle } from "./modules/shapes.js";
替换
javascript
import { Square } from "./modules/square.js";
import { Circle } from "./modules/circle.js";
import { Triangle } from "./modules/triangle.js";
来访问所有三个模块类
16、动态模块加载
JavaScript模块最近新增的功能是动态模块加载。这允许您仅在需要时动态加载模块,而不必预先加载所有内容。这有一些明显的性能优势;让我们继续读下去,看看它是如何工作的。
这个新功能允许您将import()作为函数调用,并将路径作为参数传递给模块。它返回一个Promise,它用一个模块对象来实现(参见创建模块对象),让你可以访问该对象的导出。例如:
javascript
import("./modules/myModule.js").then((module) => {
// Do something with the module.
});
注意:允许在浏览器主线程、共享和专用工作线程中进行动态导入。然而,如果在service worker或worklet中调用import(),就会抛出异常。
让我们来看一个例子。在dynamic-module-imports目录中,我们有另一个基于我们的类示例的示例。然而,这一次,当示例加载时,我们不会在画布上绘制任何东西。相反,我们包含了三个按钮------"Circle"、"Square"和"Triangle"------当按下它们时,会动态加载所需的模块,然后使用它来绘制相关的形状。
在这个例子中,我们只修改了index.html
和main.js
文件------模块导出和以前一样。
在main.js
中,我们使用document.querySelector()
调用获取了对每个按钮的引用,例如:
javascript
const squareBtn = document.querySelector(".square");
然后,我们为每个按钮附加一个事件监听器,这样当按下按钮时,相关模块就会被动态加载并用于绘制形状:
javascript
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( /* ... */ )
。
动态导入的另一个优点是它们总是可用的,即使在脚本环境中也是如此 。因此,如果你的HTML中有一个现有的<script>
标签,它没有type="module"
,你仍然可以通过动态导入它来重用作为模块分发的代码。
javascript
<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>
17、顶层 await
顶层await
是模块中可用的一个特性。这意味着可以使用await
关键字。它允许模块充当大型异步函数,这意味着代码可以在父模块中使用之前进行评估,但不会阻止兄弟模块加载。
让我们来看一个例子。您可以在top-level-await目录中找到本节中描述的所有文件和代码,该目录从前面的示例扩展而来。
首先,我们将以单独的colors.json
声明调色板文件:
javascript
{
"yellow": "#F4D03F",
"green": "#52BE80",
"blue": "#5499C7",
"red": "#CD6155",
"orange": "#F39C12"
}
然后我们将创建一个名为getColors.js的模块,它使用fetch请求来加载colors.json
文件并将数据作为对象返回。
javascript
// fetch request
const colors = fetch("../data/colors.json").then((response) => response.json());
export default await colors;
注意这里的最后一个导出行。
在指定要导出的常量colors
之前,我们使用了关键字await
。这意味着任何其他模块,包括这个将等待,直到colors
已经下载和解析之前使用它。
让我们把这个模块包含在main.js
文件中:
javascript
import colors from "./modules/getColors.js";
import { Canvas } from "./modules/canvas.js";
const circleBtn = document.querySelector(".circle");
// ...
在调用形状函数时,我们将使用colors
而不是之前使用的字符串:
javascript
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
时继续加载。
18、导入声明被提升
导入声明被提升。在这种情况下,这意味着导入的值甚至在声明它们的行之前就可以在模块的代码中使用,并且导入的模块的副作用在模块的其余代码开始运行之前就产生了。
例如,在main.js
中,在代码中间导入Canvas
仍然可以工作:
javascript
// ...
const myCanvas = new Canvas("myCanvas", document.body, 480, 320);
myCanvas.create();
import { Canvas } from "./modules/canvas.js";
myCanvas.createReportList();
// ...
尽管如此,将所有导入放在代码的顶部被认为是一种良好的实践,这使得分析依赖关系变得更加容易。
19、循环导入
模块可以导入其他模块,这些模块可以导入其他模块,以此类推。这形成了一个被称为"依赖图"的有向图。在理想情况下,这个图是非循环的。在这种情况下,可以使用深度优先遍历来评估图。
然而,循环往往是不可避免的。如果模块a导入模块b,,但b直接或间接依赖于模块a,则会出现循环导入。例如:
javascript
// -- a.js --
import { b } from "./b.js";
// -- b.js --
import { a } from "./a.js";
// Cycle:
// a.js ───> b.js
// ^ │
// └─────────┘
循环导入并不总是失败 。导入的变量的值只有在实际使用时才会被检索(因此允许实时绑定)
,并且只有当变量在那时保持未初始化时才会抛出ReferenceError。
javascript
// -- 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,因此其余代码照常执行,两个导出声明生成a和b的值。然后,超时后,a和b都可用,因此两个console.log
语句也照常执行。
如果您将代码更改为使用a同步,则模块计算失败:
javascript
// -- 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
,目前还不可用。
另一方面,如果你将代码改为同步使用b而异步使用a,则模块求值成功:
javascript
// -- 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的值是可用的。
通常应该避免在项目中进行循环导入,因为这会使代码更容易出错。一些常见的循环消除技术是:
- 将两个模块合并为一个。
- 将共享代码移动到第三个模块中。
- 将一些代码从一个模块移动到另一个模块。
但是,如果库相互依赖,也可能发生循环导入,这很难修复。
20、创建"同构"模块
模块的引入鼓励JavaScript生态系统以模块化的方式分发和重用代码。但是,这并不一定意味着一段JavaScript代码可以在任何环境中运行。假设您发现了一个模块,该模块生成用户密码的SHA散列。你能在浏览器前端使用它吗?你能在你的Node.js服务器上使用它吗?答案是:视情况而定。
如前所述,模块仍然可以访问全局变量。如果模块引用像window
这样的全局变量,它可以在浏览器中运行,但会在Node.js服务器中抛出错误,因为那里没有window
可用。类似地,如果代码需要访问process
才能正常工作,那么它只能在Node.js中使用。
为了最大限度地提高模块的可重用性,通常建议让代码"同构"------也就是说,在每个运行时都显示相同的行为。这通常通过三种方式实现:
- 把你的模块分成
core
和binding
。对于"core
",专注于纯JavaScript逻辑,如计算哈希,不需要任何DOM、网络、文件系统访问和公开实用程序函数 。对于"binding
"部分,您可以读取和写入全局上下文 。例如,"浏览器绑定"可能选择从输入框中读取值,而"Node绑定"可能从process.env
中读取值。但是从任何地方读取的值都将通过管道传输到相同的核心函数,并以相同的方式处理。核心可以在每个环境中导入并以相同的方式使用,而只有绑定(通常是轻量级的)需要特定于平台。 - 在使用特定全局变量之前检测它是否存在 。例如,如果测试
typeof window === "undefined"
,您就知道您可能处于Node.js环境中,不应该读取DOM。
javascript
// 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;
}
如果两个分支实际上最终具有相同的行为("同构"),则更可取。如果不可能提供相同的功能,或者这样做需要加载大量代码,而大部分代码仍未使用,那么最好使用不同的"绑定"。
- 使用多边形填充来为缺失的特征提供回退。例如,如果你想使用fetch函数,它只在v18版本的Node.js中被支持,你可以使用类似的API,就像node-fetch提供的那样。您可以通过动态导入有条件地这样做:
javascript
// 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的互操作性。
21、故障排除
如果您在使模块工作时遇到困难,这里有一些提示可能会对您有所帮助。如果你发现更多,请随时添加到列表中!
- 我们之前提到过这一点,但要重申:
.mjs
文件加载需要一个MIME类型的text/javascript
(或其他javascript兼容的MIME类型,但推荐text/javascript
),否则你会得到一个严格的MIME类型检查错误,如"服务器响应了一个非javascript MIME类型"。 - 如果您尝试在本地加载HTML文件(即使用
file://
URL),由于JavaScript模块的安全要求,您将遇到CORS错误。您需要通过服务器进行测试。GitHub页面是理想的,因为它也提供具有正确MIME类型的.mjs
文件。 - 因为
.mjs
是一个非标准的文件扩展名,所以一些操作系统可能无法识别它,或者试图用其他东西替换它。例如,我们发现macOS会悄悄地在.mjs
文件的末尾添加.js
,然后自动隐藏文件扩展名。所有文件都以x.mjs.js
的形式输出。一旦我们关闭了自动隐藏文件扩展名,并训练它接受.mjs
,就没问题了。