Un guide complet des cartes d'importation JavaScript, axé sur la fonctionnalité puissante des « portées », l'héritage de la portée et la hiérarchie de résolution des modules pour le développement Web moderne.
Déverrouiller une nouvelle ère de développement Web : une exploration approfondie de l'héritage de la portée des cartes d'importation JavaScript
Le parcours des modules JavaScript a été long et sinueux. Du chaos de l'espace de noms global du début du Web aux modèles sophistiqués comme CommonJS pour Node.js et AMD pour les navigateurs, les développeurs ont continuellement cherché de meilleures façons d'organiser et de partager du code. L'arrivée des modules ES natifs (ESM) a marqué un tournant monumental, standardisant un système de modules directement dans le langage JavaScript et les navigateurs.
Cependant, cette nouvelle norme s'est accompagnée d'un obstacle important pour le développement basé sur le navigateur. Les instructions d'importation simples et élégantes auxquelles nous nous sommes habitués dans Node.js, comme import _ from 'lodash';
, lèveraient une erreur dans le navigateur. En effet, les navigateurs, contrairement à Node.js avec son algorithme `node_modules`, n'ont aucun mécanisme natif pour résoudre ces « spécificateurs de modules nus » en une URL valide.
Pendant des années, la solution a été une étape de construction obligatoire. Des outils comme Webpack, Rollup et Parcel regroupaient notre code, transformant ces spécificateurs nus en chemins que le navigateur pouvait comprendre. Bien que puissants, ces outils ont ajouté de la complexité, des frais généraux de configuration et des boucles de rétroaction plus lentes au processus de développement. Et s'il existait un moyen natif, sans outil de construction, de résoudre ce problème ? Entrez les cartes d'importation JavaScript.
Les cartes d'importation sont une norme W3C qui fournit un mécanisme natif pour contrôler le comportement des importations JavaScript. Elles agissent comme une table de correspondance, indiquant au navigateur exactement comment résoudre les spécificateurs de modules en URL concrètes. Mais leur puissance s'étend bien au-delà du simple alias. Le véritable atout réside dans une fonctionnalité moins connue mais incroyablement puissante : les `portées`. Les portées permettent une résolution de module contextuelle, permettant à différentes parties de votre application d'importer le même spécificateur mais de le résoudre en différents modules. Cela ouvre de nouvelles possibilités architecturales pour les micro-frontends, les tests A/B et la gestion complexe des dépendances sans une seule ligne de configuration de bundler.
Ce guide complet vous emmènera dans une exploration approfondie du monde des cartes d'importation, en mettant un accent particulier sur la démystification de la hiérarchie de résolution des modules régie par les `portées`. Nous explorerons le fonctionnement de l'héritage de la portée (ou, plus précisément, du mécanisme de repli), disséquerons l'algorithme de résolution et découvrirons des modèles pratiques pour révolutionner votre flux de travail de développement Web moderne.
Que sont les cartes d'importation JavaScript ? Un aperçu fondamental
À la base, une carte d'importation est un objet JSON qui fournit un mappage entre le nom d'un module qu'un développeur souhaite importer et l'URL du fichier de module correspondant. Elle vous permet d'utiliser des spécificateurs de modules nus et propres dans votre code, comme dans un environnement Node.js, et permet au navigateur de gérer la résolution.
La syntaxe de base
Vous déclarez une carte d'importation à l'aide d'une balise <script>
avec l'attribut type="importmap"
. Cette balise doit être placée dans le document HTML avant toute balise <script type="module">
qui utilise les importations mappées.
Voici un exemple simple :
<!DOCTYPE html>
<html>
<head>
<!-- The Import Map -->
<script type="importmap">
{
"imports": {
"moment": "https://cdn.skypack.dev/moment",
"lodash": "/js/vendor/lodash-4.17.21.min.js",
"app/": "/js/app/"
}
}
</script>
<!-- Your Application Code -->
<script type="module" src="/js/main.js"></script>
</head>
<body>
<h1>Welcome to Import Maps!</h1>
</body>
</html>
À l'intérieur de notre fichier /js/main.js
, nous pouvons maintenant écrire du code comme ceci :
// This works because "moment" is mapped in the import map.
import moment from 'moment';
// This works because "lodash" is mapped.
import { debounce } from 'lodash';
// This is a package-like import for your own code.
// It resolves to /js/app/utils.js because of the "app/" mapping.
import { helper } from 'app/utils.js';
console.log('Today is:', moment().format('MMMM Do YYYY'));
Décomposons l'objet `imports` :
"moment": "https://cdn.skypack.dev/moment"
 : Il s'agit d'un mappage direct. Chaque fois que le navigateur voitimport ... from 'moment'
