Regroupement de ressources non-JavaScript

Découvrez comment importer et regrouper différents types de composants à partir de JavaScript.

Supposons que vous travailliez sur une application Web. Dans ce cas, vous devrez probablement gérer non seulement des modules JavaScript, mais aussi toutes sortes d'autres ressources : nœuds de calcul Web (qui sont également JavaScript, mais qui ne font pas partie du graphique de module standard), images, feuilles de style, polices, modules WebAssembly et autres.

Il est possible d'inclure des références à certaines de ces ressources directement dans le code HTML, mais elles sont souvent associées de manière logique à des composants réutilisables. Par exemple, une feuille de style pour une liste déroulante personnalisée associée à sa partie JavaScript, des images d'icônes associées à un composant de barre d'outils ou un module WebAssembly associé à sa colle JavaScript. Dans ce cas, il est plus pratique de référencer les ressources directement à partir de leurs modules JavaScript et de les charger de manière dynamique lorsque (ou si) le composant correspondant est chargé.

Graphique représentant la visualisation de différents types d'éléments importés dans JavaScript.

Cependant, la plupart des grands projets disposent de systèmes de compilation qui effectuent des optimisations et une réorganisation supplémentaires du contenu (regroupement et minimisation, par exemple). Ils ne peuvent pas exécuter le code ni prédire le résultat de l'exécution, ni parcourir tous les littéraux de chaîne possibles en JavaScript et deviner s'il s'agit ou non d'une URL de ressource. Comment pouvez-vous les faire "voir" ces éléments dynamiques chargés par les composants JavaScript et les inclure dans le build ?

Importations personnalisées dans les bundlers

Une approche courante consiste à réutiliser la syntaxe d'importation statique. Dans certains outils de regroupement, le format peut être détecté automatiquement à l'aide de l'extension de fichier, tandis que d'autres permettent aux plug-ins d'utiliser un schéma d'URL personnalisé, comme dans l'exemple suivant:

// 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);

