Utiliser des paquets npm directement depuis le navigateur
Utilisez des packages npm en HTML simple avec import maps et des URLs CDN. Choisissez ESM ou CommonJS, figez les versions et évitez le build.
Vous pouvez utiliser un paquet npm dans une simple page HTML, sans bundler, sans node_modules et sans fichier de configuration, en déclarant une import map qui fait pointer un specifier nu vers une URL de CDN servant ce paquet sous forme de module ES.
Une page, une bibliothèque, une interaction : cela ne justifie souvent pas un projet Vite, avec son serveur de développement, son répertoire de sortie de build et toute sa logistique de déploiement. Ce qui coince, ce n’est presque jamais la syntaxe de l’import map : les paquets npm sont distribués selon trois formats de modules différents, et seuls deux d’entre eux fonctionnent réellement dans un navigateur. Cet article explique comment identifier le format auquel vous avez affaire, les deux façons de le charger depuis un CDN, et pourquoi une URL non épinglée constitue un bug de correction plutôt qu’une simple préférence stylistique.
Points clés à retenir
- Une import map est un bloc JSON placé à l’intérieur d’une balise
<script type="importmap">qui indique au navigateur vers quelle URL résoudre un specifier nu tel quecanvas-confetti— exactement le travail qu’effectue un bundler au moment du build, mais déplacé dans la page. - Une import map ne peut pas sauver un paquet uniquement disponible en CommonJS, car une map modifie la façon dont un specifier est résolu, pas le format dans lequel le fichier est écrit.
- MDN classe les import maps comme Baseline « largement disponible », prises en charge par les navigateurs depuis mars 2023.
- Épinglez une version exacte dans chaque URL de CDN de la map, sinon le code exécuté par votre page peut changer sans déploiement et sans commit.
- Se passer de l’étape de build signifie renoncer au tree shaking : vous livrez tout ce que contient le paquet, et non uniquement les parties que vous utilisez.
Quand faut-il se passer de l’étape de build ?
Passez-vous de l’étape de build lorsque le coût de sa maintenance dépasse la durée de vie de ce qu’elle produit. Cela concerne une démo façon CodePen, un widget interactif isolé inséré dans un template WordPress ou une vue Rails, un tableau de bord interne utilisé par deux personnes, et tout prototype dont la durée de vie se compte en jours. Le critère n’est pas la taille mais la propriété du code : si personne ne mettra à jour la chaîne d’outils dans six mois, cette chaîne d’outils est un passif. Tout ce qui est appelé à grandir, à être exposé à du trafic réel ou à être confié à une équipe relève encore du bundler.
Trois types de fichiers, dont deux seulement fonctionnent dans un navigateur
Un paquet npm est distribué selon l’un des trois formats de modules, et seuls deux d’entre eux fonctionnent dans un navigateur : identifiez donc le build livré par le paquet avant d’écrire la moindre import map. Un fichier classique ou UMD fonctionne avec un simple <script src> et affecte une variable globale. Un module ES nécessite type="module" et des instructions import. Un build CommonJS, écrit avec require() et module.exports, ne s’exécute tout simplement pas dans un navigateur.
Le moyen le plus rapide de le vérifier est d’installer le paquet et de le lire :
npm install canvas-confetti
ls node_modules/canvas-confetti/dist
cat node_modules/canvas-confetti/package.json
Examinez deux choses dans cette sortie : les extensions de fichiers présentes dans le paquet et les champs de point d’entrée. La documentation des paquets de Node définit main, exports et type ; module est une convention de l’écosystème que les bundlers et les CDN interprètent, et non un champ spécifié par Node. Certains paquets comportent également un champ jsdelivr ou unpkg désignant un build prêt pour le navigateur. Par exemple, canvas-confetti@1.9.4 déclare "main": "src/confetti.js", "module": "dist/confetti.module.mjs" et "jsdelivr": "dist/confetti.browser.js" dans son package.json, ce qui vous indique qu’il existe à la fois un build navigateur et un build ES module.
| Format | Comment le reconnaître | Ce dont le navigateur a besoin | Sans étape de build |
|---|---|---|---|
| Classique / UMD | .umd.js, un dist/*.browser.js, ou du code source affectant window | Rien de particulier | <script src>, puis utilisation de la variable globale |
| Module ES | .mjs, import/export dans le code source, "type": "module" | type="module" | Import map plus un script de type module |
| CommonJS | .cjs, require(), module.exports, "type": "commonjs" | Une conversion préalable | Un CDN qui transpile vers ESM, ou une étape de build |
C’est sur cette dernière ligne que la plupart des tentatives échouent silencieusement. Une import map ne peut pas sauver un paquet uniquement disponible en CommonJS, car une map modifie la façon dont un specifier est résolu, pas le format dans lequel le fichier est écrit.
L’approche simple : une balise script pointant vers un CDN
Si le paquet fournit un build classique ou UMD, une seule balise script constitue l’intégralité de l’intégration. Le nom de la variable globale est choisi par l’auteur du paquet, pas par vous : consultez donc le README. Le README de canvas-confetti indique que son build CDN place une fonction confetti sur window.
<!doctype html>
<html lang="en">
<body>
<button id="go">Celebrate</button>
<script src="https://cdn.jsdelivr.net/npm/canvas-confetti@1.9.4/dist/confetti.browser.js"></script>
<script>
document.getElementById('go').addEventListener('click', () => confetti());
</script>
</body>
</html>
Si le paquet est distribué en ESM ou en CommonJS, c’est le CDN qui se charge de la conversion. Une requête vers l’endpoint /+esm de jsDelivr renvoie un module ES prêt pour le navigateur, et jsDelivr décrit cette opération comme bien plus qu’un simple changement de syntaxe : le service détermine le bon point d’entrée à partir des champs du paquet lui-même, convertit le CommonJS lorsque c’est nécessaire, intègre les dépendances dans la réponse, puis élague et minifie le résultat. esm.sh fait le même travail selon la grammaire d’URL https://esm.sh/PKG[@SEMVER][/PATH]. L’un comme l’autre vous fournit une URL que vous pouvez placer directement dans une instruction import.
La meilleure approche : une balise script de type importmap
Une import map est un bloc JSON placé à l’intérieur d’une balise <script type="importmap"> qui associe des specifiers nus à des URL, de sorte que votre code de module se lit exactement comme il le ferait dans un projet géré par un bundler.
<!doctype html>
<html lang="en">
<body>
<button id="go">Celebrate</button>
<script type="importmap">
{
"imports": {
"canvas-confetti": "https://esm.sh/canvas-confetti@1.9.4"
}
}
</script>
<script type="module">
import confetti from 'canvas-confetti';
document.getElementById('go').addEventListener('click', () => confetti());
</script>
</body>
</html>
Le bénéfice tient en une ligne. Sans la map, chaque fichier ayant besoin de la bibliothèque répète l’URL du CDN et la version :
import confetti from 'https://esm.sh/canvas-confetti@1.9.4';
Avec la map, la version ne figure qu’à un seul endroit et l’instruction d’import est portable telle quelle vers un projet utilisant un bundler.
Quatre règles comptent en pratique. Premièrement, l’ordre détermine si la map fonctionne tout court : le navigateur doit la lire avant de rencontrer tout script de module qui importe via elle, donc le bloc <script type="importmap"> doit se situer au-dessus de ce code. Deuxièmement, le standard HTML autorise un document à contenir plusieurs maps et spécifie comment elles sont fusionnées, mais la prise en charge par les moteurs n’est pas uniforme : écrivez donc une seule map par document. Troisièmement, les valeurs relatives doivent commencer par /, ./ ou ../. Quatrièmement, une barre oblique finale des deux côtés d’un mapping associe tout un répertoire de paquet plutôt qu’un point d’entrée unique :
<script type="importmap">
{
"imports": {
"canvas-confetti": "https://esm.sh/canvas-confetti@1.9.4",
"canvas-confetti/": "https://esm.sh/canvas-confetti@1.9.4/"
}
}
</script>
<script type="module">
import confetti from 'canvas-confetti';
import { default as raw } from 'canvas-confetti/dist/confetti.module.mjs';
</script>
MDN classe les import maps comme Baseline « largement disponible », présentes dans les navigateurs depuis mars 2023 : un polyfill ne fait donc plus partie d’une configuration normale. Si vous souhaitez malgré tout une vérification à l’exécution, HTMLScriptElement.supports() vous en fournit une, à utiliser sous la forme HTMLScriptElement.supports?.("importmap").
Un piège qui ne s’accompagne d’aucun message d’erreur : les modules ES sont récupérés selon les règles CORS, si bien qu’ouvrir le fichier HTML depuis le disque échoue alors que le fichier identique fonctionne dès qu’un serveur local vous le sert.
Épinglez la version, à chaque fois
Épinglez une version exacte dans chaque URL de CDN de la map. Une URL non épinglée ou basée sur une plage de versions signifie que le code exécuté par votre page peut changer sans déploiement, sans commit et sans rien dans votre dépôt pour expliquer la différence. Le comportement déployé de la page devient alors fonction de l’horloge du CDN plutôt que de votre historique git, ce qui transforme un rapport de bug de routine en exercice d’archéologie : le HTML est inchangé, les logs du serveur sont inchangés, et le JavaScript est différent.
C’est la seule règle qu’il n’y a aucun intérêt à enfreindre. canvas-confetti@1.9.4 est un fait sur lequel vous pouvez raisonner ; canvas-confetti@latest est une promesse tenue par quelqu’un d’autre.
À quoi renoncez-vous ?
Charger des paquets depuis un CDN confère à une origine tierce la capacité d’exécuter du script arbitraire dans le contexte de votre page. Vous pouvez restreindre ce risque avec une CSP et avec l’intégrité des sous-ressources (SRI) : MDN précise que l’objet JSON de l’import map accepte une clé integrity aux côtés de imports et scopes, associant des URL de modules à des empreintes SRI telles que sha384-…. Si vous préférez maîtriser entièrement la chaîne de distribution, servir vos propres ressources constitue une configuration différente, abordée dans le rôle des CDN dans la performance frontend et une comparaison des plateformes CDN.
Trois autres coûts sont inhérents à cette approche. Il n’y a pas de tree shaking : vous livrez donc tout ce que contient le paquet plutôt que les seules parties que vous utilisez, ce qui est un compromis acceptable pour une démo et un mauvais choix pour une application appelée à grandir. Un graphe de dépendances profond résolu à l’exécution signifie que le navigateur ne découvre chaque module qu’après avoir récupéré son parent, d’où l’intervention des CDN : esm.sh regroupe par défaut les sous-modules d’un paquet dans la réponse, n’excluant que ceux partagés par les points d’entrée déclarés dans son champ exports, et ?bundle=false désactive ce comportement. Enfin, le mode de défaillance est silencieux : le document est analysé, la mise en page est complète, et un module n’arrive jamais parce qu’un proxy, une extension ou une règle CSP a bloqué l’origine — c’est précisément la catégorie de bug que le session replay met en évidence plus vite qu’un rapport d’erreur, puisque rien n’a jamais été levé.
Pour tout projet substantiel en production, utilisez un bundler. Cette technique est réservée à ce qui n’en justifie pas un.
Commencez par lire le paquet avant d’écrire une seule ligne de HTML : listez les fichiers, lisez main, module, exports et type, et déterminez à partir de là si vous avez besoin d’une balise script, d’une import map, ou finalement d’une étape de build.
FAQ
Puis-je conserver l'import map dans un fichier JSON séparé plutôt qu'en ligne dans le HTML ?
Non. La spécification interdit purement et simplement à un élément script de type importmap de porter un attribut src, de même que async, nomodule, defer, crossorigin, integrity et referrerpolicy : le JSON doit donc se trouver à l'intérieur du document. Si la map est générée, produisez-la dans la page côté serveur plutôt que d'y faire référence par un lien, et placez-la au-dessus du premier script de module.
Comment charger deux versions différentes du même paquet sur une même page ?
Utilisez la clé scopes. Un scope attache une seconde table de specifiers à un chemin d'URL, de sorte que les scripts chargés sous ce chemin peuvent résoudre un paquet vers une version épinglée donnée tandis que le reste de la page le résout vers une autre. Lorsque deux scopes correspondent, le chemin le plus long est examiné en premier, et la table imports sert de solution de repli. L'alternative plus simple consiste à attribuer à chaque version son propre specifier nu.
Les import maps s'appliquent-elles aux web workers ou à l'attribut src d'une balise script ?
Non. Une map ne réécrit que les specifiers présents dans les instructions import et les appels import() du document lui-même. L'URL de l'attribut src d'une balise script ne passe jamais par elle, pas plus que quoi que ce soit chargé dans un worker ou un worklet. Un import dynamique au sein d'un module du document est bien résolu via la map, mais le script d'entrée d'un worker et ses propres imports nécessitent des URL complètes.
Que se passe-t-il si un specifier nu n'est pas présent dans l'import map ?
La résolution lève une TypeError avant l'exécution du module, et les deux moteurs la formulent différemment. Chrome signale qu'il n'a pas réussi à résoudre le specifier de module, nomme ce specifier, et ajoute que les références relatives doivent commencer par /, ./ ou ../ (chacune de ces trois formes étant citée entre guillemets dans le message réel). Firefox indique : The specifier “canvas-confetti” was a bare specifier, but was not remapped to anything. Relative module specifiers must start with “./”, “../” or “/”. Rien dans votre code applicatif ne lève d'exception : la page s'affiche normalement et seule la fonctionnalité reposant sur ce module est inopérante.