JavaScript 模块

发布于 · 标签:ECMAScript ES2015

JavaScript 模块现在已 在所有主流浏览器中得到支持

本文介绍了如何使用 JS 模块、如何负责任地部署它们以及 Chrome 团队如何努力在未来使模块变得更好。

什么是 JS 模块? #

JS 模块(也称为“ES 模块”或“ECMAScript 模块”)是一项主要的新功能,或者更确切地说是一组新功能。您可能过去使用过用户级 JavaScript 模块系统。也许您使用过 Node.js 中的 CommonJS,或者也许是 AMD,或者可能是其他东西。所有这些模块系统都有一个共同点:它们允许您导入和导出内容。

JavaScript 现在拥有针对此目的的标准化语法。在模块中,您可以使用 export 关键字导出几乎任何东西。您可以导出 constfunction 或任何其他变量绑定或声明。只需在变量语句或声明前加上 export,您就完成了。

// 📁 lib.mjs
export const repeat = (string) => `${string} ${string}`;
export function shout(string) {
return `${string.toUpperCase()}!`;
}

然后,您可以使用 import 关键字从另一个模块导入模块。在这里,我们从 lib 模块导入 repeatshout 功能,并在我们的 main 模块中使用它。

// 📁 main.mjs
import {repeat, shout} from './lib.mjs';
repeat('hello');
// → 'hello hello'
shout('Modules in action');
// → 'MODULES IN ACTION!'

您还可以从模块导出一个默认值。

// 📁 lib.mjs
export default function(string) {
return `${string.toUpperCase()}!`;
}

此类 default 导出可以使用任何名称导入。

// 📁 main.mjs
import shout from './lib.mjs';
// ^^^^^

模块与经典脚本略有不同。

  • 模块默认情况下启用了 严格模式

  • 模块不支持 HTML 样式的注释语法,尽管它在经典脚本中有效。

    // Don’t use HTML-style comment syntax in JavaScript!
    const x = 42; <!-- TODO: Rename x to y.
    // Use a regular single-line comment instead:
    const x = 42; // TODO: Rename x to y.
  • 模块具有词法顶层作用域。这意味着例如,在模块中运行 var foo = 42; 不会创建一个名为 foo 的全局变量,该变量可以通过浏览器中的 window.foo 访问,尽管这在经典脚本中是这种情况。

  • 同样,模块中的 this 不会引用全局 this,而是 undefined。(如果您需要访问全局 this,请使用 globalThis。)

  • 新的静态 importexport 语法仅在模块中可用 - 它在经典脚本中不起作用。

  • 顶层 await 在模块中可用,但在经典脚本中不可用。相关地,await 不能用作模块中任何地方的变量名,尽管经典脚本中的变量可以在异步函数之外命名为 await

由于这些差异,相同的 JavaScript 代码在被视为模块与经典脚本时可能会有不同的行为。因此,JavaScript 运行时需要知道哪些脚本是模块。

在浏览器中使用 JS 模块 #

在网络上,您可以通过将 type 属性设置为 module 来告诉浏览器将 <script> 元素视为模块。

<script type="module" src="main.mjs"></script>
<script nomodule src="fallback.js"></script>

理解 type="module" 的浏览器会忽略具有 nomodule 属性的脚本。这意味着您可以为支持模块的浏览器提供基于模块的有效负载,同时为其他浏览器提供回退。能够做出这种区分非常棒,仅仅是为了性能!想想看:只有现代浏览器支持模块。如果浏览器理解您的模块代码,它也支持 模块出现之前的功能,例如箭头函数或 async-await。您不再需要在模块捆绑包中转译这些功能!您可以 为现代浏览器提供更小且基本上未转译的基于模块的有效负载。只有旧版浏览器才会获得 nomodule 有效负载。

由于 模块默认情况下是延迟的,您可能还想以延迟方式加载 nomodule 脚本。

<script type="module" src="main.mjs"></script>
<script nomodule defer src="fallback.js"></script>

模块和经典脚本之间的浏览器特定差异 #

如您所知,模块不同于经典脚本。除了我们上面概述的平台无关差异之外,还有一些特定于浏览器的差异。

例如,模块仅评估一次,而经典脚本会根据您将其添加到 DOM 的次数进行评估。

