2022 年 10 月 14 日

模块,简介

随着我们的应用程序变得越来越大,我们希望将其拆分为多个文件,即所谓的“模块”。一个模块可能包含一个类或一个用于特定目的的函数库。

很长一段时间里,JavaScript 都没有语言级别的模块语法。这并不是一个问题,因为最初的脚本很小且简单,所以没有必要。

但最终脚本变得越来越复杂,因此社区发明了多种方法来将代码组织成模块,特殊的库可以按需加载模块。

举几个例子(出于历史原因)

  • AMD – 最古老的模块系统之一,最初由库 require.js 实现。
  • CommonJS – 为 Node.js 服务器创建的模块系统。
  • UMD – 另一个模块系统,建议作为通用系统,与 AMD 和 CommonJS 兼容。

现在,这些都慢慢成为历史的一部分,但我们仍然可以在旧脚本中找到它们。

语言级别的模块系统于 2015 年出现在标准中,此后逐渐演变,现在得到所有主流浏览器和 Node.js 的支持。因此,我们从现在开始研究现代 JavaScript 模块。

什么是模块?

模块只是一个文件。一个脚本就是一个模块。就这么简单。

模块可以相互加载,并使用特殊的指令 exportimport 来交换功能,从一个模块调用另一个模块的函数

  • 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"> 告诉浏览器应将脚本视为模块。

像这样

结果
say.js
index.html
export function sayHi(user) {
  return `Hello, ${user}!`;
}
<!doctype html>
<script type="module">
  import {sayHi} from './say.js';

  document.body.innerHTML = sayHi('John');
</script>

浏览器自动获取并评估导入的模块(如果需要,还包括其导入),然后运行脚本。

模块仅通过 HTTP(s) 工作,而不是本地

如果您尝试通过 file:// 协议在本地打开网页,您会发现 import/export 指令不起作用。使用本地 Web 服务器,例如 static-server 或使用编辑器的“实时服务器”功能,例如 VS Code Live Server Extension 来测试模块。

核心模块功能

与“常规”脚本相比,模块有什么不同?

有一些核心功能,对浏览器和服务器端 JavaScript 都有效。

始终“使用严格模式”

模块始终在严格模式下工作。例如,赋值给未声明的变量将产生错误。

<script type="module">
  a = 5; // error
</script>

模块级范围

每个模块都有自己的顶级范围。换句话说,模块中的顶级变量和函数在其他脚本中不可见。

在下面的示例中,导入了两个脚本,并且 hello.js 尝试使用在 user.js 中声明的 user 变量。它失败了,因为它是一个单独的模块(您将在控制台中看到错误)

结果
hello.js
user.js
index.html
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,而不是依赖全局变量。

这是正确的变量

结果
hello.js
user.js
index.html
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 对象,其他导入者将看到这一点。

这种行为实际上非常方便,因为它允许我们配置模块。

换句话说,模块可以提供需要设置的通用功能。例如,身份验证需要凭证。然后,它可以导出一个配置对象,期望外部代码分配给它。

以下是经典模式

  1. 模块导出一些配置方法,例如配置对象。
  2. 在第一次导入时,我们对其进行初始化,写入其属性。顶级应用程序脚本可能会这样做。
  3. 进一步的导入使用该模块。

例如,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" 的外部脚本在两个方面有所不同

  1. 具有相同 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>
  2. 从其他来源(例如其他网站)获取的外部脚本需要 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 模块。

构建工具执行以下操作

  1. 获取“主”模块,该模块旨在放入 HTML 中的 <script type="module">
  2. 分析其依赖项:导入,然后导入导入等。
  3. 使用所有模块(或多个文件,可调整)构建一个文件,用捆绑器函数替换本机 import 调用,使其正常工作。还支持“特殊”模块类型,例如 HTML/CSS 模块。
  4. 在此过程中,可以应用其他转换和优化
    • 删除无法访问的代码。
    • 删除未使用的导出(“tree-shaking”)。
    • 删除特定于开发的语句,例如 consoledebugger
    • 可以使用 Babel 将现代、前沿的 JavaScript 语法转换为具有类似功能的较旧语法。
    • 对生成的文件进行缩小(删除空格、用较短的名称替换变量等)。

如果我们使用捆绑工具,那么当脚本被捆绑到一个文件(或几个文件)中时,这些脚本中的 import/export 语句将被特殊的捆绑器函数替换。因此,生成的“捆绑”脚本不包含任何 import/export,它不需要 type="module",我们可以将其放入常规脚本中

<!-- Assuming we got bundle.js from a tool like Webpack -->
<script src="bundle.js"></script>

也就是说,本机模块也可以使用。因此,我们不会在这里使用 Webpack:您可以稍后对其进行配置。

总结

总结一下,核心概念是

  1. 模块是一个文件。要使 import/export 正常工作,浏览器需要 <script type="module">。模块有几个不同之处
    • 默认情况下延迟。
    • Async 在内联脚本上工作。
    • 要从另一个来源(域/协议/端口)加载外部脚本,需要 CORS 头。
    • 忽略重复的外部脚本。
  2. 模块有自己的本地顶级作用域,并通过 import/export 交换功能。
  3. 模块始终 use strict
  4. 模块代码仅执行一次。导出创建一次并在导入者之间共享。

当我们使用模块时,每个模块都实现功能并导出它。然后我们使用 import 在需要的地方直接导入它。浏览器自动加载并评估脚本。

在生产中,人们经常使用捆绑器(例如 Webpack)将模块捆绑在一起以提高性能和其他原因。

在下一章中,我们将看到更多模块示例,以及如何导出/导入内容。

教程地图

评论

在评论之前阅读此内容…
  • 如果您有改进建议 - 请 提交 GitHub 问题或提交请求,而不是发表评论。
  • 如果您无法理解文章中的内容 - 请详细说明。
  • 要插入一些代码,请使用 <code> 标记,对于多行 - 将它们包装在 <pre> 标记中,对于 10 行以上 - 使用沙盒(plnkrjsbincodepen…)