JavaScript 以外のリソースのバンドル

JavaScript からさまざまな種類のアセットをインポートしてバンドルする方法について学習します。

ウェブアプリを開発しているとします。その場合、JavaScript モジュールだけでなく、さまざまなリソース(Web Worker(JavaScript でもあるが、通常のモジュールグラフの一部ではない)、画像、スタイルシート、フォント、WebAssembly モジュールなど)を扱う必要がある可能性があります。

これらのリソースの一部への参照を HTML に直接含めることもできますが、多くの場合、再利用可能なコンポーネントに論理的に結合されます。たとえば、JavaScript 部分に関連付けられたカスタム プルダウンのスタイルシート、ツールバー コンポーネントに関連付けられたアイコン画像、JavaScript グルーピングに関連付けられた WebAssembly モジュールなどです。このような場合は、JavaScript モジュールからリソースを直接参照し、対応するコンポーネントが読み込まれたときに(または読み込まれる場合に)リソースを動的に読み込む方が便利です。

JS にインポートされたさまざまな種類のアセットを可視化したグラフ。

ただし、ほとんどの大規模なプロジェクトには、バンドルや圧縮など、コンテンツの追加の最適化と再編成を行うビルドシステムがあります。コードを実行して実行結果を予測することはできません。また、JavaScript で可能なすべての文字列リテラルを走査して、リソース URL かどうかを推測することもできません。では、JavaScript コンポーネントによって読み込まれた動的アセットをビルドに含めるにはどうすればよいでしょうか。

バンドルツールのカスタム インポート

一般的なアプローチの 1 つは、静的インポート構文を再利用することです。一部のバンドルツールでは、ファイル拡張子から形式が自動的に検出されますが、他のバンドルツールでは、次の例のようにプラグインがカスタム URL スキームを使用できます。

// 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:)を含むインポートを見つけると、参照先のアセットをビルドグラフに追加し、最終的な宛先にコピーします。また、アセットのタイプに適用可能な最適化を実行し、実行時に使用される最終的な URL を返します。

このアプローチのメリット: JavaScript インポート構文を再利用することで、すべての URL が静的で現在のファイルに相対的になることが保証され、ビルドシステムでこのような依存関係を簡単に見つけることができます。

ただし、1 つの大きな欠点があります。ブラウザはカスタム インポート スキームや拡張機能を処理できないため、このようなコードをブラウザで直接実行することはできません。すべてのコードを制御し、開発にはバンドルツールに依存している場合は問題ありませんが、摩擦を軽減するために、少なくとも開発中はブラウザで 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(...) コンストラクタは、1 つ目の引数として相対 URL を受け取り、2 つ目の引数として指定された絶対 URL に対して解決します。この例では、2 番目の引数は import.meta.url で、現在の JavaScript モジュールの URL を指定します。そのため、最初の引数には、そのモジュールを基準とした任意のパスを指定できます。

動的インポートと同様のトレードオフがあります。import(...)import(someUrl) などの任意の式で使用できますが、バンドラは静的 URL import('./some-static-url.js') を含むパターンを特別に処理します。これは、コンパイル時に既知の依存関係を事前処理し、動的に読み込まれる独自のチャンクに分割する方法です。

同様に、new URL(...)new URL(relativeUrl, customAbsoluteBase) などの任意の式で使用できますが、new URL('...', import.meta.url) パターンは、バンドラーが依存関係をプリプロセッシングしてメインの JavaScript とともに含めるように明示的に指示します。

曖昧な相対 URL

バンドルツールが、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 ファイルの URL を認識しません。代わりに、ドキュメントを基準に URL を解決します。その結果、fetch('./module.wasm') は意図した http://example.com/src/module.wasm ではなく http://example.com/module.wasm を読み込もうとして失敗します(または、意図したリソースとは異なるリソースがサイレントで読み込まれることもあります)。

相対 URL を new URL('...', import.meta.url) にラップすることで、この問題を回避し、指定された URL がローダに渡される前に、現在の JavaScript モジュール(import.meta.url)の URL を基準に解決されるようにできます。

fetch('./module.wasm')fetch(new URL('./module.wasm', import.meta.url)) に置き換えると、想定される WebAssembly モジュールが正常に読み込まれ、バンドラはビルド時にそれらの相対パスを見つける方法も得られます。

ツールのサポート

バンドラ

次のバンドラはすでに new URL スキームをサポートしています。

WebAssembly

WebAssembly を扱う場合、通常は Wasm モジュールを手動で読み込むのではなく、ツールチェーンによって出力された JavaScript グルーイングをインポートします。次の toolchain では、説明した new URL(...) パターンが自動的に生成されます。

Emscripten を介した C/C++

Emscripten を使用する場合は、次のいずれかのオプションを使用して、JavaScript グルーピングを通常のスクリプトではなく ES6 モジュールとして出力するように Emscripten に指示できます。

$ 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

WebAssembly 用の主な Rust ツールチェーンである wasm-pack にも、いくつかの出力モードがあります。

デフォルトでは、WebAssembly ESM 統合プロポーザルに依存する JavaScript モジュールが出力されます。執筆時点では、この提案はまだ試験運用版であり、出力は Webpack とバンドルされている場合にのみ機能します。

代わりに、--target web を介してブラウザ互換の ES6 モジュールを出力するよう wasm-pack に指示できます。

$ wasm-pack build --target web

出力では、説明した new URL(..., import.meta.url) パターンが使用され、Wasm ファイルもバンドルツールによって自動的に検出されます。

Rust で WebAssembly スレッドを使用する場合は、状況が少し複雑になります。詳しくは、ガイドの該当するセクションをご覧ください。

簡単に説明すると、任意のスレッド API は使用できませんが、Rayon を使用する場合は、wasm-bindgen-rayon アダプターと組み合わせて、ウェブでワーカーを生成できます。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 で試験運用版として実装されていますが、ウェブでの動作方法については未解決の問題がいくつかあります。

アサーションをインポートする

インポート アサーションとは、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 の機能の説明をご覧ください。

まとめ

ご覧のとおり、JavaScript 以外のリソースをウェブに含める方法はいくつかありますが、それぞれにさまざまな欠点があり、さまざまなツールチェーンで動作しません。今後の提案では、このようなアセットを特殊な構文でインポートできるようになるかもしれませんが、現時点ではそうではありません。

それまでは、new URL(..., import.meta.url) パターンが最も有望なソリューションです。このパターンは、ブラウザ、さまざまなバンドルツール、WebAssembly ツールチェーンですでに動作しています。