捆绑非 JavaScript 资源

了解如何从 JavaScript 导入和打包各种类型的资源。

Ingvar Stepanyan
Ingvar Stepanyan

假设您正在开发 Web 应用。在这种情况下,您可能不仅要处理 JavaScript 模块,还要处理各种其他资源,例如 Web Worker(也是 JavaScript,但不是常规模块图的一部分)、图片、样式表、字体、WebAssembly 模块等。

您可以在 HTML 中直接加入对其中某些资源的引用,但它们往往在逻辑上与可重复使用的组件相关联。例如,与其 JavaScript 部分相关联的自定义下拉菜单的样式表、与工具栏组件相关联的图标图片,或与其 JavaScript 粘合剂相关联的 WebAssembly 模块。在这些情况下,更方便的方法是直接从 JavaScript 模块引用资源,并在相应组件加载时(如果加载)动态加载这些资源。

用于直观呈现导入到 JS 中的各种类型的资源的图表。

不过,大多数大型项目都有构建系统,可以对内容进行额外的优化和重组,例如捆绑和缩减。它们无法执行代码并预测执行结果,也无法遍历 JavaScript 中的每个可能的字符串字面量,并猜测其是否为资源网址。那么,如何让它们“看到”由 JavaScript 组件加载的动态资源,并将其包含在 build 中?

捆绑器中的自定义导入

一种常见方法是重复使用静态导入语法。在某些捆绑工具中,它可能会根据文件扩展名自动检测格式,而在其他捆绑工具中,插件可以使用自定义网址架构,如以下示例所示:

// regular JavaScript import
import { loadImg } from './utils.js';

// special "URL imports" for assets
import imageUrl from 'asset-url:./image.png';
import wasmUrl from 'asset-url:./module.wasm';
import workerUrl from 'js-url:./worker.js';

loadImg(imageUrl);
WebAssembly.instantiateStreaming(fetch(wasmUrl));
new Worker(workerUrl);

当捆绑器插件找到包含其可识别的扩展或此类显式自定义架构(上例中的 asset-url:js-url:)的导入时,它会将引用的资源添加到构建图,将其复制到最终目的地,执行适用于该资产类型的优化,并返回要在运行时使用的最终到达网址。

这种方法的好处在于:重复使用 JavaScript 导入语法可确保所有网址都是静态的,并且相对于当前文件,这使得构建系统可以轻松定位此类依赖项。

不过,它有一个重大缺点:此类代码无法直接在浏览器中运行,因为浏览器不知道如何处理这些自定义导入方案或扩展程序。如果您控制所有代码并依赖于捆绑器进行开发,这可能没什么问题,但越来越常见的是,直接在浏览器中使用 JavaScript 模块(至少在开发期间),以减少摩擦。开发小演示的人可能根本不需要打包器,即使是在生产环境中。

适用于浏览器和捆绑程序的通用模式

如果您正在开发可重复使用的组件,则希望它能够在任一环境中正常运行,无论是直接在浏览器中使用,还是作为更大型应用的一部分预构建。大多数现代版捆绑器都允许这样做,因为它们接受 JavaScript 模块中的以下模式:

new URL('./relative-path', import.meta.url)

工具可以静态检测此模式,就像它是一种特殊的语法一样,但它也是一个有效的 JavaScript 表达式,也可以直接在浏览器中运行。

使用此模式时,可将上面的示例重写为:

// regular JavaScript import
import { loadImg } from './utils.js';

loadImg(new URL('./image.png', import.meta.url));
WebAssembly.instantiateStreaming(
  fetch(new URL('./module.wasm', import.meta.url)),
  { /* … */ }
);
new Worker(new URL('./worker.js', import.meta.url));

How does it work? 我们来详细了解一下。new URL(...) 构造函数将相对网址作为第一个参数,并根据作为第二个参数提供的绝对网址解析该相对网址。在本例中,第二个参数是 import.meta.url,它提供当前 JavaScript 模块的网址,因此第一个参数可以是相对于它的任何路径。

这种方法的权衡方式与动态导入类似。虽然 import(...) 可以用于任意表达式(如 import(someUrl)),但捆绑器会对具有静态网址 import('./some-static-url.js') 的模式进行特殊处理,以作为预处理时已知的依赖项的方式,同时将其拆分为一个动态加载的独立块

同样,您可以将 new URL(...)new URL(relativeUrl, customAbsoluteBase) 等任意表达式搭配使用,但 new URL('...', import.meta.url) 模式会向捆绑工具发出明确信号,让其对依赖项进行预处理,并将其与主要 JavaScript 文件一起包含在内。

模糊不清的相对网址

您可能会想知道,为什么捆绑器无法检测其他常见模式,例如不带 new URL 封装容器的 fetch('./module.wasm')

原因在于,与 import 语句不同,任何动态请求都是相对于文档本身进行解析,而不是相对于当前的 JavaScript 文件进行解析。假设您有以下结构:

  • index.html
    html <script src="src/main.js" type="module"></script>
  • src/
    • main.js
    • module.wasm

