了解如何从 JavaScript 导入和打包各种类型的资源。
假设您正在开发 Web 应用。在这种情况下,您可能不仅要处理 JavaScript 模块,还要处理各种其他资源,例如 Web Worker(也是 JavaScript,但不是常规模块图的一部分)、图片、样式表、字体、WebAssembly 模块等。
您可以在 HTML 中直接加入对其中某些资源的引用,但它们往往在逻辑上与可重复使用的组件相关联。例如,与其 JavaScript 部分相关联的自定义下拉菜单的样式表、与工具栏组件相关联的图标图片,或与其 JavaScript 粘合剂相关联的 WebAssembly 模块。在这些情况下,更方便的方法是直接从 JavaScript 模块引用资源,并在相应组件加载时(如果加载)动态加载这些资源。
不过,大多数大型项目都有构建系统,可以对内容进行额外的优化和重组,例如捆绑和缩减。它们无法执行代码并预测执行结果,也无法遍历 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
架构:
- Webpack v5
- 汇总(通过插件实现 - @web/rollup-plugin-import-meta-assets 适用于通用素材资源,@surma/rollup-plugin-off-main-thread 专门适用于 Worker)。
- Parcel v2(Beta 版)
- Vite
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 工具链中运行。