Lorsqu'un plug-in de bundler détecte une importation avec une extension qu'il reconnaît ou un schéma personnalisé explicite (asset-url: et js-url: dans l'exemple ci-dessus), il ajoute l'asset référencé au graphique de compilation, le copie à la destination finale, effectue les optimisations applicables au type de l'asset et renvoie l'URL finale à utiliser au moment de l'exécution.

Avantages de cette approche: la réutilisation de la syntaxe d'importation JavaScript garantit que toutes les URL sont statiques et relatives au fichier actuel, ce qui facilite la localisation de ces dépendances pour le système de compilation.

Cependant, ce code présente un inconvénient majeur: il ne peut pas fonctionner directement dans le navigateur, car celui-ci ne sait pas gérer ces schémas ou extensions d'importation personnalisés. Ce n'est pas un problème si vous contrôlez tout le code et utilisez tout de même un bundler pour le développement. Toutefois, pour simplifier les choses, il est de plus en plus courant d'utiliser des modules JavaScript directement dans le navigateur, au moins pendant le développement. Un développeur qui travaille sur une petite démonstration n'a peut-être même pas besoin d'un bundler, même en production.

Modèle universel pour les navigateurs et les outils de regroupement

Si vous travaillez sur un composant réutilisable, vous souhaitez qu'il fonctionne dans les deux environnements, qu'il soit utilisé directement dans le navigateur ou précompilé dans le cadre d'une application plus grande. La plupart des outils de compilation modernes le permettent en acceptant le modèle suivant dans les modules JavaScript:

new URL('./relative-path', import.meta.url)

Ce modèle peut être détecté de manière statique par des outils, presque comme s'il s'agissait d'une syntaxe spéciale. Il s'agit pourtant d'une expression JavaScript valide qui fonctionne également directement dans le navigateur.

Lorsque vous utilisez ce modèle, l'exemple ci-dessus peut être réécrit comme suit:

// 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));

Fonctionnement Décomposons cela. Le constructeur new URL(...) utilise une URL relative comme premier argument et la résout par rapport à une URL absolue fournie en tant que deuxième argument. Dans notre cas, le deuxième argument est import.meta.url, qui fournit l'URL du module JavaScript actuel. Le premier argument peut donc être n'importe quel chemin relatif à celui-ci.

Il présente des compromis similaires à ceux de l'importation dynamique. Bien qu'il soit possible d'utiliser import(...) avec des expressions arbitraires comme import(someUrl), les bundlers appliquent un traitement spécial à un modèle avec une URL statique import('./some-static-url.js') afin de prétraiter une dépendance connue au moment de la compilation, tout en la diviser en un fragment chargé de manière dynamique.

De même, vous pouvez utiliser new URL(...) avec des expressions arbitraires comme new URL(relativeUrl, customAbsoluteBase), mais le modèle new URL('...', import.meta.url) indique clairement aux bundlers qu'ils doivent prétraiter et inclure une dépendance en plus du code JavaScript principal.

URL relatives ambiguës

Vous vous demandez peut-être pourquoi les bundles ne peuvent pas détecter d'autres modèles courants, par exemple fetch('./module.wasm') sans les wrappers new URL.

En effet, contrairement aux instructions d'importation, toutes les requêtes dynamiques sont résolues par rapport au document lui-même, et non au fichier JavaScript actuel. Supposons que votre structure soit la suivante:

  • index.html:
    html <script src="src/main.js" type="module"></script>
  • src/
    • main.js
    • module.wasm

Si vous souhaitez charger module.wasm à partir de main.js, vous pourriez être tenté d'utiliser un chemin d'accès relatif tel que fetch('./module.wasm').

Toutefois, fetch ne connaît pas l'URL du fichier JavaScript dans lequel il est exécuté. À la place, il résout les URL par rapport au document. Par conséquent, fetch('./module.wasm') tenterait de charger http://example.com/module.wasm au lieu de http://example.com/src/module.wasm et échouerait (ou, pire, chargerait silencieusement une autre ressource que celle prévue).

En encapsulant l'URL relative dans new URL('...', import.meta.url), vous pouvez éviter ce problème et vous assurer que toute URL fournie est résolue par rapport à l'URL du module JavaScript actuel (import.meta.url) avant d'être transmise à des chargeurs.

Remplacez fetch('./module.wasm') par fetch(new URL('./module.wasm', import.meta.url)). Le module WebAssembly attendu sera alors chargé, et les outils de compilation pourront également trouver ces chemins relatifs au moment de la compilation.

Outils compatibles

Bundlers

Les bundlers suivants sont déjà compatibles avec le schéma new URL:

WebAssembly

Lorsque vous travaillez avec WebAssembly, vous ne chargez généralement pas le module Wasm manuellement, mais importez plutôt la colle JavaScript émise par la chaîne d'outils. Les chaînes d'outils suivantes peuvent émettre le modèle new URL(...) décrit en arrière-plan.

C/C++ via Emscripten

Lorsque vous utilisez Emscripten, vous pouvez lui demander d'émettre une colle JavaScript en tant que module ES6 au lieu d'un script standard via l'une des options suivantes:

$ 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

Lorsque vous utilisez cette option, la sortie utilise le modèle new URL(..., import.meta.url) en arrière-plan, afin que les bundlers puissent trouver automatiquement le fichier Wasm associé.

Vous pouvez également utiliser cette option avec les threads WebAssembly en ajoutant un indicateur -pthread:

$ 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

Dans ce cas, le Web worker généré sera inclus de la même manière et sera également visible par les bundlers et les navigateurs.

Rust via wasm-pack / wasm-bindgen

wasm-pack, la chaîne d'outils Rust principale pour WebAssembly, comporte également plusieurs modes de sortie.

Par défaut, il émet un module JavaScript qui s'appuie sur la proposition d'intégration de l'ESM WebAssembly. Au moment de la rédaction de cet article, cette proposition est encore expérimentale, et la sortie ne fonctionnera que lorsqu'elle sera groupée avec Webpack.

À la place, vous pouvez demander à Wasm-pack d'émettre un module ES6 compatible avec le navigateur via --target web:

$ wasm-pack build --target web

La sortie utilisera le modèle new URL(..., import.meta.url) décrit, et le fichier Wasm sera également automatiquement détecté par les bundlers.

Si vous souhaitez utiliser des threads WebAssembly avec Rust, la situation est un peu plus complexe. Pour en savoir plus, consultez la section correspondante du guide.

En résumé, vous ne pouvez pas utiliser d'API de thread arbitraires. Toutefois, si vous utilisez Rayon, vous pouvez le combiner à l'adaptateur wasm-bindgen-rayon afin qu'il puisse générer des workers sur le Web. La colle JavaScript utilisée par wasm-bindgen-rayon inclut également le modèle new URL(...) sous le capot. Les workers seront donc détectables et inclus par les bundlers.

Fonctionnalités futures

import.meta.resolve

L'appel import.meta.resolve(...) dédié peut faire l'objet d'améliorations potentielles. Cela permettrait de résoudre les spécificateurs par rapport au module actuel de manière plus simple, sans paramètres supplémentaires:

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

Il s'intégrerait également mieux aux cartes d'importation et aux résolveurs personnalisés, car il passerait par le même système de résolution de module que import. Il s'agit également d'un signal plus fort pour les bundlers, car il s'agit d'une syntaxe statique qui ne dépend pas des API d'exécution telles que URL.

import.meta.resolve est déjà implémenté à titre expérimental dans Node.js, mais certaines questions restent sans réponse sur son fonctionnement sur le Web.

Assertions d'importation

Les assertions d'importation sont une nouvelle fonctionnalité qui permet d'importer des types autres que des modules ECMAScript. Pour le moment, elles sont limitées au format JSON:

foo.json:

{ "answer": 42 }

main.mjs:

import json from './foo.json' assert { type: 'json' };
console.log(json.answer); // 42

Ils peuvent également être utilisés par les bundlers et remplacer les cas d'utilisation actuellement couverts par le modèle new URL. Toutefois, les types dans les assertions d'importation sont ajoutés au cas par cas. Pour le moment, ils ne couvrent que le format JSON. Des modules CSS seront bientôt disponibles, mais d'autres types d'assets nécessiteront toujours une solution plus générique.

Consultez la présentation des fonctionnalités de v8.dev pour en savoir plus.

Conclusion

Comme vous pouvez le voir, il existe plusieurs façons d'inclure des ressources non JavaScript sur le Web, mais elles présentent plusieurs inconvénients et ne fonctionnent pas avec différentes chaînes d'outils. Nous pourrions proposer à l'avenir d'importer ces éléments avec une syntaxe spécialisée, mais nous n'en sommes pas encore tout à fait au point.

En attendant, le modèle new URL(..., import.meta.url) est la solution la plus prometteuse, qui fonctionne déjà dans les navigateurs, les différents outils de compilation et les chaînes d'outils WebAssembly.