Verschiedene Arten von Assets aus JavaScript importieren und bündeln
Angenommen, Sie arbeiten an einer Webanwendung. In diesem Fall haben Sie wahrscheinlich nicht nur mit JavaScript-Modulen, sondern auch mit allen möglichen anderen Ressourcen zu tun: Web Worker (auch JavaScript, aber nicht Teil des regulären Modulgraphs), Bilder, Stylesheets, Schriftarten, WebAssembly-Module und mehr.
Es ist möglich, Verweise auf einige dieser Ressourcen direkt in den HTML-Code aufzunehmen, sie sind jedoch oft logisch mit wiederverwendbaren Komponenten gekoppelt. Beispielsweise ein Stylesheet für ein benutzerdefiniertes Drop-down-Menü, das mit dem JavaScript-Teil verknüpft ist, Symbolbilder, die mit einer Symbolleiste verknüpft sind, oder ein WebAssembly-Modul, das mit dem JavaScript-Bindecode verknüpft ist. In diesen Fällen ist es einfacher, direkt über die JavaScript-Module auf die Ressourcen zu verweisen und sie dynamisch zu laden, wenn (oder falls) die entsprechende Komponente geladen wird.
Die meisten großen Projekte haben jedoch Build-Systeme, die zusätzliche Optimierungen und Umorganisationen von Inhalten vornehmen, z. B. Bündelung und Minimierung. Sie können nicht den Code ausführen und nicht vorhersagen, wie das Ergebnis der Ausführung aussehen wird. Sie können auch nicht jedes mögliche String-Literal in JavaScript durchlaufen und Vermutungen darüber anstellen, ob es sich um eine Ressourcen-URL handelt oder nicht. Wie können Sie also sicherstellen, dass diese die dynamischen Assets, die von JavaScript-Komponenten geladen werden, sehen und in den Build aufnehmen?
Benutzerdefinierte Importe in Bundlern
Eine gängige Methode ist die Wiederverwendung der Syntax für den statischen Import. Bei einigen Bundlern wird das Format möglicherweise automatisch anhand der Dateiendung erkannt, während andere Plugins ein benutzerdefiniertes URL-Schema verwenden können, wie im folgenden Beispiel:
// 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);
Wenn ein Bundler-Plug-in einen Import mit einer erkannten Erweiterung oder einem solchen expliziten benutzerdefinierten Schema (asset-url:
und js-url:
im Beispiel oben) findet, fügt es das referenzierte Asset dem Build-Graph hinzu, kopiert es an das endgültige Ziel, führt Optimierungen für den Asset-Typ durch und gibt die finale URL zurück, die während der Laufzeit verwendet werden soll.
Vorteile dieses Ansatzes: Durch die Wiederverwendung der JavaScript-Importsyntax wird sichergestellt, dass alle URLs statisch und relativ zur aktuellen Datei sind. Das erleichtert dem Build-System das Auffinden solcher Abhängigkeiten.
Es gibt jedoch einen wesentlichen Nachteil: Dieser Code kann nicht direkt im Browser ausgeführt werden, da der Browser nicht weiß, wie er mit diesen benutzerdefinierten Importschemata oder Erweiterungen umgehen soll. Das ist in Ordnung, wenn Sie den gesamten Code verwalten und für die Entwicklung ohnehin einen Bundler verwenden. Es wird jedoch immer häufiger, JavaScript-Module zumindest während der Entwicklung direkt im Browser zu verwenden, um die Abläufe zu vereinfachen. Wer an einer kleinen Demo arbeitet, benötigt möglicherweise gar keinen Bundler, auch nicht in der Produktion.
Universelles Muster für Browser und Bundler
Wenn Sie an einer wiederverwendbaren Komponente arbeiten, sollte sie in beiden Umgebungen funktionieren, unabhängig davon, ob sie direkt im Browser verwendet oder als Teil einer größeren App vorkonfiguriert wird. Die meisten modernen Bundler ermöglichen dies, indem sie das folgende Muster in JavaScript-Modulen akzeptieren:
new URL('./relative-path', import.meta.url)
Dieses Muster kann statisch von Tools erkannt werden, fast so, als wäre es eine spezielle Syntax. Es ist jedoch ein gültiger JavaScript-Ausdruck, der auch direkt im Browser funktioniert.
Mit diesem Muster kann das obige Beispiel so umgeschrieben werden:
// 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));
Wie funktioniert das? Lass uns das trennen. Der Konstruktor new URL(...)
nimmt eine relative URL als erstes Argument und löst sie anhand einer absoluten URL auf, die als zweites Argument angegeben wird. In unserem Fall ist das zweite Argument import.meta.url
und gibt die URL des aktuellen JavaScript-Moduls an. Das erste Argument kann also ein beliebiger relativ dazu stehender Pfad sein.
Es hat ähnliche Nachteile wie beim dynamischen Import. Es ist zwar möglich, import(...)
mit beliebigen Ausdrücken wie import(someUrl)
zu verwenden, aber die Bundler behandeln ein Muster mit statischer URL import('./some-static-url.js')
besonders. So können sie eine Abhängigkeit, die zur Kompilierungszeit bekannt ist, vorprozessieren und in einen eigenen Chunk aufteilen, der dynamisch geladen wird.
Ebenso können Sie new URL(...)
mit beliebigen Ausdrücken wie new URL(relativeUrl, customAbsoluteBase)
verwenden. Das new URL('...', import.meta.url)
-Muster ist jedoch ein klares Signal für Bundler, eine Abhängigkeit zusammen mit dem Haupt-JavaScript zu verarbeiten und einzubinden.
Mehrdeutige relative URLs
Sie fragen sich vielleicht, warum andere gängige Muster nicht erkannt werden können, z. B. fetch('./module.wasm')
ohne die new URL
-Wrapper?
Der Grund dafür ist, dass im Gegensatz zu Importanweisungen alle dynamischen Anfragen relativ zum Dokument selbst und nicht in der aktuellen JavaScript-Datei aufgelöst werden. Angenommen, Sie haben die folgende Struktur:
index.html
:
html <script src="src/main.js" type="module"></script>
src/
main.js
module.wasm
Wenn Sie module.wasm
aus main.js
laden möchten, ist es vielleicht verlockend, einen relativen Pfad wie fetch('./module.wasm')
zu verwenden.
fetch
kennt jedoch nicht die URL der JavaScript-Datei, in der es ausgeführt wird, und löst die URLs relativ zum Dokument auf. Daher versucht fetch('./module.wasm')
, http://example.com/module.wasm
anstelle der beabsichtigten http://example.com/src/module.wasm
zu laden, was fehlschlägt. Schlimmer noch: Es wird möglicherweise eine andere Ressource als beabsichtigt geladen.
Wenn Sie die relative URL in new URL('...', import.meta.url)
einschließen, können Sie dieses Problem vermeiden und dafür sorgen, dass jede angegebene URL relativ zur URL des aktuellen JavaScript-Moduls (import.meta.url
) aufgelöst wird, bevor sie an die Lader übergeben wird.
Ersetzen Sie fetch('./module.wasm')
durch fetch(new URL('./module.wasm', import.meta.url))
, damit das erwartete WebAssembly-Modul geladen wird. Außerdem können Bundler diese relativen Pfade auch während der Buildzeit finden.
Tool-Support
Paketersteller
Die folgenden Bundler unterstützen das new URL
-Schema bereits:
- Webpack v5
- Rollup (über Plug-ins erzielt – @web/rollup-plugin-import-meta-assets für allgemeine Assets und @surma/rollup-plugin-off-main-thread für Worker.
- Parcel v2 (Beta)
- Vite
WebAssembly
Bei der Arbeit mit WebAssembly laden Sie das Wasm-Modul in der Regel nicht manuell, sondern importieren den von der Toolchain ausgegebenen JavaScript-Glue. Die folgenden Toolchains können das beschriebene new URL(...)
-Muster für Sie generieren.
C/C++ über Emscripten
Wenn Sie Emscripten verwenden, können Sie über eine der folgenden Optionen angeben, dass JavaScript-Bindungen als ES6-Modul statt als reguläres Script generiert werden sollen:
$ 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
Wenn Sie diese Option verwenden, wird in der Ausgabe das Muster new URL(..., import.meta.url)
verwendet, damit Bundler die zugehörige Wasm-Datei automatisch finden können.
Sie können diese Option auch mit WebAssembly-Threads verwenden, indem Sie ein -pthread
-Flag hinzufügen:
$ 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
In diesem Fall wird der generierte Web Worker auf dieselbe Weise eingefügt und ist sowohl für Bundler als auch für Browser sichtbar.
Rust über wasm-pack / wasm-bindgen
wasm-pack, die primäre Rust-Toolchain für WebAssembly, bietet ebenfalls mehrere Ausgabemodi.
Standardmäßig wird ein JavaScript-Modul ausgegeben, das auf dem Integrationsvorschlag von WebAssembly ESM basiert. Zum Zeitpunkt der Veröffentlichung dieses Dokuments befindet sich dieser Vorschlag noch in der experimentellen Phase und die Ausgabe funktioniert nur, wenn sie mit Webpack gebundelt ist.
Stattdessen können Sie wasm-pack bitten, ein browserkompatibles ES6-Modul über --target web
auszugeben:
$ wasm-pack build --target web
Die Ausgabe verwendet das beschriebene new URL(..., import.meta.url)
-Muster und die Wasm-Datei wird auch von Bundlern automatisch erkannt.
Wenn Sie WebAssembly-Threads mit Rust verwenden möchten, ist die Situation etwas komplizierter. Weitere Informationen finden Sie im entsprechenden Abschnitt des Leitfadens.
Kurz gesagt: Sie können keine beliebigen Thread-APIs verwenden. Wenn Sie jedoch Rayon verwenden, können Sie es mit dem Adapter wasm-bindgen-rayon kombinieren, damit Worker im Web gestartet werden können. Der von wasm-bindgen-rayon verwendete JavaScript-Bindecode enthält auch das new URL(...)
-Muster. Daher werden die Worker von Bundlern erkannt und eingeschlossen.
Zukünftige Funktionen
import.meta.resolve
Ein dedizierter import.meta.resolve(...)
-Anruf ist eine potenzielle Verbesserung. So können Spezifikationen einfacher relativ zum aktuellen Modul aufgelöst werden, ohne zusätzliche Parameter:
new URL('...', import.meta.url)
await import.meta.resolve('...')
Außerdem lässt sich besser in Importkarten und benutzerdefinierte Resolver einbinden, da es dasselbe Modulauflösungssystem wie import
verwendet. Es wäre auch für Bundler ein stärkeres Signal, da es sich um eine statische Syntax handelt, die nicht von Laufzeit-APIs wie URL
abhängig ist.
import.meta.resolve
ist bereits als Test in Node.js implementiert, aber es gibt noch einige ungeklärte Fragen dazu, wie es im Web funktionieren soll.
Importaussagen
Importaussagen sind eine neue Funktion, mit der andere Typen als ECMAScript-Module importiert werden können. Derzeit sind sie auf JSON beschränkt:
foo.json:
{ "answer": 42 }
main.mjs:
import json from './foo.json' assert { type: 'json' };
console.log(json.answer); // 42
Sie können auch von Bundlern verwendet werden und die Anwendungsfälle ersetzen, die derzeit vom new URL
-Muster abgedeckt werden. Typen in Importaussagen werden jedoch jeweils einzeln hinzugefügt. Derzeit werden nur JSON-Dateien unterstützt. CSS-Module werden bald folgen. Für andere Arten von Assets ist jedoch weiterhin eine allgemeinere Lösung erforderlich.
Weitere Informationen zu dieser Funktion finden Sie in der Funktionsbeschreibung auf v8.dev.
Fazit
Wie Sie sehen, gibt es verschiedene Möglichkeiten, nicht-JavaScript-Ressourcen im Web einzubinden. Diese haben jedoch verschiedene Nachteile und funktionieren nicht für verschiedene Toolchains. In Zukunft werden wir solche Assets möglicherweise mit einer speziellen Syntax importieren können, aber wir sind noch nicht ganz so weit.
Bis dahin ist das new URL(..., import.meta.url)
-Muster die vielversprechendste Lösung, die bereits in Browsern, verschiedenen Bundlern und WebAssembly-Toolchains funktioniert.