加载模块
尽管 ES6 定义了模块的语法,但并未定义如何加载它们。这是规范复杂性的一部分, 这种复杂性对于实现环境来说是无法预知的。ES6 未选择给所有 JS 环境努力创建一个有效的单一规范,而只对一个未定义的内部操作 HostResolveImportedModule 指定了语法以及抽象的加载机制。web 浏览器与 Node.js 可以自行决定用什么方式实现 HostResolveImportedModule,以便更好契合各自的环境。
在 Web 浏览器中使用模块
即使在 ES6 之前,web 浏览器都有多种方式在 web 应用中加载 JS。这些可能的脚本加载选择是:
-
使用 <script> 元素以及 src 属性来指定代码加载的位置,以便加载 JS 代码文件;
-
使用 <script> 元素但不使用 src 属性,来嵌入内联的 JS 代码;
-
加载 JS 代码文件并作为 Worker(例如 Web Worker 或 Service Worker)来执行。
为了完全支持模块,web 浏览器必须更新这些机制。相关细节被定义在 HTML 规范中,我将会在本节对其进行概述。
在 script 标签中使用模块
<script> 元素默认以脚本方式(而非模块)来加载 JS 文件,只要 type 属性缺失,或者 type 属性含有与 JS 对应的内容类型(例如 "text/javascript" )。<script> 元素能够执行内联脚本,也能加载在 src 中指定的文件。为了支持模块,添加了 "module" 值作为 type 的选项。将 type 设置为 "module",就告诉浏览器要将内联代码或是指定文件中的代码当作模块,而不是当作脚本。此处有个简单范例:
<!-- load a module JavaScript file -->
<script type="module" src="module.js"></script>
<!-- include a module inline -->
<script type="module">
import { sum } from "./example.js";
let result = sum(1, 2);
</script>
此例中第一个 <script> 元素使用 src 加载了外部模块文件,与加载脚本唯一的区别是将 type 指定为 "module"。第二个 <script> 元素则包含了一个直接嵌入到网页内的模块,result 变量并未被暴露到全局,因为它只在使用 <script> 元素定义的这个模块内部存在,因此也没有被添加为 window 对象的属性。
正如你所见,在网页中包含模块十分简单,并且类似于包含脚本。然而,在如何加载模块方面有一些区别。
你可能已经注意到 "module" 并不是与 "text/javascript" 相似的内容类型。 模块 JS 文件的内容类型与脚本 JS 文件相同,因此不可能依据文件的内容类型将它们完全区别开来。此外,当 type 属性无法辨认时,浏览器就会忽略 <script> 元素,因此不支持模块的浏览器也就会自动忽略 <script type="module"> 声明,从而提供良好的向下兼容性。 |
Web 浏览器中的模块加载次序
模块相对脚本的独特之处在于:它们能使用 import 来指定必须要加载的其他文件,以保证正确执行。为了支持此功能,<script type="module"> 总是表现得像是已经应用了 defer 属性。
defer 属性是加载脚本文件时的可选项,但在加载模块文件时总是自动应用的。当 HTML 解析到拥有 src 属性的 <script type="module"> 标签时,就会立即开始下载模块文件,但并不会执行它,直到整个网页文档全部解析完为止。模块也会按照它们在 HTML 文件中出现的顺序依次执行,这意味着第一个 <script type="module"> 总是保证在第二个之前执行,即使其中有些模块不是用 src 指定而是包含了内联脚本。例如:
<!-- this will execute first -->
<script type="module" src="module1.js"></script>
<!-- this will execute second -->
<script type="module">
import { sum } from "./example.js";
let result = sum(1, 2);
</script>
<!-- this will execute third -->
<script type="module" src="module2.js"></script>
这三个 <script> 元素依照它们被指定的顺序执行,因此 module1.js 保证在内联模块之前执行,而内联模块又保证在 module2.js 之前执行。
每个模块可能都用 import 导入了一个或多个其他模块,这就让事情变复杂了。这也就是模块为何首先需要被解析,因为这样才能识别所有的 import 语句。每个 import 语句又会触发一次 fetch(无论是从网络还是从缓存中获取),并且在所有用 import 导入的资源被加载与执行完毕之前,没有任何模块会被执行。
所有模块,无论是用 <script type="module"> 显式包含的,还是用 import 隠式包含的,都会依照次序加载与执行。在前面的范例中,完整的加载次序是:
-
下载并解析 module1.js ;
-
递归下载并解析在 module1.js 中使用 import 导入的资源;
-
解析内联模块;
-
递归下载并解析在内联模块中使用 import 导入的资源;
-
下载并解析 module2.js ;
-
递归下载并解析在 module2.js 中使用 import 导入的资源。
一旦加载完毕,直到页面文档被完整解析之前,都不会有任何代码被执行。在文档解析完毕后,会发生下列行为:
-
递归执行 module1.js 导入的资源;
-
执行 module1.js ;
-
递归执行内联模块导入的资源;
-
执行内联模块;
-
递归执行 module2.js 导入的资源;
-
执行 module2.js 。
注意内联模块除了不必先下载代码之外,与其他两个模块的行为一致,加载 import 的资源与执行模块的次序都是完全一样的。
<script type="module"> 上的 defer 属性总是会被忽略,因为它已经应用了该属性。 |
Web 浏览器中的异步模块加载
你或许已熟悉了 <script> 元素上的 async 属性。当配合脚本使用时,async 会导致脚本文件在下载并解析完毕后就立即执行。但带有 async 的脚本在文档中的顺序却并不会影响脚本执行的次序,脚本总是会在下载完成后就立即执行,而无须等待包含它的文档解析完毕。
async 属性也能同样被应用到模块上。在 <script type="module"> 上使用 async 会导致模块的执行行为与脚本相似。唯一区别是模块中所有 import 导入的资源会在模块自身被执行前先下载。这保证了模块中所有需要的资源会在模块执行前被下载,你只是不能保证模块何时会执行。 研究以下代码:
<!-- no guarantee which one of these will execute first -->
<script type="module" async src="module1.js"></script>
<script type="module" async src="module2.js"></script>
此例中两个模块文件被异步加载了。仅查看代码就判断出那个模块会被先执行,这是不可能的。若 module1.js 首先结束下载(包括它的所有导入资源),那么它就会首先执行。而对于 module2.js 来说也是一样。
将模块作为 Worker 加载
诸如 Web Worker 与 Service Worker 之类的 worker,会在网页上下文外部执行 JS 代码。创建一个新的 worker 调用,也就会创建 Worker(或其他 worker 类)的一个实例,并会向其传入 JS 文件的位置。其默认的加载机制是将文件当作脚本来下载,例如:
// load script.js as a script
let worker = new Worker("script.js");
为了支持模块加载,HTML 标准的开发者为这些 worker 构造器添加了第二个参数, 此参数是一个有 type 属性的对象,该属性的默认值是 "script"。你也可以将 type 设置为 "module" 以便加载模块文件:
// load module.js as a module
let worker = new Worker("module.js", { type: "module" });
此例通过传递 type 属性值为 "module" 的第二个参数,将 module.js 作为模块而不是脚本进行了加载(type 属性也就是模拟了 <script> 标签在模块与脚本之间的 type 区别)。这第二个参数在浏览器中的所有的 worker 类型中都得到了支持。
worker 模块通常与 worker 脚本一致,但存在两点例外。首先,worker 脚本被限制只能从同源的网页进行加载,而 worker 模块可以不受此限制。尽管 worker 模块具有相同的默认限制,但当响应头中包含恰当的跨域资源共享( Cross-Origin Resource Sharing,CORS )时,就允许跨域加载文件。其次,worker 脚本可以使用 self.importScripts() 方法来将额外脚本引入 worker,而 worker 模块上的 self.importScripts() 却总会失败,因为应当换用 import 。
浏览器模块说明符方案
本章至今的所有范例都使用了相对的模块说明符,例如 "./example.js"。浏览器要求模块说明符应当为下列格式之一:
-
以 / 为起始,表示从根目录开始解析;
-
以 ./ 为起始,表示从当前目录开始解析;
-
以 ../ 为起始,表示从父级目录开始解析;
-
URL 格式。
例如,假设你拥有一个位于 https://www.example.com/modules/module.js 的模块文件,包含了以下代码:
// imports from https://www.example.com/modules/example1.js
import { first } from "./example1.js";
// imports from https://www.example.com/example2.js
import { second } from "../example2.js";
// imports from https://www.example.com/example3.js
import { third } from "/example3.js";
// imports from https://www2.example.com/example4.js
import { fourth } from "https://www2.example.com/example4.js";
此例中每一个模块说明符在浏览器中使用时都是有效的,包括最后一行的完整 URL ( 你无须确保 ww2.example.com 已经正确配置了它的 CORS 响应头来允许跨域加载,这会影响是否能跨域加载,却不会影响语法的有效性)。这些是浏览器默认情况下仅能使用的模块说明符格式(不过未完成的模块加载器规范将会提供对其他格式的支持)。这意味着某些看似正常的模块说明符实际上在浏览器中是无效的,并且会导致错误,正如:
// invalid - doesn't begin with /, ./, or ../
import { first } from "example.js";
// invalid - doesn't begin with /, ./, or ../
import { second } from "example/index.js";
此处的模块说明符都不能被浏览器加载。这两个模块说明符都用了无效的格式(缺失了正确的起始字符),尽管在 <script> 标签中作为 src 来使用是有效的。这是在 <script> 与 import 之间有意制造的行为差异。