<script src="classic.js"></script>
<script src="classic.js"></script>
<!-- classic.js executes multiple times. -->

<script type="module" src="module.mjs"></script>
<script type="module" src="module.mjs"></script>
<script type="module">import './module.mjs';</script>
<!-- module.mjs executes only once. -->

此外,模块脚本及其依赖项使用 CORS 获取。这意味着任何跨域模块脚本都必须使用正确的标头提供,例如 Access-Control-Allow-Origin: *。这对于经典脚本来说并不适用。

另一个差异与 async 属性有关,该属性会导致脚本下载而不阻塞 HTML 解析器(如 defer),但它也会尽快执行脚本,没有保证的顺序,并且不会等待 HTML 解析完成。async 属性不适用于内联经典脚本,但它适用于内联 <script type="module">

关于文件扩展名的说明 #

您可能已经注意到我们正在为模块使用 .mjs 文件扩展名。在网络上,文件扩展名实际上并不重要,只要文件使用 JavaScript MIME 类型 text/javascript 提供即可。浏览器知道它是一个模块,因为脚本元素上的 type 属性。

尽管如此,我们还是建议您为模块使用 .mjs 扩展名,原因有两个。

  1. 在开发过程中,.mjs 扩展名使您和任何查看您的项目的人员都清楚地知道该文件是一个模块,而不是一个经典脚本。(仅仅通过查看代码并不总是能分辨出来。)如前所述,模块的处理方式不同于经典脚本,因此这种差异非常重要!
  2. 它确保您的文件被诸如 Node.jsd8 之类的运行时以及诸如 Babel 之类的构建工具解析为模块。虽然这些环境和工具各自都有通过配置来解释具有其他扩展名的文件作为模块的专有方法,但 .mjs 扩展名是确保文件被视为模块的跨兼容方法。

注意:要在网络上部署 .mjs,您的 Web 服务器需要配置为使用适当的 Content-Type: text/javascript 标头提供具有此扩展名的文件,如上所述。此外,您可能还想配置您的编辑器将 .mjs 文件视为 .js 文件以获得语法高亮显示。大多数现代编辑器默认情况下已经这样做。

模块说明符 #

import 模块时,指定模块位置的字符串称为“模块说明符”或“导入说明符”。在我们之前的示例中,模块说明符是 './lib.mjs'

import {shout} from './lib.mjs';
// ^^^^^^^^^^^

浏览器中的模块说明符有一些限制。所谓的“裸”模块说明符目前不受支持。此限制是 指定的,以便将来浏览器可以允许自定义模块加载器为以下裸模块说明符赋予特殊含义。

// Not supported (yet):
import {shout} from 'jquery';
import {shout} from 'lib.mjs';
import {shout} from 'modules/lib.mjs';

另一方面,以下示例都受支持。

// Supported:
import {shout} from './lib.mjs';
import {shout} from '../lib.mjs';
import {shout} from '/modules/lib.mjs';
import {shout} from 'https://simple.example/modules/lib.mjs';

目前,模块说明符必须是完整的 URL,或者以 /./../ 开头的相对 URL。

模块默认情况下是延迟的 #

经典 <script> 默认情况下会阻塞 HTML 解析器。您可以通过添加 defer 属性 来解决此问题,该属性可确保脚本下载与 HTML 解析并行进行。

模块脚本默认情况下是延迟的。因此,您无需在 <script type="module"> 标签中添加 defer!不仅主模块的下载与 HTML 解析并行进行,所有依赖模块也是如此!

其他模块功能 #

动态 import() #

到目前为止,我们只使用了静态 import。使用静态 import,您的整个模块图需要在主代码可以运行之前下载并执行。有时,您不想预先加载模块,而是按需加载,只有在需要时才加载 - 例如,当用户单击链接或按钮时。这提高了初始加载时间性能。 动态 import() 使这成为可能!

<script type="module">
(async () => {
const moduleSpecifier = './lib.mjs';
const {repeat, shout} = await import(moduleSpecifier);
repeat('hello');
// → 'hello hello'
shout('Dynamic import in action');
// → 'DYNAMIC IMPORT IN ACTION!'
})();
</script>

