捆绑非 JavaScript 资源

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

英格瓦·斯捷潘安 (Ingvar Stepanyan)
Ingvar Stepanyan

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

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

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

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

打包器中的自定义导入

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

// 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:)时,它会将引用的资源添加到 build 图中,将其复制到最终目的地,执行适用于资源类型的优化,并返回要在运行时使用的最终到达网址。

这种方法的优势:重复使用 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));

工作原理我们把它拆开。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 时,您可以通过以下选项之一,要求其将 JavaScript 粘合代码作为 ES6 模块(而不是常规脚本)发出:

$ 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 将以相同的方式包含,并且可由捆绑器和浏览器等发现。

Rust 通过 wasm-pack / wasm-bindgen 部署

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

默认情况下,它将发出一个依赖于 WebAssembly ESM 集成方案的 JavaScript 模块。在撰写本文时,该方案仍处于实验阶段,其输出仅在与 Webpack 捆绑在一起时有效。

您可以要求 wasm-pack 通过 --target web 发出与浏览器兼容的 ES6 模块:

$ wasm-pack build --target web

输出将使用上述 new URL(..., import.meta.url) 模式,而 Wasm 文件也会自动被捆绑器发现。

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

简写形式是您不能使用任意线程 API,但如果您使用 Rayon,则可以将其与 wasm-bindgen-rayon 适配器相结合,这样它就可以在网络上生成 worker。wasm-bindgen-rayon 使用的 JavaScript 粘合剂还包含 new URL(...) 模式在后台,因此捆绑器也会发现 Workers。

未来的功能

import.meta.resolve

专用的 import.meta.resolve(...) 调用是未来有望进行的改进。它允许以更直接的方式解析相对于当前模块的说明符,而无需额外的参数:

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

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

import.meta.resolve 已在 Node.js 中以实验的形式实现,但关于如何在网页上运行,仍有一些未解决的问题

导入断言

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

foo.json:

{ "answer": 42 }

main.mjs:

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

捆绑器也可能会使用这些类型,以替换 new URL 模式当前涵盖的用例,但导入断言中的类型是按 case 添加的。目前,它们仅涵盖 JSON,CSS 模块很快就会推出,但其他类型的素材资源仍需要更通用的解决方案。

查看 v8.dev 功能说明,详细了解此功能。

总结

如您所见,在 Web 中包含非 JavaScript 资源的方法有很多,但它们存在各种缺点,并且不适用于各种工具链。未来的提案可能会让我们能使用专用语法导入此类素材资源,但目前这方面还不行。

在那之前,new URL(..., import.meta.url) 模式是最具前景的解决方案,现可用于浏览器、各种捆绑器和 WebAssembly 工具链。