, il récupère le module à partir de l'URL CDN spécifiée."lodash": "/js/vendor/lodash-4.17.21.min.js"
 : Cela mappe le spécificateur `lodash` à un fichier hébergé localement."app/": "/js/app/"
 : Il s'agit d'un mappage basé sur le chemin. Notez la barre oblique de fin sur la clé et la valeur. Cela indique au navigateur que tout spécificateur d'importation commençant par `app/` doit être résolu par rapport à `/js/app/`. Par exemple, `import ... from 'app/auth/user.js'` serait résolu en `/js/app/auth/user.js`. Ceci est incroyablement utile pour structurer votre propre code d'application sans utiliser de chemins relatifs désordonnés comme `../../`.
Les principaux avantages
Même avec cette simple utilisation, les avantages sont clairs :
- Développement sans construction : Vous pouvez écrire du JavaScript moderne et modulaire et l'exécuter directement dans le navigateur sans bundler. Cela conduit à des actualisations plus rapides et à une configuration de développement plus simple.
- Dépendances découplées : Votre code d'application fait référence à des spécificateurs abstraits (`'moment'`) au lieu d'URL codées en dur. Il est donc trivial d'échanger des versions, des fournisseurs CDN ou de passer d'un fichier local à un CDN en modifiant uniquement le JSON de la carte d'importation.
- Mise en cache améliorée : Étant donné que les modules sont chargés en tant que fichiers individuels, le navigateur peut les mettre en cache indépendamment. Une modification apportée à un petit module ne nécessite pas de retélécharger un bundle massif.
Au-delà des bases : Présentation des `portées` pour un contrôle granulaire
La clé `imports` de niveau supérieur fournit un mappage global pour l'ensemble de votre application. Mais que se passe-t-il lorsque votre application gagne en complexité ? Prenons l'exemple d'une grande application Web qui intègre un widget de chat tiers. L'application principale utilise la version 5 d'une bibliothèque de graphiques, mais le widget de chat hérité n'est compatible qu'avec la version 4.
Sans `portées`, vous seriez confronté à un choix difficile : essayer de refactoriser le widget, trouver un widget différent ou accepter de ne pas pouvoir utiliser la nouvelle bibliothèque de graphiques. C'est précisément le problème que les `portées` ont été conçues pour résoudre.
La clé `scopes` dans une carte d'importation vous permet de définir différents mappages pour le même spécificateur en fonction de l'endroit d'où l'importation est effectuée. Elle fournit une résolution de module contextuelle ou à portée définie.
La structure des `portées`
La valeur `scopes` est un objet où chaque clé est un préfixe d'URL, représentant un « chemin de portée ». La valeur de chaque chemin de portée est un objet de type `imports` qui définit les mappages qui s'appliquent spécifiquement dans cette portée.
Résolvons notre problème de bibliothèque de graphiques avec un exemple :
<script type="importmap">
{
"imports": {
"charting-lib": "/libs/charting-lib/v5/main.js",
"api-client": "/js/api/v2/client.js"
},
"scopes": {
"/widgets/chat/": {
"charting-lib": "/libs/charting-lib/v4/legacy.js"
}
}
}
</script>
<script type="module" src="/js/app.js"></script>
<script type="module" src="/widgets/chat/init.js"></script>
Voici comment le navigateur interprète cela :
- Un script situé à `/js/app.js` veut importer `charting-lib`. Le navigateur vérifie si le chemin du script (`/js/app.js`) correspond à l'un des chemins de portée. Il ne correspond pas à `/widgets/chat/`. Par conséquent, le navigateur utilise le mappage `imports` de niveau supérieur, et `charting-lib` est résolu en `/libs/charting-lib/v5/main.js`.
- Un script situé à `/widgets/chat/init.js` veut également importer `charting-lib`. Le navigateur constate que le chemin de ce script (`/widgets/chat/init.js`) relève de la portée `/widgets/chat/`. Il recherche à l'intérieur de cette portée un mappage `charting-lib` et en trouve un. Ainsi, pour ce script et tous les modules qu'il importe à partir de ce chemin, `charting-lib` est résolu en `/libs/charting-lib/v4/legacy.js`.
Avec les `portées`, nous avons réussi à permettre à deux parties de notre application d'utiliser différentes versions de la même dépendance, coexistant pacifiquement sans conflits. Il s'agit d'un niveau de contrôle qui n'était auparavant réalisable qu'avec des configurations de bundler complexes ou une isolation basée sur iframe.
Le concept essentiel : comprendre l'héritage de la portée et la hiérarchie de résolution des modules
Nous arrivons maintenant au cœur du problème. Comment le navigateur décide-t-il quelle portée utiliser lorsque plusieurs portées pourraient potentiellement correspondre au chemin d'un fichier ? Et qu'advient-il des mappages dans les `imports` de niveau supérieur ? Ceci est régi par une hiérarchie claire et prévisible.
La règle d'or : la portée la plus spécifique gagne
Le principe fondamental de la résolution de la portée est la spécificité. Lorsqu'un module à une certaine URL demande un autre module, le navigateur examine toutes les clés de l'objet `scopes`. Il trouve la clé la plus longue qui est un préfixe de l'URL du module demandeur. Cette portée correspondante « la plus spécifique » est la seule qui sera utilisée pour résoudre l'importation. Toutes les autres portées sont ignorées pour cette résolution particulière.
Illustrons cela avec une structure de fichiers et une carte d'importation plus complexes.
Structure de fichiers :
- `/index.html` (contient la carte d'importation)
- `/js/main.js`
- `/js/feature-a/index.js`
- `/js/feature-a/core/logic.js`
Carte d'importation dans `index.html`Â :
{
"imports": {
"api": "/js/api/v1/api.js",
"ui-kit": "/js/ui/v2/kit.js"
},
"scopes": {
"/js/feature-a/": {
"api": "/js/api/v2-beta/api.js"
},
"/js/feature-a/core/": {
"api": "/js/api/v3-experimental/api.js",
"ui-kit": "/js/ui/v1/legacy-kit.js"
}
}
}
Traçons maintenant la résolution de `import api from 'api';` et `import ui from 'ui-kit';` à partir de différents fichiers :
-
Dans `/js/main.js`Â :
- Le chemin `/js/main.js` ne correspond pas Ă `/js/feature-a/` ou `/js/feature-a/core/`.
- Aucune portée ne correspond. La résolution revient aux `imports` de niveau supérieur.
- `api` est résolu en `/js/api/v1/api.js`.
- `ui-kit` est résolu en `/js/ui/v2/kit.js`.
-
Dans `/js/feature-a/index.js`Â :
- Le chemin `/js/feature-a/index.js` est préfixé par `/js/feature-a/`. Il n'est pas préfixé par `/js/feature-a/core/`.
- La portée correspondante la plus spécifique est `/js/feature-a/`.
- Cette portée contient un mappage pour `api`. Par conséquent, `api` est résolu en `/js/api/v2-beta/api.js`.
- Cette portée ne contient pas de mappage pour `ui-kit`. La résolution de ce spécificateur revient aux `imports` de niveau supérieur. `ui-kit` est résolu en `/js/ui/v2/kit.js`.
-
Dans `/js/feature-a/core/logic.js`Â :
- Le chemin `/js/feature-a/core/logic.js` est préfixé à la fois par `/js/feature-a/` et `/js/feature-a/core/`.
- Étant donné que `/js/feature-a/core/` est plus long et donc plus spécifique, il est choisi comme portée gagnante. La portée `/js/feature-a/` est complètement ignorée pour ce fichier.
- Cette portée contient un mappage pour `api`. `api` est résolu en `/js/api/v3-experimental/api.js`.
- Cette portée contient également un mappage pour `ui-kit`. `ui-kit` est résolu en `/js/ui/v1/legacy-kit.js`.
La vérité sur l'« héritage » : c'est un repli, pas une fusion
Il est essentiel de comprendre un point de confusion courant. Le terme « héritage de la portée » peut être trompeur. Une portée plus spécifique n'hérite pas ou ne fusionne pas avec une portée (parente) moins spécifique. Le processus de résolution est plus simple et plus direct :
- Trouvez la seule portée correspondante la plus spécifique pour l'URL du script d'importation.
- Si cette portée contient un mappage pour le spécificateur demandé, utilisez-le. Le processus se termine ici.
- Si la portée gagnante ne contient pas de mappage pour le spécificateur, le navigateur vérifie immédiatement l'objet `imports` de niveau supérieur pour un mappage. Il n'examine pas d'autres portées moins spécifiques.
- Si un mappage est trouvé dans les `imports` de niveau supérieur, il est utilisé.
- Si aucun mappage n'est trouvé dans la portée gagnante ou dans les `imports` de niveau supérieur, une `TypeError` est levée.
Revenons à notre dernier exemple pour consolider cela. Lors de la résolution de `ui-kit` à partir de `/js/feature-a/index.js`, la portée gagnante était `/js/feature-a/`. Cette portée ne définissait pas `ui-kit`, le navigateur n'a donc pas vérifié la portée `/` (qui n'existe pas en tant que clé) ou tout autre parent. Il est allé directement aux `imports` globaux et a trouvé le mappage là . Il s'agit d'un mécanisme de repli, pas d'un héritage en cascade ou de fusion comme CSS.
Applications pratiques et scénarios avancés
La puissance des cartes d'importation à portée définie brille vraiment dans les applications complexes et réelles. Voici quelques modèles architecturaux qu'elles permettent.
Micro-frontends
Il s'agit sans doute du cas d'utilisation phare pour les portées de carte d'importation. Imaginez un site de commerce électronique où la recherche de produits, le panier d'achat et le paiement sont tous des applications distinctes (micro-frontends) développées par différentes équipes. Ils sont tous intégrés dans une seule page hôte.
- L'équipe de recherche peut utiliser la dernière version de React.
- L'équipe du panier pourrait utiliser une ancienne version stable de React en raison d'une dépendance héritée.
- L'application hôte pourrait utiliser Preact pour sa coque afin d'être légère.
Une carte d'importation peut orchestrer cela de manière transparente :
{
"imports": {
"react": "/libs/preact/v10/preact.js",
"react-dom": "/libs/preact/v10/preact-dom.js",
"shared-state": "/js/state-manager.js"
},
"scopes": {
"/apps/search/": {
"react": "/libs/react/v18/react.js",
"react-dom": "/libs/react/v18/react-dom.js"
},
"/apps/cart/": {
"react": "/libs/react/v17/react.js",
"react-dom": "/libs/react/v17/react-dom.js"
}
}
}
Ici, chaque micro-frontend, identifié par son chemin d'URL, obtient sa propre version isolée de React. Ils peuvent toujours tous importer un module `shared-state` à partir des `imports` de niveau supérieur pour communiquer entre eux. Cela fournit une forte encapsulation tout en permettant une interopérabilité contrôlée, le tout sans configurations de fédération de bundler complexes.
Tests A/B et indicateurs de fonctionnalité
Vous voulez tester une nouvelle version d'un flux de paiement pour un pourcentage de vos utilisateurs ? Vous pouvez servir un fichier `index.html` légèrement différent au groupe de test avec une carte d'importation modifiée.
Carte d'importation du groupe de contrôle :
{
"imports": {
"checkout-flow": "/js/checkout/v1/flow.js"
}
}
Carte d'importation du groupe de test :
{
"imports": {
"checkout-flow": "/js/checkout/v2-beta/flow.js"
}
}
Votre code d'application reste identique : `import start from 'checkout-flow';`. L'acheminement du module à charger est géré entièrement au niveau de la carte d'importation, qui peut être générée dynamiquement sur le serveur en fonction des cookies de l'utilisateur ou d'autres critères.
Gestion des monorépositoires
Dans un grand monorépositoire, vous pourriez avoir de nombreux packages internes qui dépendent les uns des autres. Les portées peuvent aider à gérer ces dépendances proprement. Vous pouvez mapper le nom de chaque package à son code source pendant le développement.
{
"imports": {
"@my-corp/design-system": "/packages/design-system/src/index.js",
"@my-corp/utils": "/packages/utils/src/index.js"
},
"scopes": {
"/packages/design-system/": {
"@my-corp/utils": "/packages/design-system/src/vendor/utils-shim.js"
}
}
}
Dans cet exemple, la plupart des packages obtiennent la principale bibliothèque `utils`. Cependant, le package `design-system`, peut-être pour une raison spécifique, obtient une version shimée ou différente de `utils` définie dans sa propre portée.
Prise en charge du navigateur, outils et considérations de déploiement
Prise en charge du navigateur
À la fin de 2023, la prise en charge native des cartes d'importation est disponible dans tous les principaux navigateurs modernes, notamment Chrome, Edge, Safari et Firefox. Cela signifie que vous pouvez commencer à les utiliser en production pour une grande majorité de votre base d'utilisateurs sans aucun polyfill.
Retours en arrière pour les anciens navigateurs
Pour les applications qui doivent prendre en charge les anciens navigateurs qui ne prennent pas en charge nativement les cartes d'importation, la communauté dispose d'une solution robuste : le polyfill `es-module-shims.js`. Ce script unique, lorsqu'il est inclus avant votre carte d'importation, rétablit la prise en charge des cartes d'importation et d'autres fonctionnalités de module modernes (comme `import()` dynamique) dans les anciens environnements. Il est léger, testé au combat et l'approche recommandée pour garantir une large compatibilité.
<!-- Polyfill for older browsers -->
<script async src="https://ga.jspm.io/npm:es-module-shims@1.8.2/dist/es-module-shims.js"></script>
<!-- Your import map -->
<script type="importmap">
...
</script>
Cartes dynamiques générées par le serveur
L'un des modèles de déploiement les plus puissants est de ne pas avoir de carte d'importation statique dans votre fichier HTML. Au lieu de cela, votre serveur peut générer dynamiquement le JSON en fonction de la demande. Cela permet :
- Changement d'environnement : Servir des modules non minimisés et mappés à la source dans un environnement de `développement` et des modules minimisés et prêts pour la production en `production`.
- Modules basés sur le rôle de l'utilisateur : Un utilisateur administrateur pourrait obtenir une carte d'importation qui inclut des mappages pour les outils réservés aux administrateurs.
- Localisation : Mapper un module `traductions` à différents fichiers en fonction de l'en-tête `Accept-Language` de l'utilisateur.
Meilleures pratiques et pièges potentiels
Comme pour tout outil puissant, il existe des meilleures pratiques à suivre et des pièges à éviter.
- Gardez-le lisible : Bien que vous puissiez créer des hiérarchies de portée très profondes et complexes, il peut devenir difficile de déboguer. Visez la structure de portée la plus simple qui répond à vos besoins. Commentez votre JSON de carte d'importation s'il devient complexe.
- Utilisez toujours des barres obliques de fin pour les chemins : Lorsque vous mappez un préfixe de chemin (comme un répertoire), assurez-vous que la clé dans la carte d'importation et la valeur de l'URL se terminent par un `/`. Ceci est essentiel pour que l'algorithme de correspondance fonctionne correctement pour tous les fichiers de ce répertoire. L'oubli de ceci est une source courante de bogues.
- Piège : Le piège de la non-héritage : Rappelez-vous, une portée spécifique n'hérite pas d'une portée moins spécifique. Elle revient *uniquement* aux `imports` globaux. Si vous déboguez un problème de résolution, identifiez toujours d'abord la seule portée gagnante.
- Piège : Mise en cache de la carte d'importation : Votre carte d'importation est le point d'entrée pour l'ensemble de votre graphe de modules. Si vous mettez à jour l'URL d'un module dans la carte, vous devez vous assurer que les utilisateurs obtiennent la nouvelle carte. Une stratégie courante consiste à ne pas mettre en cache fortement le fichier `index.html` principal, ou à charger dynamiquement la carte d'importation à partir d'une URL qui contient un hachage de contenu, bien que la première soit plus courante.
- Le débogage est votre ami : Les outils de développement des navigateurs modernes sont excellents pour déboguer les problèmes de module. Dans l'onglet Réseau, vous pouvez voir exactement quelle URL a été demandée pour chaque module. Dans la console, les erreurs de résolution indiqueront clairement quel spécificateur n'a pas pu être résolu à partir de quel script d'importation.
Conclusion : L'avenir du développement Web sans construction
Les cartes d'importation JavaScript, et en particulier leur fonctionnalité `scopes`, représentent un changement de paradigme dans le développement frontend. Elles déplacent un élément important de la logique (la résolution des modules) d'une étape de construction de précompilation directement vers une norme native du navigateur. Il ne s'agit pas seulement de commodité, mais de construire des applications Web plus flexibles, dynamiques et résilientes.
Nous avons vu comment fonctionne la hiérarchie de résolution des modules : le chemin de portée le plus spécifique gagne toujours, et il revient à l'objet `imports` global, pas aux portées parentes. Cette règle simple mais puissante permet la création d'architectures d'application sophistiquées comme les micro-frontends et permet des comportements dynamiques comme les tests A/B avec une facilité surprenante.
Alors que la plateforme Web continue de mûrir, la dépendance à des outils de construction lourds et complexes pour le développement diminue. Les cartes d'importation sont une pierre angulaire de cet avenir « sans construction », offrant un moyen plus simple, plus rapide et plus standardisé de gérer les dépendances. En maîtrisant les concepts de portées et la hiérarchie de résolution, vous n'apprenez pas seulement une nouvelle API de navigateur, vous vous équipez des outils pour construire la prochaine génération d'applications pour le Web mondial.