与静态 import 不同,动态 import() 可以从常规脚本中使用。它是在现有代码库中逐步开始使用模块的简便方法。有关更多详细信息,请参阅 我们关于动态 import() 的文章

注意: webpack 有自己的 import() 版本,它巧妙地将导入的模块拆分为自己的块,与主捆绑包分开。

import.meta #

另一个与模块相关的新功能是 import.meta,它为您提供有关当前模块的元数据。您获得的确切元数据未作为 ECMAScript 的一部分指定;它取决于主机环境。例如,在浏览器中,您可能获得的元数据与 Node.js 中不同。

以下是在网络上使用 import.meta 的示例。默认情况下,图像相对于 HTML 文档中的当前 URL 加载。import.meta.url 使加载相对于当前模块的图像成为可能。

function loadThumbnail(relativePath) {
const url = new URL(relativePath, import.meta.url);
const image = new Image();
image.src = url;
return image;
}

const thumbnail = loadThumbnail('../img/thumbnail.png');
container.append(thumbnail);

性能建议 #

保持捆绑 #

使用模块,可以开发不使用 webpack、Rollup 或 Parcel 等捆绑器的网站。在以下情况下,直接在本地使用原生 JS 模块是可以的。

  • 在本地开发期间
  • 在生产中用于总模块数少于 100 个且依赖树相对较浅(即最大深度小于 5 个)的小型 Web 应用程序

但是,正如我们在 对加载由 ~300 个模块组成的模块化库时 Chrome 加载管道的瓶颈分析 中了解到的那样,捆绑应用程序的加载性能优于未捆绑的应用程序。

原因之一是静态 import/export 语法是静态可分析的,因此它可以帮助捆绑器工具通过消除未使用的导出来优化您的代码。静态 importexport 不仅仅是语法;它们是一个重要的工具功能!

我们的一般建议是在将模块部署到生产环境之前继续使用捆绑器。在某种程度上,捆绑与压缩代码类似:它会导致性能提升,因为您最终会发送更少的代码。捆绑具有相同的效果!保持捆绑。

与往常一样,DevTools 代码覆盖率功能 可以帮助您确定是否正在向用户推送不必要的代码。我们还建议使用 代码拆分 来拆分捆绑包并延迟加载非首屏关键脚本。

捆绑与发送未捆绑模块的权衡 #

正如网页开发中常见的,一切都是权衡。发布未捆绑的模块可能会降低初始加载性能(冷缓存),但与发布单个未进行代码拆分的捆绑包相比,实际上可以提高后续访问的加载性能(热缓存)。对于一个 200 KB 的代码库,更改单个细粒度模块并使其成为后续访问中从服务器获取的唯一内容,比重新获取整个捆绑包要好得多。

如果您更关心具有热缓存的访问者的体验,而不是首次访问的性能,并且您的网站的细粒度模块数量不到几百个,您可以尝试发布未捆绑的模块,衡量冷热加载的性能影响,然后做出数据驱动的决策!

浏览器工程师正在努力改进模块的开箱即用性能。随着时间的推移,我们预计在更多情况下发布未捆绑的模块将变得可行。

使用细粒度模块 #

养成使用小而细粒度的模块编写代码的习惯。在开发过程中,通常每个模块只有几个导出比手动将大量导出合并到单个文件中更好。

考虑一个名为 ./util.mjs 的模块,它导出三个名为 droppluckzip 的函数

export function drop() { /* … */ }
export function pluck() { /* … */ }
export function zip() { /* … */ }

如果您的代码库只需要 pluck 功能,您可能需要按如下方式导入它

import {pluck} from './util.mjs';

在这种情况下(没有构建时捆绑步骤),浏览器最终仍然需要下载、解析和编译整个 ./util.mjs 模块,即使它实际上只需要那个导出。这很浪费!

如果 pluckdropzip 不共享任何代码,最好将其移到自己的细粒度模块中,例如 ./pluck.mjs

export function pluck() { /* … */ }

然后,我们可以导入 pluck,而无需处理 dropzip 的开销

import {pluck} from './pluck.mjs';

注意:您可以根据个人喜好使用 default 导出而不是命名导出。

