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

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

たとえば、ウェブアプリを扱っている場合、JavaScript モジュールだけでなく、Web Worker(通常のモジュール グラフの一部ではないが JavaScript でもある)、画像、スタイルシート、フォント、WebAssembly モジュールなど、他のあらゆる種類のリソースを扱う必要があるでしょう。

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

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

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

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

一般的な方法の一つは、静的インポート構文の再利用です。バンドラの中には、ファイル拡張子によって形式を自動検出するものと、プラグインで次の例のようにカスタム 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 が現在のファイルに対する相対 URL であることが保証されるため、ビルドシステムがこのような依存関係を見つけやすくなります。

ただし、ブラウザはカスタム インポート スキームや拡張機能の処理方法を知らないため、ブラウザ内で直接動作しないという大きな欠点があります。すべてのコードを制御し、いずれにせよ、開発はバンドラに依存している場合は問題ありませんが、煩わしさを軽減するために、少なくとも開発中は、ブラウザ内で 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 番目の引数は現在の JavaScript モジュールの URL を示す import.meta.url であるため、1 番目の引数には任意の相対パスを指定できます。

動的インポートと同様のトレードオフがあります。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') など)と疑問に思われるかもしれません。

これは、インポート ステートメントとは異なり、動的リクエストは現在の 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 の接着剤をインポートします。次のツールチェーンを使用すると、上記の 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 を介してブラウザ互換の 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 パターンでカバーされているユースケースを置き換えることができますが、import アサーションの型はケースごとに追加されます。現時点では JSON のみを対象としており、CSS モジュールは近日公開予定です。ただし、他の種類のアセットでは、より一般的なソリューションが必要になります。

この機能について詳しくは、v8.dev の機能の説明をご覧ください。

まとめ

JavaScript 以外のリソースをウェブに含めるにはさまざまな方法がありますが、さまざまなデメリットがあり、さまざまなツールチェーンでは機能しません。今後の提案では、特殊な構文でそのようなアセットをインポートできるようになるかもしれませんが、まだ十分ではありません。

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