Nicht-JavaScript-Ressourcen bündeln

Hier erfahren Sie, wie Sie verschiedene Arten von Assets aus JavaScript importieren und bündeln.

Angenommen, Sie arbeiten an einer Web-App. In diesem Fall müssen Sie sich wahrscheinlich nicht nur mit JavaScript-Modulen, sondern auch mit vielen anderen Ressourcen zu tun haben, wie Web Workers (auch JavaScript, aber nicht Teil des regulären Moduldiagramms), Bilder, Stylesheets, Schriftarten, WebAssembly-Module und andere.

Es ist möglich, Verweise auf einige dieser Ressourcen direkt in den HTML-Code einzufügen, aber oft sind sie logisch an wiederverwendbare Komponenten gekoppelt. Zum Beispiel ein Stylesheet für ein benutzerdefiniertes Dropdown-Menü, das mit seinem JavaScript-Teil verbunden ist, Symbolbilder, die an eine Toolbar-Komponente gebunden sind, oder ein WebAssembly-Modul, das mit seinem JavaScript-Glue verbunden ist. In diesen Fällen ist es besser, direkt aus den JavaScript-Modulen auf die Ressourcen zu verweisen und sie dynamisch zu laden, wenn (oder falls) die entsprechende Komponente geladen wird.

Grafik mit verschiedenen Arten von Assets, die in JavaScript importiert wurden

Die meisten großen Projekte verfügen jedoch über Build-Systeme, die zusätzliche Optimierungen und Reorganisation von Inhalten durchführen, z. B. Bündelung und Reduzierung. Sie können den Code nicht ausführen und das Ergebnis der Ausführung vorhersagen. Sie können auch nicht jedes mögliche String-Literal in JavaScript durchlaufen und vermuten, ob es sich um eine Ressourcen-URL handelt oder nicht. Wie können Sie also dafür sorgen, dass sie die von JavaScript-Komponenten geladenen dynamischen Assets „sehen“ und sie in den Build einbinden?

Benutzerdefinierte Importe in Bundlern

Ein gängiger Ansatz ist die Wiederverwendung der statischen Importsyntax. In einigen Bundlern wird das Format möglicherweise automatisch anhand der Dateiendung erkannt, während andere Plug-ins die Verwendung eines benutzerdefinierten URL-Schemas wie im folgenden Beispiel ermöglichen:

// 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 expliziten benutzerdefinierten Schema (im obigen Beispiel asset-url: und js-url:) findet, wird das referenzierte Asset der Build-Grafik hinzugefügt, an das endgültige Ziel kopiert, entsprechende Optimierungen für den Asset-Typ durchgeführt und die finale URL zurückgegeben, die während der Laufzeit verwendet werden soll.

Die Vorteile dieses Ansatzes: Die Wiederverwendung der JavaScript-Importsyntax sorgt dafür, dass alle URLs statisch sind und sich auf die aktuelle Datei beziehen. Dadurch ist das Auffinden solcher Abhängigkeiten für das Build-System einfach.

Dies hat jedoch einen erheblichen Nachteil: Dieser Code funktioniert nicht direkt im Browser, da der Browser nicht weiß, wie er mit diesen benutzerdefinierten Importschemata oder Erweiterungen umgehen soll. Dies kann in Ordnung sein, wenn Sie den gesamten Code steuern und trotzdem für die Entwicklung einen Bundler verwenden. Es wird jedoch immer üblicher, JavaScript-Module direkt im Browser zu verwenden, zumindest während der Entwicklung, um den Aufwand zu verringern. Jemand, der an einer kleinen Demo arbeitet, benötigt möglicherweise gar keinen Bundler, nicht einmal in der Produktion.

Universelles Muster für Browser und Bundler

Wenn Sie an einer wiederverwendbaren Komponente arbeiten, sollte diese in beiden Umgebungen funktionieren, unabhängig davon, ob sie direkt im Browser verwendet oder als Teil einer größeren App vorgefertigt 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 von Tools statisch erkannt werden, fast wie bei einer speziellen Syntax. Es ist jedoch ein gültiger JavaScript-Ausdruck, der auch direkt im Browser funktioniert.

Bei Verwendung dieses Musters kann das obige Beispiel folgendermaßen 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? Teilen wir es auf. Der new URL(...)-Konstruktor nimmt eine relative URL als erstes Argument an und löst sie gegen eine absolute URL auf, die als zweites Argument bereitgestellt wird. In unserem Fall ist das zweite Argument import.meta.url, das die URL des aktuellen JavaScript-Moduls angibt. Das erste Argument kann also ein beliebiger Pfad relativ dazu sein.

Er hat ähnliche Vor- und Nachteile wie beim dynamischen Import. Obwohl es möglich ist, import(...) mit beliebigen Ausdrücken wie import(someUrl) zu verwenden, geben die Bundler einem Muster mit der statischen URL import('./some-static-url.js') eine besondere Behandlung an, um eine bei der Kompilierung bekannte Abhängigkeit vorzuverarbeiten und in einen eigenen Block aufzuteilen, der dynamisch geladen wird.

