Regroupement de ressources non-JavaScript

Découvrez comment importer et regrouper différents types d'éléments à partir de JavaScript.

Supposons que vous travaillez sur une application Web. Dans ce cas, il est probable que vous deviez gérer non seulement des modules JavaScript, mais aussi toutes sortes d'autres ressources : Web Workers (qui sont également JavaScript, mais ne font pas partie du graphique de module standard), images, feuilles de style, polices, modules WebAssembly, etc.

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 façon logique à des composants réutilisables. Il peut s'agir, par exemple, d'une feuille de style pour un menu déroulant personnalisé lié à sa partie JavaScript, d'images d'icônes liées à un composant de barre d'outils ou d'un module WebAssembly lié à 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 dynamiquement lorsque (ou si) le composant correspondant est chargé.

Graphique illustrant différents types d'éléments importés dans JavaScript

Cependant, la plupart des grands projets comportent des systèmes de développement qui effectuent des optimisations et une réorganisation supplémentaires du contenu (par exemple, regroupement et minimisation). 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 pour déterminer s'il s'agit ou non d'une URL de ressource. Comment faire pour que ces éléments "voir" les éléments dynamiques chargés par des composants JavaScript et les inclure dans le build ?

Importations personnalisées dans les bundles

Une approche courante consiste à réutiliser la syntaxe d'importation statique. Dans certains bundlers, il peut détecter automatiquement le format en fonction de l'extension de fichier, tandis que d'autres autorisent les plug-ins à 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 bundler trouve 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'élément référencé au graphique de compilation, le copie dans la destination finale, effectue les optimisations applicables au type d'élément et renvoie l'URL finale à utiliser pendant 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, elle présente un inconvénient majeur: ce code ne peut pas fonctionner directement dans le navigateur, car celui-ci ne sait pas comment gérer ces schémas ou extensions d'importation personnalisés. Cela peut convenir si vous contrôlez tout le code et que vous utilisez de toute façon un bundler pour le développement, mais il est de plus en plus courant d'utiliser des modules JavaScript directement dans le navigateur, au moins pendant le développement, afin de réduire les frictions. Une personne travaillant sur une petite démo n'a peut-être même pas besoin d'un bundler, même en production.

Format universel pour les navigateurs et les bundlers

Si vous travaillez sur un composant réutilisable, vous souhaitez qu'il fonctionne dans l'un ou l'autre des environnements, qu'il soit utilisé directement dans le navigateur ou prédéfini dans une application plus importante. La plupart des bundlers modernes le permettent en acceptant le modèle suivant dans les modules JavaScript:

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

Ce format peut être détecté de manière statique par les outils, presque comme s'il s'agissait d'une syntaxe spéciale, mais il s'agit 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));

Comment ça marche ? Décomposons cela. Le constructeur new URL(...) utilise une URL relative comme premier argument et la résout avec une URL absolue fournie comme deuxième argument. Dans notre cas, le deuxième argument est import.meta.url, qui donne l'URL du module JavaScript actuel. Le premier argument peut donc être n'importe quel chemin d'accès relatif à celui-ci.

Elle présente des compromis similaires à ceux de l'importation dynamique. Bien qu'il soit possible d'utiliser import(...) avec des expressions arbitraires telles que import(someUrl), les bundlers accordent un traitement spécial à un format avec l'URL statique import('./some-static-url.js') comme moyen de prétraiter une dépendance connue au moment de la compilation, tout en la divisant en un seul fragment chargé dynamiquement.

De même, vous pouvez utiliser new URL(...) avec des expressions arbitraires telles que new URL(relativeUrl, customAbsoluteBase), mais le modèle new URL('...', import.meta.url) est un signal clair permettant aux bundlers de prétraiter et d'inclure une dépendance avec le code JavaScript principal.

URL relatives ambiguës

Vous vous demandez peut-être pourquoi les bundlers 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, 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, il peut être tentant d'utiliser un chemin d'accès relatif tel que fetch('./module.wasm').

Cependant, fetch ne connaît pas l'URL du fichier JavaScript dans lequel il est exécuté. Au lieu de cela, il résout les URL par rapport au document. Par conséquent, fetch('./module.wasm') tente de charger http://example.com/module.wasm au lieu de la http://example.com/src/module.wasm prévue et échoue (ou, pire, charge silencieusement une ressource différente de celle que vous souhaitiez).

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 qu'elle ne soit transmise aux chargeurs.

Remplacez fetch('./module.wasm') par fetch(new URL('./module.wasm', import.meta.url)) pour charger correctement le module WebAssembly attendu et permettre aux bundles de trouver ces chemins d'accès relatifs au moment de la compilation.

Outils compatibles

Packs

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 vous importez 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 de la colle JavaScript sous forme de module ES6 au lieu d'un script standard en utilisant 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 principale chaîne d'outils Rust pour WebAssembly, possède également plusieurs modes de sortie.

Par défaut, elle émet un module JavaScript basé sur la proposition d'intégration ESM de WebAssembly. Au moment de la rédaction, cette proposition est encore au stade expérimental, et la sortie ne fonctionnera que lorsqu'elle sera regroupé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 format new URL(..., import.meta.url) décrit, et le fichier Wasm sera également automatiquement découvert par les bundlers.

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

Dans la version courte, vous ne pouvez pas utiliser d'API de thread arbitraires. Toutefois, si vous utilisez Rayon, vous pouvez le combiner avec 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(...) en arrière-plan. Les workers seront donc également visibles et inclus par les bundlers.

Fonctionnalités à venir

import.meta.resolve

Un appel import.meta.resolve(...) dédié est une amélioration potentielle à l'avenir. Cela permettrait de résoudre plus facilement les spécificateurs par rapport au module actuel, sans paramètres supplémentaires:

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

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

import.meta.resolve est déjà implémenté en tant que test dans Node.js, mais il reste des questions non résolues 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 les modules ECMAScript. Pour l'instant, elles se limitent 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 des bundlers et remplacer les cas d'utilisation actuellement couverts par le modèle new URL, mais les types des assertions d'importation sont ajoutés au cas par cas. Pour le moment, ils ne concernent que le format JSON. Des modules CSS seront bientôt disponibles, mais d'autres types d'éléments nécessiteront toujours une solution plus générique.

Consultez la présentation de la fonctionnalité 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. Les propositions futures pourraient nous permettre d'importer ces éléments avec une syntaxe spécialisée, mais nous n'en sommes pas encore tout à fait là.

D'ici là, le modèle new URL(..., import.meta.url) est la solution la plus prometteuse qui fonctionne déjà dans les navigateurs, divers bundlers et chaînes d'outils WebAssembly.