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

module.wasmmain.js から読み込む場合は、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 ツールチェーンですでに動作しています。