随着我们的应用程序变得越来越大,我们希望将其拆分为多个文件,即所谓的“模块”。一个模块可能包含一个类或一个用于特定目的的函数库。
很长一段时间里,JavaScript 都没有语言级别的模块语法。这并不是一个问题,因为最初的脚本很小且简单,所以没有必要。
但最终脚本变得越来越复杂,因此社区发明了多种方法来将代码组织成模块,特殊的库可以按需加载模块。
举几个例子(出于历史原因)
- AMD – 最古老的模块系统之一,最初由库 require.js 实现。
- CommonJS – 为 Node.js 服务器创建的模块系统。
- UMD – 另一个模块系统,建议作为通用系统,与 AMD 和 CommonJS 兼容。
现在,这些都慢慢成为历史的一部分,但我们仍然可以在旧脚本中找到它们。
语言级别的模块系统于 2015 年出现在标准中,此后逐渐演变,现在得到所有主流浏览器和 Node.js 的支持。因此,我们从现在开始研究现代 JavaScript 模块。
什么是模块?
模块只是一个文件。一个脚本就是一个模块。就这么简单。
模块可以相互加载,并使用特殊的指令 export
和 import
来交换功能,从一个模块调用另一个模块的函数
export
关键字标记应从当前模块外部访问的变量和函数。import
允许从其他模块导入功能。
例如,如果我们有一个文件 sayHi.js
导出一个函数
// 📁 sayHi.js
export function sayHi(user) {
alert(`Hello, ${user}!`);
}
…那么另一个文件可以导入并使用它
// 📁 main.js
import {sayHi} from './sayHi.js';
alert(sayHi); // function...
sayHi('John'); // Hello, John!
import
指令通过相对于当前文件的路径 ./sayHi.js
加载模块,并将导出的函数 sayHi
分配给相应的变量。
让我们在浏览器中运行该示例。
由于模块支持特殊关键字和功能,我们必须通过使用属性 <script type="module">
告诉浏览器应将脚本视为模块。
像这样
export function sayHi(user) {
return `Hello, ${user}!`;
}
<!doctype html>
<script type="module">
import {sayHi} from './say.js';
document.body.innerHTML = sayHi('John');
</script>
浏览器自动获取并评估导入的模块(如果需要,还包括其导入),然后运行脚本。
如果您尝试通过 file://
协议在本地打开网页,您会发现 import/export
指令不起作用。使用本地 Web 服务器,例如 static-server 或使用编辑器的“实时服务器”功能,例如 VS Code Live Server Extension 来测试模块。
核心模块功能
与“常规”脚本相比,模块有什么不同?
有一些核心功能,对浏览器和服务器端 JavaScript 都有效。
始终“使用严格模式”
模块始终在严格模式下工作。例如,赋值给未声明的变量将产生错误。
<script type="module">
a = 5; // error
</script>
模块级范围
每个模块都有自己的顶级范围。换句话说,模块中的顶级变量和函数在其他脚本中不可见。
在下面的示例中,导入了两个脚本,并且 hello.js
尝试使用在 user.js
中声明的 user
变量。它失败了,因为它是一个单独的模块(您将在控制台中看到错误)
alert(user); // no such variable (each module has independent variables)
let user = "John";
<!doctype html>
<script type="module" src="user.js"></script>
<script type="module" src="hello.js"></script>
模块应该export
它们希望从外部访问的内容,并import
它们需要的内容。
user.js
应该导出user
变量。hello.js
应该从user.js
模块中导入它。
换句话说,使用模块时,我们使用import/export,而不是依赖全局变量。
这是正确的变量
import {user} from './user.js';
document.body.innerHTML = user; // John
export let user = "John";
<!doctype html>
<script type="module" src="hello.js"></script>
在浏览器中,如果我们讨论HTML页面,每个<script type="module">
也存在独立的顶级作用域。
以下是同一页面上的两个脚本,两者都是type="module"
。它们看不到彼此的顶级变量
<script type="module">
// The variable is only visible in this module script
let user = "John";
</script>
<script type="module">
alert(user); // Error: user is not defined
</script>
在浏览器中,我们可以通过将变量显式分配给window
属性(例如window.user = "John"
)来使其成为窗口级全局变量。
然后所有脚本都将看到它,无论是否带有type="module"
。
也就是说,不赞成创建这样的全局变量。请尽量避免它们。
模块代码仅在首次导入时才会被评估
如果将同一模块导入多个其他模块,则其代码仅在首次导入时执行一次。然后,它的导出内容将提供给所有进一步的导入者。
一次性评估具有重要的后果,我们应该意识到这一点。
让我们看几个例子。
首先,如果执行模块代码会产生副作用,比如显示消息,那么多次导入它只会触发一次——第一次
// 📁 alert.js
alert("Module is evaluated!");
// Import the same module from different files
// 📁 1.js
import `./alert.js`; // Module is evaluated!
// 📁 2.js
import `./alert.js`; // (shows nothing)
第二次导入不会显示任何内容,因为模块已经过评估。
有一条规则:顶级模块代码应该用于初始化、创建模块特定的内部数据结构。如果我们需要多次调用某个内容——我们应该将其作为函数导出,就像我们在上面使用sayHi
所做的那样。
现在,让我们考虑一个更深入的例子。
假设一个模块导出一个对象
// 📁 admin.js
export let admin = {
name: "John"
};
如果从多个文件导入此模块,则仅在第一次评估模块,创建admin
对象,然后将其传递给所有进一步的导入者。
所有导入者都将获得唯一的一个admin
对象
// 📁 1.js
import {admin} from './admin.js';
admin.name = "Pete";
// 📁 2.js
import {admin} from './admin.js';
alert(admin.name); // Pete
// Both 1.js and 2.js reference the same admin object
// Changes made in 1.js are visible in 2.js
如你所见,当1.js
更改导入的admin
中的name
属性时,2.js
可以看到新的admin.name
。
这正是因为模块仅执行一次。导出已生成,然后在导入者之间共享,因此如果某些内容更改了 admin
对象,其他导入者将看到这一点。
这种行为实际上非常方便,因为它允许我们配置模块。
换句话说,模块可以提供需要设置的通用功能。例如,身份验证需要凭证。然后,它可以导出一个配置对象,期望外部代码分配给它。
以下是经典模式
- 模块导出一些配置方法,例如配置对象。
- 在第一次导入时,我们对其进行初始化,写入其属性。顶级应用程序脚本可能会这样做。
- 进一步的导入使用该模块。
例如,admin.js
模块可以提供某些功能(例如身份验证),但期望凭证从外部进入 config
对象
// 📁 admin.js
export let config = { };
export function sayHi() {
alert(`Ready to serve, ${config.user}!`);
}
此处,admin.js
导出 config
对象(最初为空,但也可以具有默认属性)。
然后在 init.js
中,我们应用程序的第一个脚本,我们从中导入 config
并设置 config.user
// 📁 init.js
import {config} from './admin.js';
config.user = "Pete";
…现在模块 admin.js
已配置。
进一步的导入者可以调用它,它正确显示当前用户
// 📁 another.js
import {sayHi} from './admin.js';
sayHi(); // Ready to serve, Pete!
import.meta
对象 import.meta
包含有关当前模块的信息。
其内容取决于环境。在浏览器中,它包含脚本的 URL,或如果在 HTML 中,则包含当前网页的 URL
<script type="module">
alert(import.meta.url); // script URL
// for an inline script - the URL of the current HTML-page
</script>
在模块中,“this”未定义
这是一种次要功能,但为了完整性,我们应该提到它。
在模块中,顶级 this
未定义。
将其与非模块脚本进行比较,其中 this
是一个全局对象
<script>
alert(this); // window
</script>
<script type="module">
alert(this); // undefined
</script>
特定于浏览器的功能
与常规脚本相比,type="module"
脚本还有一些特定于浏览器的差异。
如果您是第一次阅读或不使用浏览器中的 JavaScript,您可能希望暂时跳过此部分。
模块脚本被延迟
模块脚本始终被延迟,与 defer
属性(在章节 脚本:async、defer 中描述)相同,适用于外部和内联脚本。
换句话说
- 下载外部模块脚本
<script type="module" src="...">
不会阻止 HTML 处理,它们与其他资源并行加载。 - 模块脚本会等到 HTML 文档完全就绪(即使它们很小且加载速度比 HTML 快)后才运行。
- 脚本的相对顺序会得到保留:文档中排在前面的脚本会先执行。
作为副作用,模块脚本总是“看到”完全加载的 HTML 页面,包括它们下方的 HTML 元素。
例如
<script type="module">
alert(typeof button); // object: the script can 'see' the button below
// as modules are deferred, the script runs after the whole page is loaded
</script>
Compare to regular script below:
<script>
alert(typeof button); // button is undefined, the script can't see elements below
// regular scripts run immediately, before the rest of the page is processed
</script>
<button id="button">Button</button>
请注意:第二个脚本实际上在第一个脚本之前运行!所以我们首先会看到 undefined
,然后是 object
。
这是因为模块是延迟的,所以我们等待文档被处理。常规脚本会立即运行,所以我们首先看到它的输出。
在使用模块时,我们应该意识到 HTML 页面会在加载时显示,而 JavaScript 模块会在之后运行,所以用户可能会在 JavaScript 应用程序准备就绪之前看到该页面。一些功能可能还无法使用。我们应该放置“加载指示符”,或以其他方式确保访问者不会因此感到困惑。
Async 适用于内联脚本
对于非模块脚本,async
属性仅适用于外部脚本。Async 脚本会在就绪时立即运行,独立于其他脚本或 HTML 文档。
对于模块脚本,它也适用于内联脚本。
例如,下面的内联脚本具有 async
,所以它不会等待任何东西。
它会执行导入(获取 ./analytics.js
)并在就绪时运行,即使 HTML 文档尚未完成或其他脚本仍在挂起。
这对于不依赖任何东西的功能很有用,例如计数器、广告、文档级事件侦听器。
<!-- all dependencies are fetched (analytics.js), and the script runs -->
<!-- doesn't wait for the document or other <script> tags -->
<script async type="module">
import {counter} from './analytics.js';
counter.count();
</script>
外部脚本
具有 type="module"
的外部脚本在两个方面有所不同
-
具有相同
src
的外部脚本仅运行一次<!-- the script my.js is fetched and executed only once --> <script type="module" src="my.js"></script> <script type="module" src="my.js"></script>
-
从其他来源(例如其他网站)获取的外部脚本需要 CORS 头,如 Fetch:跨源请求 一章中所述。换句话说,如果从其他来源获取模块脚本,则远程服务器必须提供允许获取的
Access-Control-Allow-Origin
头。<!-- another-site.com must supply Access-Control-Allow-Origin --> <!-- otherwise, the script won't execute --> <script type="module" src="http://another-site.com/their.js"></script>
这默认情况下确保了更好的安全性。
不允许“裸”模块
在浏览器中,import
必须获取相对 URL 或绝对 URL。没有任何路径的模块称为“裸”模块。此类模块在 import
中不被允许。
例如,此 import
无效
import {sayHi} from 'sayHi'; // Error, "bare" module
// the module must have a path, e.g. './sayHi.js' or wherever the module is
某些环境,例如 Node.js 或捆绑工具允许使用裸模块,而无需任何路径,因为它们有自己的方法来查找模块并对其进行微调。但浏览器还不支持裸模块。
兼容性,“nomodule”
旧浏览器无法理解 type="module"
。未知类型的脚本将被忽略。对于它们,可以使用 nomodule
属性提供后备
<script type="module">
alert("Runs in modern browsers");
</script>
<script nomodule>
alert("Modern browsers know both type=module and nomodule, so skip this")
alert("Old browsers ignore script with unknown type=module, but execute this.");
</script>
构建工具
在实际应用中,浏览器模块很少以其“原始”形式使用。通常,我们会使用 Webpack 等特殊工具将它们捆绑在一起并部署到生产服务器。
使用捆绑器的优点之一是它们可以更好地控制模块的解析方式,允许使用裸模块以及更多内容,例如 CSS/HTML 模块。
构建工具执行以下操作
- 获取“主”模块,该模块旨在放入 HTML 中的
<script type="module">
。 - 分析其依赖项:导入,然后导入导入等。
- 使用所有模块(或多个文件,可调整)构建一个文件,用捆绑器函数替换本机
import
调用,使其正常工作。还支持“特殊”模块类型,例如 HTML/CSS 模块。 - 在此过程中,可以应用其他转换和优化
- 删除无法访问的代码。
- 删除未使用的导出(“tree-shaking”)。
- 删除特定于开发的语句,例如
console
和debugger
。 - 可以使用 Babel 将现代、前沿的 JavaScript 语法转换为具有类似功能的较旧语法。
- 对生成的文件进行缩小(删除空格、用较短的名称替换变量等)。
如果我们使用捆绑工具,那么当脚本被捆绑到一个文件(或几个文件)中时,这些脚本中的 import/export
语句将被特殊的捆绑器函数替换。因此,生成的“捆绑”脚本不包含任何 import/export
,它不需要 type="module"
,我们可以将其放入常规脚本中
<!-- Assuming we got bundle.js from a tool like Webpack -->
<script src="bundle.js"></script>
也就是说,本机模块也可以使用。因此,我们不会在这里使用 Webpack:您可以稍后对其进行配置。
总结
总结一下,核心概念是
- 模块是一个文件。要使
import/export
正常工作,浏览器需要<script type="module">
。模块有几个不同之处- 默认情况下延迟。
- Async 在内联脚本上工作。
- 要从另一个来源(域/协议/端口)加载外部脚本,需要 CORS 头。
- 忽略重复的外部脚本。
- 模块有自己的本地顶级作用域,并通过
import/export
交换功能。 - 模块始终
use strict
。 - 模块代码仅执行一次。导出创建一次并在导入者之间共享。
当我们使用模块时,每个模块都实现功能并导出它。然后我们使用 import
在需要的地方直接导入它。浏览器自动加载并评估脚本。
在生产中,人们经常使用捆绑器(例如 Webpack)将模块捆绑在一起以提高性能和其他原因。
在下一章中,我们将看到更多模块示例,以及如何导出/导入内容。
评论
<code>
标记,对于多行 - 将它们包装在<pre>
标记中,对于 10 行以上 - 使用沙盒(plnkr、jsbin、codepen…)