如果您想从 main.js 加载 module.wasm,可能会很想使用 fetch('./module.wasm') 等相对路径。

不过,fetch 不知道其所执行的 JavaScript 文件的网址,而是相对于文档解析网址。因此,fetch('./module.wasm') 最终会尝试加载 http://example.com/module.wasm 而非预期的 http://example.com/src/module.wasm,并会失败(更糟糕的是,会静默加载与您预期不同的资源)。

通过将相对网址封装到 new URL('...', import.meta.url) 中,您可以避免此问题,并保证在传递给任何加载器之前,任何提供的网址都会相对于当前 JavaScript 模块 (import.meta.url) 的网址进行解析。

fetch('./module.wasm') 替换为 fetch(new URL('./module.wasm', import.meta.url)),这样它就会成功加载预期的 WebAssembly 模块,并为打包器提供在构建时查找这些相对路径的方法。

工具支持

打包器

以下捆绑器已经支持 new URL 架构:

WebAssembly

使用 WebAssembly 时,您通常不会手动加载 Wasm 模块,而是会导入工具链发出的 JavaScript 粘合代码。以下工具链可在后台发出上述 new URL(...) 模式。

通过 Emscripten 使用 C/C++

使用 Emscripten 时,您可以通过以下选项之一,要求其以 ES6 模块(而不是常规脚本)的形式发出 JavaScript 粘合剂:

$ emcc input.cpp -o output.mjs
## or, if you don't want to use .mjs extension
$ emcc input.cpp -o output.js -s EXPORT_ES6

使用此选项时,输出将在后台使用 new URL(..., import.meta.url) 模式,以便捆绑程序可以自动找到关联的 Wasm 文件。

您还可以通过添加 -pthread 标志,将此选项与 WebAssembly 线程搭配使用:

$ emcc input.cpp -o output.mjs -pthread
## or, if you don't want to use .mjs extension
$ emcc input.cpp -o output.js -s EXPORT_ES6 -pthread

在这种情况下,生成的 Web Worker 将以相同的方式包含在内,并且可供捆绑器和浏览器发现。

通过 wasm-pack / wasm-bindgen 使用 Rust

wasm-pack(WebAssembly 的主要 Rust 工具链)也具有多种输出模式。

默认情况下,它会发出依赖于 WebAssembly ESM 集成提案的 JavaScript 模块。在撰写本文时,此提案仍处于实验阶段,并且输出内容只有与 Webpack 捆绑后才能正常运行。

不过,您可以通过 --target web 让 wasm-pack 生成与浏览器兼容的 ES6 模块:

$ wasm-pack build --target web

输出将使用所述的 new URL(..., import.meta.url) 模式,并且 Wasm 文件也将由捆绑器自动发现。

如果您想将 WebAssembly 线程与 Rust 搭配使用,情况会稍微复杂一些。如需了解详情,请参阅指南的相应部分

简而言之,您无法使用任意线程 API,但如果您使用 Rayon,则可以将其与 wasm-bindgen-rayon 适配器结合使用,以便在 Web 上生成 Worker。wasm-bindgen-rayon 使用的 JavaScript 粘合程序在底层还包含 new URL(...) 模式,因此捆绑器也能够发现 Worker 并将其包含在内。

未来功能

import.meta.resolve

我们可能会在未来改进为专用 import.meta.resolve(...) 调用。这样,您就可以更直接地解析相对于当前模块的说明符,而无需额外的参数:

new URL('...', import.meta.url)
await import.meta.resolve('...')

此外,它还可以与导入映射和自定义解析器更好地集成,因为它会使用与 import 相同的模块解析系统。这对捆绑器来说也是一个更强烈的信号,因为它是一种静态语法,不依赖于 URL 等运行时 API。

import.meta.resolve作为 Node.js 中的一项实验实现,但关于它在 Web 上的工作方式,仍有一些未解答的问题

导入断言

导入断言是一项新功能,可用于导入 ECMAScript 模块以外的类型。目前,它们仅限于 JSON:

foo.json:

{ "answer": 42 }

main.mjs:

import json from './foo.json' assert { type: 'json' };
console.log(json.answer); // 42

它们也可能会被捆绑器使用,并取代目前由 new URL 模式涵盖的用例,但导入断言中的类型是按具体情况添加的。目前,它们仅涵盖 JSON,CSS 模块即将推出,但其他类型的资源仍需要更通用的解决方案。

如需详细了解此功能,请参阅 v8.dev 功能说明

总结

如您所见,在 Web 上包含非 JavaScript 资源有多种方法,但它们各有缺点,并且无法在各种工具链中使用。未来的提案可能会允许我们使用专用语法导入此类资源,但我们尚未达到这一目标。

在此之前,new URL(..., import.meta.url) 模式是目前最有前途的解决方案,它已在浏览器、各种捆绑工具和 WebAssembly 工具链中运行。