In ähnlicher Weise können Sie new URL(...) mit beliebigen Ausdrücken wie new URL(relativeUrl, customAbsoluteBase) verwenden. Allerdings ist das new URL('...', import.meta.url)-Muster für Bundler ein klares Signal, dass Bundler vorverarbeiten und eine Abhängigkeit neben dem Haupt-JavaScript einfügen müssen.

Nicht eindeutige relative URLs

Sie fragen sich vielleicht, warum Bundler keine anderen gängigen Muster erkennen 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 in Bezug auf das Dokument selbst und nicht auf die aktuelle JavaScript-Datei aufgelöst werden. Angenommen, Ihre Struktur ist wie folgt:

  • 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 möglicherweise 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 die URLs relativ zum Dokument aufgelöst. Infolgedessen würde fetch('./module.wasm') versuchen, http://example.com/module.wasm anstelle der vorgesehenen http://example.com/src/module.wasm zu laden, und schlägt fehl (oder schlimmer noch, wenn im Hintergrund eine andere Ressource als beabsichtigt geladen wird).

Durch das Einfügen der relativen URL in new URL('...', import.meta.url) 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 Loader weitergegeben wird.

Ersetzen Sie fetch('./module.wasm') durch fetch(new URL('./module.wasm', import.meta.url)). Dadurch wird das erwartete WebAssembly-Modul geladen und Bundler können diese relativen Pfade auch während der Build-Zeit ermitteln.

Unterstützung von Tools

Bundler

Die folgenden Bundler unterstützen bereits das Schema new URL:

WebAssembly

Wenn Sie mit WebAssembly arbeiten, 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 im Hintergrund für Sie ausgeben.

C/C++ über Emscripten

Wenn Sie Emscripten verwenden, können Sie es über eine der folgenden Optionen auffordern, JavaScript-Glue als ES6-Modul anstelle eines regulären Skripts auszugeben:

$ 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 für die Ausgabe das Muster new URL(..., import.meta.url) im Hintergrund verwendet, sodass 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 eingebunden und kann auch von Bundlern und Browsern gleichermaßen gefunden werden.

Rost mit Wasm-Pack / Wasm-bindgen

wasm-pack, die primäre Rust-Toolchain für WebAssembly, hat ebenfalls mehrere Ausgabemodi.

Standardmäßig wird ein JavaScript-Modul ausgegeben, das auf dem WebAssembly-ESM-Integrationsvorschlag basiert. Derzeit befindet sich dieser Vorschlag noch in der Testphase und die Ausgabe funktioniert nur in Verbindung mit Webpack.

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 Sache etwas komplizierter. Weitere Informationen finden Sie im entsprechenden Abschnitt im Leitfaden.

Kurzversion ist, dass Sie keine beliebigen Thread-APIs verwenden können. Wenn Sie jedoch Rayon verwenden, können Sie ihn mit dem Wasm-bindgen-rayon-Adapter kombinieren, um Worker im Web zu erzeugen. Der von Wasm-Bindgen-Rayon verwendete JavaScript-Klebstoff enthält auch das new URL(...)-Muster unter der Haube, sodass die Worker auch von Bundlern gefunden und einbezogen werden können.

Zukünftige Funktionen

import.meta.resolve

Ein spezieller import.meta.resolve(...)-Anruf ist eine mögliche zukünftige Verbesserung. Sie könnte Spezifizierer relativ zum aktuellen Modul einfacher und ohne zusätzliche Parameter auflösen:

new URL('...', import.meta.url)
await import.meta.resolve('...')

Außerdem lässt sie sich besser in Importkarten und benutzerdefinierte Resolver einbinden, da sie dasselbe Modulauflösungssystem wie import durchlaufen. Dies 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 wurde bereits als Test in Node.js implementiert. Es gibt jedoch noch einige ungelöste Fragen zur Funktionsweise im Web.

Assertions importieren

Import-Assertions sind eine neue Funktion, mit der andere Typen als ECMAScript-Module importiert werden können. Im Moment 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 sind. Typen in ImportAssertions werden jedoch auf Fallbasis hinzugefügt. Im Moment decken sie nur JSON ab, CSS-Module folgen bald, aber für andere Arten von Assets ist noch eine allgemeinere Lösung erforderlich.

Weitere Informationen zu dieser Funktion finden Sie in der Erläuterung der Funktionen von v8.dev.

Fazit

Wie Sie sehen, gibt es verschiedene Möglichkeiten, Nicht-JavaScript-Ressourcen im Web einzubinden, diese haben jedoch auch verschiedene Nachteile und funktionieren nicht für verschiedene Toolchains. In zukünftigen Vorschlägen könnten wir solche Assets mit einer speziellen Syntax importieren, aber wir sind noch nicht ganz so weit.

Bis dahin ist das Muster new URL(..., import.meta.url) die vielversprechendste Lösung, die bereits in Browsern, verschiedenen Bundlern und WebAssembly-Toolchains funktioniert.