长春长春网站建设网,WordPress建站详细过程,优化seo厂家,dedecms网站迁移ES 模块语法
1、模块化的背景
JavaScript 程序本来很小——在早期#xff0c;它们大多被用来执行独立的脚本任务#xff0c;在你的 web 页面需要的地方提供一定交互#xff0c;所以一般不需要多大的脚本。过了几年#xff0c;我们现在有了运行大量 JavaScript 脚本的复杂…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文件结构如下
index.html
main.js
modules/canvas.jssquare.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 typemodule 属性用于指示引入的模块你会在下面看到。
5、导出模块的功能
为了获得模块的功能要做的第一件事是把它们导出来。使用 export 语句来完成。
最简单的方法是把它指上面的 export 语句放到你想要导出的项前面比如
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,};
}你能够导出函数varletconst, 和等会会看到的类。export 要放在最外层比如你不能够在函数内使用 export。
一个更方便的方法导出所有你想要导出的模块的方法是在模块文件的末尾使用一个 export 语句语句是用花括号括起来的用逗号分割的列表。比如
export { name, draw, reportArea, reportPerimeter };6、导入功能到你的脚本
你想在模块外面使用一些功能那你就需要导入他们才能使用。最简单的就像下面这样的
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。
那么看看例子吧
/js/examples/modules/basic-modules/modules/square.js变成了
./modules/square.js你可以在 main.js 中看到这些。 备注 在一些模块系统中你可以忽略文件扩展名比如 ‘/model/squre’。这在原生 JavaScript 模块系统中不工作。此外记住你需要包含最前面的正斜杠。 修订版 1889482 因为你导入了这些功能到你的脚本文件你可以像定义在相同的文件中的一样去使用它。下面展示的是在 main.js 中的 import 语句下面的内容。
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:
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地址。
script typeimportmap{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重新映射到另一个路径。
// 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类。
// 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模块。
import { name, draw, reportArea, reportPerimeter } from square;要在浏览器上使用裸名你需要一个导入映射它提供了浏览器将模块说明符解析为url所需的信息(JavaScript如果试图导入一个无法解析为模块位置的模块说明符将抛出TypeError)。
下面您可以看到一个映射它定义了一个square 模块说明符键在本例中映射到一个相对地址值。
script typeimportmap{imports: {square: ./shapes/square.js}}
/script有了这个映射我们现在可以在导入模块时使用裸名:
import { name as squareName, draw } from square;7.3 重新映射模块路径
模块说明符映射项其中说明符键及其关联值都有一个尾斜杠(/)可以用作路径前缀。这允许将一整套导入url从一个位置重新映射到另一个位置。它还可以用于模拟“包和模块”的工作例如您可能在Node生态系统中看到的。 注意:后面的/表示模块说明符键可以被替换为模块说明符的一部分。如果不存在浏览器将只匹配(并替换)整个模块说明符键。 7.4 模块包
下面的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;可以在没有.js文件扩展名的情况下导入上面的fp但是您需要为该文件创建一个裸模块说明符键例如lodash/fp而不是使用路径。对于一个模块来说这可能是合理的但是如果您希望导入许多模块则伸缩性很差。
7.5 常规URL重映射
模块说明符键不一定是path——它也可以是绝对URL(或类似URL的相对路径如./../, /)。如果您希望将具有绝对路径的模块重新映射到具有您自己的本地资源的资源这可能会很有用。
{imports: {https://www.unpkg.com/moment/: /node_modules/moment/}
}7.6 用于版本管理的限定范围模块
像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中的模块说明符map将被使用映射到/node_modules/coolmodule/index.js中的版本。
请注意用于选择作用域的路径不会影响地址解析的方式。映射路径中的值不必与作用域路径匹配相对路径仍然被解析为包含导入映射的脚本的基本URL。
就像模块说明符映射一样您可以有许多范围键这些键可能包含重叠的路径。如果多个作用域与引用者URL匹配则首先检查匹配说明符的最特定的作用域路径(最长的作用域键)。如果没有匹配说明符浏览器将退回到下一个最具体的匹配范围路径依此类推。如果在任何匹配范围中都没有匹配的说明符浏览器将检查imports键中的模块说明符映射中的匹配项。
7.7 通过映射散列文件名来改进缓存
网站使用的脚本文件通常有散列文件名以简化缓存。这种方法的缺点是如果一个模块发生变化任何使用其散列文件名导入它的模块也需要更新/重新生成。这可能导致更新的级联这是网络资源的浪费。
导入映射为这个问题提供了一个方便的解决方案。应用程序和脚本不是依赖于特定的散列文件名而是依赖于模块名称(地址)的未散列版本。然后像下面这样的导入映射提供了到实际脚本文件的映射。
{imports: {main_script: /node/srcs/application-fg7744e1b.js,dependency_script: /node/srcs/dependency-3qn7e4b1q.js}
}如果dependency_script更改那么文件名中包含的哈希值也会更改。在这种情况下我们只需要更新导入映射来反映模块的更改名称。我们不需要更新任何依赖于它的JavaScript代码的源代码因为import语句中的说明符没有改变。
8、应用模块到你的 HTML
现在我们只需要将 main.js 模块应用到我们的 HTML 页面。这与我们将常规脚本应用于页面的方式非常相似但有一些显着的差异。
首先你需要把 typemodule 放到 script 标签中来声明这个脚本是一个模块
script typemodule srcmain.js/script你导入模块功能的脚本基本是作为顶级模块。如果省略它Firefox 就会给出错误“SyntaxError: import declarations may only appear at top level of a module。
你只能在模块内部使用 import 和export 语句不是普通脚本文件。 备注 你还可以将模块导入内部脚本只要包含 type“module”例如 script typemodule //include script here /script。 注意:模块和它们的依赖可以通过在link元素中用relmodulepreloaded指定它们来预加载。这可以显著减少使用模块时的加载时间。 9、其他模块与标准脚本的不同
你需要注意本地测试——如果你通过本地加载 HTML 文件比如一个 file:// 路径的文件你将会遇到 CORS 错误因为 JavaScript 模块安全性需要。你需要通过一个服务器来测试。另请注意你可能会从模块内部定义的脚本部分获得与标准脚本中不同的行为。这是因为模块自动使用严格模式。加载一个模块脚本时不需要使用 defer 属性 (see script attributes) 模块会自动延迟加载。最后一个但不是不重要你需要明白模块功能导入到单独的脚本文件的范围——他们无法在全局获得。因此你只能在导入这些功能的脚本文件中使用他们你也无法通过 JavaScript console 中获取到他们比如在 DevTools 中你仍然能够获取到语法错误但是你可能无法像你想的那样使用一些 debug 技术。
模块定义的变量的作用域为模块除非显式附加到全局对象。另一方面全局定义的变量可以在模块中使用。例如给定以下代码:
!doctype html
html langen-USheadmeta charsetUTF-8 /title/titlelink relstylesheet href //headbodydiv idmain/divscript// A var statement creates a global variable.var text Hello;/scriptscript typemodule src./render.js/script/body
/html/* 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()的函数它可以创建一个具有随机颜色、大小和位置的正方形。我们想把它作为默认值导出所以在文件的底部我们这样写:
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语法将在下面的重命名导入和导出部分中进行解释。 11、避免命名冲突
到目前为止我们的画布形状绘制模块似乎工作正常。但是如果我们尝试添加一个处理绘制另一个形状(如圆形或三角形)的模块会发生什么呢?这些形状可能也有相关的函数如draw() reportArea()等;如果我们试图将同名的不同函数导入到相同的顶级模块文件中就会出现冲突和错误。
幸运的是有很多方法可以解决这个问题。我们将在下面几节中讨论这些问题。
12、重命名导入和导出
在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;结果是一样的。使用什么风格由您决定但是保留模块代码并在导入中进行更改可能更有意义。当您从无法控制的第三方模块导入时这尤其有意义。
13、创建模块对象
上面的方法可以工作但它有点混乱和冗长。一个更好的解决方案是在模块对象中导入每个模块的特性。下面的语法形式做到了这一点:
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);因此您现在可以像以前一样编写代码(只要在需要的地方包含对象名称)并且导入更加整洁。
14、模块和类
正如我们前面所暗示的您还可以导出和导入类;这是避免代码冲突的另一种选择如果您已经以面向对象的风格编写了模块代码则特别有用。
你可以在我们的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();15、聚合模块
有时需要将模块聚合在一起。您可能有多个级别的依赖关系您希望简化事情将几个子模块组合成一个父模块。这可以在父模块中使用以下表单的export语法:
export * from x.js;
export { name } from x.js;例如请参阅我们的模块 module-aggregation。在这个例子中(基于我们之前的类的例子)我们有一个额外的模块叫做shape.js它聚集了circle.js, square.js和triangle.js的所有功能。我们还将我们的子模块移到了modules目录下名为shapes的子目录中。所以这个例子中的模块结构是:
modules/canvas.jsshapes.jsshapes/circle.jssquare.jstriangle.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模块中可用。 注意:shape.js中引用的导出基本上通过文件被重定向并且实际上不存在因此您将无法在同一文件中编写任何有用的相关代码。 现在在main.js文件中我们可以通过用下面这一行:
import { Square, Circle, Triangle } from ./modules/shapes.js;替换
import { Square } from ./modules/square.js;
import { Circle } from ./modules/circle.js;
import { Triangle } from ./modules/triangle.js;来访问所有三个模块类
16、动态模块加载
JavaScript模块最近新增的功能是动态模块加载。这允许您仅在需要时动态加载模块而不必预先加载所有内容。这有一些明显的性能优势;让我们继续读下去看看它是如何工作的。
这个新功能允许您将import()作为函数调用并将路径作为参数传递给模块。它返回一个Promise它用一个模块对象来实现(参见创建模块对象)让你可以访问该对象的导出。例如:
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()调用获取了对每个按钮的引用例如:
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实现返回一个模块对象因此类随后成为该对象的子特性因此我们现在需要使用Module.访问构造函数。附加在它前面例如:Module.Square( /* … */ )。
动态导入的另一个优点是它们总是可用的即使在脚本环境中也是如此。因此如果你的HTML中有一个现有的script标签它没有typemodule你仍然可以通过动态导入它来重用作为模块分发的代码。
scriptimport(./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);
/script17、顶层 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时继续加载。
18、导入声明被提升
导入声明被提升。在这种情况下这意味着导入的值甚至在声明它们的行之前就可以在模块的代码中使用并且导入的模块的副作用在模块的其余代码开始运行之前就产生了。
例如在main.js中在代码中间导入Canvas仍然可以工作:
// …
const myCanvas new Canvas(myCanvas, document.body, 480, 320);
myCanvas.create();
import { Canvas } from ./modules/canvas.js;
myCanvas.createReportList();
// …尽管如此将所有导入放在代码的顶部被认为是一种良好的实践这使得分析依赖关系变得更加容易。
19、循环导入
模块可以导入其他模块这些模块可以导入其他模块以此类推。这形成了一个被称为“依赖图”的有向图。在理想情况下这个图是非循环的。在这种情况下可以使用深度优先遍历来评估图。
然而循环往往是不可避免的。如果模块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因此其余代码照常执行两个导出声明生成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时它需要首先计算b.js, a.js的依赖项。然而b.js使用的是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的值是可用的。
通常应该避免在项目中进行循环导入因为这会使代码更容易出错。一些常见的循环消除技术是:
将两个模块合并为一个。将共享代码移动到第三个模块中。将一些代码从一个模块移动到另一个模块。
但是如果库相互依赖也可能发生循环导入这很难修复。
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。
// myModule.js
let password;
if (typeof process ! undefined) {// We are running in Node.js; read it from process.envpassword process.env.PASSWORD;
} else if (typeof window ! undefined) {// We are running in the browser; read it from the input boxpassword document.getElementById(password).value;
}如果两个分支实际上最终具有相同的行为(“同构”)则更可取。如果不可能提供相同的功能或者这样做需要加载大量代码而大部分代码仍未使用那么最好使用不同的“绑定”。
使用多边形填充来为缺失的特征提供回退。例如如果你想使用fetch函数它只在v18版本的Node.js中被支持你可以使用类似的API就像node-fetch提供的那样。您可以通过动态导入有条件地这样做:
// myModule.js
if (typeof fetch undefined) {// We are running in Node.js; use node-fetchglobalThis.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就没问题了。