这不仅使您的源代码保持简洁,而且还减少了对捆绑器执行的死代码消除的需求。如果源代码树中的某个模块未使用,则它永远不会被导入,因此浏览器永远不会下载它。确实被使用的模块可以由浏览器单独代码缓存。(实现此功能的基础设施已在 V8 中落地,并且正在进行工作以在 Chrome 中启用它。)

使用小而细粒度的模块有助于为您的代码库做好准备,以便将来可以使用本机捆绑解决方案

预加载模块 #

您可以通过使用<link rel="modulepreload">进一步优化模块的交付。这样,浏览器就可以预加载甚至预解析和预编译模块及其依赖项。

<link rel="modulepreload" href="lib.mjs">
<link rel="modulepreload" href="main.mjs">
<script type="module" src="main.mjs"></script>
<script nomodule src="fallback.js"></script>

这对于较大的依赖树尤其重要。如果没有 rel="modulepreload",浏览器需要执行多个 HTTP 请求才能找出完整的依赖树。但是,如果您使用 rel="modulepreload" 声明完整的依赖模块脚本列表,浏览器就不必逐步发现这些依赖项。

使用 HTTP/2 #

尽可能使用 HTTP/2 始终是良好的性能建议,即使只是为了其多路复用支持。使用 HTTP/2 多路复用,多个请求和响应消息可以同时处于活动状态,这对加载模块树很有利。

Chrome 团队调查了另一个 HTTP/2 功能,特别是HTTP/2 服务器推送,是否可以成为部署高度模块化应用程序的实用解决方案。不幸的是,HTTP/2 服务器推送很难正确实现,并且 Web 服务器和浏览器的实现目前没有针对高度模块化的 Web 应用程序用例进行优化。例如,很难只推送用户尚未缓存的资源,而通过将源的整个缓存状态传达给服务器来解决这个问题会带来隐私风险。

因此,请务必使用 HTTP/2!请记住,HTTP/2 服务器推送(不幸的是)不是灵丹妙药。

Web 上对 JS 模块的采用 #

JS 模块正在 Web 上逐渐普及。我们的使用计数器显示,目前 0.08% 的页面加载使用 <script type="module">。请注意,此数字不包括其他入口点,例如动态 import()worklets

JS 模块的下一步是什么? #

Chrome 团队正在努力以各种方式改进 JS 模块的开发时体验。让我们讨论其中的一些。

更快且确定性的模块解析算法 #

我们提议对模块解析算法进行更改,以解决速度和确定性方面的不足。新算法现在已在HTML 规范ECMAScript 规范中生效,并在Chrome 63中实现。预计此改进很快将在更多浏览器中落地!

新算法效率更高,速度更快。旧算法的计算复杂度为二次方,即 𝒪(n²),与依赖图的大小成正比,因此当时 Chrome 的实现也是如此。新算法是线性的,即 𝒪(n)。

此外,新算法以确定性的方式报告解析错误。给定一个包含多个错误的图,旧算法的不同运行可能会报告不同的错误是导致解析失败的原因。这使得调试变得不必要地困难。新算法保证每次都报告相同的错误。

Worklets 和 Web Workers #

Chrome 现在实现了worklets,它允许 Web 开发人员自定义 Web 浏览器“底层部分”中的硬编码逻辑。使用 worklets,Web 开发人员可以将 JS 模块馈送到渲染管道或音频处理管道(以及将来可能更多的管道!)。

Chrome 65 支持PaintWorklet(也称为 CSS Paint API)来控制 DOM 元素的绘制方式。

const result = await css.paintWorklet.addModule('paint-worklet.mjs');

Chrome 66 支持AudioWorklet,它允许您使用自己的代码控制音频处理。同一 Chrome 版本启动了AnimationWorklet 的 OriginTrial,它可以创建与滚动相关的和其他高性能过程动画。

最后,LayoutWorklet(也称为 CSS Layout API)现在已在 Chrome 67 中实现。

我们正在努力在 Chrome 中添加对使用 JS 模块与专用 Web Workers 的支持。您现在可以使用启用的 chrome://flags/#enable-experimental-web-platform-features 尝试此功能。

const worker = new Worker('worker.mjs', { type: 'module' });

共享工作者和服务工作者的 JS 模块支持即将推出

