Nicht-JavaScript-Ressourcen bündeln

Hier erfährst du, wie du verschiedene Arten von Assets aus JavaScript importierst und bündelst.

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.

Verweise auf einige dieser Ressourcen können direkt in den HTML-Code eingefügt werden, häufig sind sie jedoch logisch mit wiederverwendbaren Komponenten verknüpft. 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.

Diagramm, das verschiedene Arten von Assets visualisiert, die in JS importiert wurden

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 den Code nicht ausführen und das Ergebnis der Ausführung nicht vorhersagen. Außerdem können sie nicht jedes mögliche Stringliteral in JavaScript durchsuchen und Vermutungen darüber anstellen, ob es sich um eine Ressourcen-URL handelt oder nicht. Wie können Sie also dafür sorgen, dass diese dynamischen Assets, die von JavaScript-Komponenten geladen werden, „gesehen“ und in den Build eingeschlossen werden?

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 die Suche nach solchen 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? Sehen wir uns das genauer an. 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, die URL des aktuellen JavaScript-Moduls. Das erste Argument kann also ein beliebiger Pfad relativ dazu sein.

Die Vor- und Nachteile ähneln denen des dynamischen Imports. 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 Muster new URL('...', import.meta.url) 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 alle dynamischen Anfragen im Gegensatz zu Importanweisungen relativ zum Dokument selbst und nicht zur 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 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. Stattdessen werden URLs relativ zum Dokument aufgelöst. 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:

WebAssembly

Wenn Sie mit WebAssembly arbeiten, laden Sie das Wasm-Modul in der Regel nicht manuell, sondern importieren stattdessen den von der Toolchain generierten JavaScript-Code. 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

Bei Verwendung dieser Option wird in der Ausgabe das new URL(..., import.meta.url)-Muster 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 Webworker auf die gleiche Weise eingeschlossen und kann sowohl von Bundlern als auch von Browsern gefunden werden.

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 generiert, das auf dem Vorschlag zur ESM-Integration von WebAssembly basiert. Zum Zeitpunkt der Erstellung dieses Artikels 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 spezieller import.meta.resolve(...)-Aufruf ist eine potenzielle zukünftige 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 ein stärkeres Signal für Bundler, da es sich um eine statische Syntax handelt, die nicht von Laufzeit-APIs wie URL abhängt.

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 sollte.

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 Erläuterung der Funktion 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.