const worker = new SharedWorker('worker.mjs', { type: 'module' });
const registration = await navigator.serviceWorker.register('worker.mjs', { type: 'module' });

导入映射 #

在 Node.js/npm 中,通常通过“包名”导入 JS 模块。例如

import moment from 'moment';
import {pluck} from 'lodash-es';

目前,根据 HTML 规范,此类“裸导入说明符”会抛出异常。我们的导入映射提案允许此类代码在 Web 上运行,包括在生产应用程序中。导入映射是一个 JSON 资源,它帮助浏览器将裸导入说明符转换为完整 URL。

导入映射仍处于提案阶段。虽然我们已经对它们如何解决各种用例进行了深入思考,但我们仍在与社区互动,尚未编写完整的规范。欢迎反馈!

Web 打包:本机捆绑包 #

Chrome 加载团队目前正在探索本机 Web 打包格式作为一种新的 Web 应用程序分发方式。Web 打包的核心功能是

已签署的 HTTP 交换,它允许浏览器信任单个 HTTP 请求/响应对是由其声称的源生成的;捆绑的 HTTP 交换,即交换的集合,每个交换都可以是已签署的或未签署的,并包含一些元数据来描述如何将整个捆绑包解释为一个整体。

结合起来,这种 Web 打包格式将使多个同源资源能够安全地嵌入单个 HTTP GET 响应中。

现有的捆绑工具(如 webpack、Rollup 或 Parcel)目前会发出单个 JavaScript 捆绑包,其中原始独立模块和资产的语义会丢失。使用本机捆绑包,浏览器可以将资源解捆绑回其原始形式。简而言之,您可以将捆绑的 HTTP 交换想象成一组资源,可以通过目录(清单)以任何顺序访问这些资源,并且其中包含的资源可以根据其相对重要性进行有效存储和标记,同时保留单个文件的概念。因此,本机捆绑包可以改善调试体验。在 DevTools 中查看资产时,浏览器可以准确地找到原始模块,而无需复杂的源映射。

本机捆绑包格式的透明性为各种优化机会打开了大门。例如,如果浏览器已经本地缓存了本机捆绑包的一部分,它可以将此信息传达给 Web 服务器,然后只下载缺少的部分。

Chrome 已经支持提案的一部分(SignedExchanges),但捆绑格式本身及其在高度模块化应用程序中的应用仍处于探索阶段。我们非常欢迎您在存储库中或通过电子邮件发送反馈至[email protected]

分层 API #

发布新功能和 Web API 会产生持续的维护和运行时成本——每个新功能都会污染浏览器命名空间,增加启动成本,并且代表着在整个代码库中引入错误的新表面。分层 API是通过更可扩展的方式在 Web 浏览器中实现和发布更高级别 API 的一项努力。JS 模块是分层 API 的关键使能技术

  • 由于模块是显式导入的,因此要求分层 API 通过模块公开,以确保开发人员只为他们使用的分层 API 付费。
  • 由于模块加载是可配置的,因此分层 API 可以具有内置机制,用于在不支持分层 API 的浏览器中自动加载 polyfill。

模块和分层 API 如何协同工作仍在制定中,但当前提案看起来像这样

<script
type="module"
src="std:virtual-scroller|https://example.com/virtual-scroller.mjs"
>
</script>

<script> 元素从浏览器的内置分层 API 集(std:virtual-scroller)或指向 polyfill 的回退 URL 加载 virtual-scroller API。此 API 可以执行 Web 浏览器中 JS 模块可以执行的任何操作。一个示例是定义自定义 <virtual-scroller> 元素,以便以下 HTML 能够按需进行渐进增强

<virtual-scroller>
<!-- Content goes here. -->
</virtual-scroller>

致谢 #

感谢 Domenic Denicola、Georg Neis、Hiroki Nakagawa、Hiroshige Hayashizaki、Jakob Gruber、Kouhei Ueno、Kunihiko Sakamoto 和 Yang Guo 使 JavaScript 模块变得更快!

此外,感谢 Eric Bidelman、Jake Archibald、Jason Miller、Jeffrey Posnick、Philip Walton、Rob Dodson、Sam Dutton、Sam Thorogood 和 Thomas Steiner 阅读本指南的草稿并提供反馈。