Un module, c'est un fichier avec sa propre portée
Avant l'arrivée des modules ES, chaque balise <script> déversait ses variables dans l'espace global, et l'ordre de chargement décidait qui voyait quoi. Les modules ES JavaScript règlent le problème : chaque fichier a sa propre portée. Rien ne fuit vers l'extérieur sauf si tu fais un export explicite, et rien n'entre sauf si tu fais un import explicite.
Deux fichiers, l'un qui exporte, l'autre qui importe :
add et multiply vivent dans math.js. Ils ne deviennent visibles dans main.js qu'à cause de l'import. Tout le reste de math.js — fonctions utilitaires, constantes, peu importe — reste inaccessible depuis l'extérieur.
Deux règles en découlent, et autant les intégrer tout de suite :
- Les modules tournent automatiquement en mode strict. Pas besoin de
'use strict'. - Au plus haut niveau,
thisvautundefined, pas l'objet global.
Exports nommés : on exporte au fil de l'eau
C'est la forme la plus courante. Colle export devant n'importe quelle function, class, const ou let et le voilà qui rejoint la surface publique du module :
Les noms entre accolades doivent correspondre exactement aux noms exportés — import { circlearea } échouerait. Si un nom entre en conflit avec quelque chose qui existe déjà chez toi, renomme-le à l'import avec as :
Tu peux aussi regrouper tes exports en bas de fichier plutôt que de les déclarer au fil de l'eau — certains préfèrent cette approche, qui met bien en évidence l'« API publique » du module :
Les deux styles donnent le même résultat. Choisissez-en un et gardez-le sur l'ensemble du projet.
Export par défaut : un seul par module
Un module peut aussi avoir un unique export par défaut. L'export default est idéal pour les fichiers qui ont vraiment une chose principale à exposer — un composant, une classe, un objet de configuration :
Trois points à retenir :
- Pas d'accolades autour de
logcôté import. - Le nom à l'import, c'est toi qui le choisis.
import shout from './logger.js'fonctionnerait exactement pareil. - Un seul
export defaultpar fichier. Si tu en ajoutes un deuxième, le fichier refuse de se parser.
Les exports nommés et l'export default peuvent cohabiter dans un même fichier :
D'abord le default, puis les exports nommés entre accolades. L'ordre est imposé.
Lequel choisir ? Les exports nommés sont plus faciles à refactorer : renommer un symbole dans toute une base de code revient à un simple rechercher-remplacer, puisque chaque import utilise le même nom. Les export default offrent plus de souplesse, mais chaque appelant peut choisir un nom différent, ce qui rend le grep beaucoup moins efficace. La plupart des guides de style modernes privilégient les exports nommés et réservent export default aux modules qui ont vraiment une seule responsabilité.
Importer tout un module, réexporter, effets de bord
Voici quelques autres formes d'import que vous allez croiser.
Pour récupérer tous les exports nommés dans un seul objet espace de noms :
Ré-exporter depuis un autre module sans l'importer dans la portée courante :
Ce schéma est exactement ce que font les points d'entrée de librairies pour regrouper des morceaux éparpillés dans plusieurs fichiers internes en une seule surface publique.
Et pour finir, il existe aussi l'import sans aucune liaison — pratique pour les modules dont le seul rôle est de produire des effets de bord (polyfills, CSS-in-JS, enregistrement de handlers) :
Le fichier s'exécute une seule fois ; rien n'est importé par son nom.
Les imports sont statiques et vivants
Deux caractéristiques d'import qui surprennent parfois.
Statique. Les déclarations import sont résolues avant que la moindre ligne de votre code ne tourne. Impossible donc d'en glisser une dans un if, une fonction ou un try. Le chemin doit obligatoirement être une chaîne littérale — pas une variable. C'est précisément ce qui permet aux outils d'analyser les imports sans exécuter le code : bundlers, vérificateurs de types et tree-shakers reposent tous là-dessus.
// Non autorisé — SyntaxError.
if (userWantsFancy) {
import { fancy } from './fancy.js';
}
Si tu as besoin d'un chargement conditionnel, utilise import() (on y vient juste après).
Vivante. Une liaison importée est une référence en lecture seule vers l'export, pas une copie figée. Si le module qui exporte réassigne la valeur, ceux qui l'importent voient la nouvelle valeur :
Côté consommateur, tu ne peux pas non plus réaffecter un import : faire count = 5 dans main.js déclencherait une erreur. Les imports sont des vues en lecture seule.
Charger à la demande avec import() dynamique
Quand tu dois décider à l'exécution s'il faut charger un module — fonctionnalités lourdes, code splitting par route, polyfills conditionnels — utilise import() comme une fonction. Elle renvoie une promesse qui se résout avec les exports du module :
Comme il s'agit d'un simple appel de fonction, tu peux :
- L'attendre avec
awaitdans une fonctionasync. - Lui passer une variable comme chemin.
- L'utiliser dans un
ifou untry/catch.
Le déstructurage de l'objet résolu fonctionne exactement comme avec un import statique :
default est la clé utilisée pour l'export par défaut quand tu fais une déstructuration. Libre à toi de la renommer comme tu veux.
Les cas d'usage concrets ? Le code-splitting (ne charger la bibliothèque de graphiques que lorsque l'utilisateur clique sur « afficher le graphique »), les polyfills conditionnels selon la détection de fonctionnalités, ou encore les plugins découverts à l'exécution.
Exécuter des modules ES : navigateurs et Node.js
La syntaxe reste identique partout ; ce qui change, c'est la façon dont le runtime localise et charge le fichier.
Côté navigateur, il suffit de déclarer le script d'entrée comme un module :
<script type="module" src="./main.js"></script>
Avec type="module", le navigateur prend en compte les import/export, exécute le code en mode strict et diffère l'exécution jusqu'à ce que le HTML soit parsé. Les chemins doivent être relatifs (./, ../) ou des URLs absolues — les spécificateurs nus comme import 'lodash' ne fonctionnent pas sans import map ni bundler.
Côté Node, il existe deux façons d'activer les modules ES :
- Nommer le fichier avec l'extension
.mjs, ou - Définir
"type": "module"dans lepackage.jsonle plus proche, ce qui transforme chaque fichier.jsen module.
Node impose aussi des chemins complets avec l'extension : import './utils.js', et non import './utils'.
// package.json
{
"type": "module",
"main": "./index.js"
}
Les deux environnements exigent des extensions explicites en ESM natif. Les bundlers (Vite, webpack, esbuild) résolvent les chemins sans extension pendant le développement — pratique, mais s'en remettre à eux, c'est accepter que votre code source ne tournera pas sans l'étape de build.
Pièges classiques à éviter
Quelques erreurs qui reviennent souvent :
- Oublier
type="module"dans le navigateur. Sans ça, la balise<script>est exécutée comme un script classique, etimportdéclenche une erreur de syntaxe. - Omettre les extensions de fichier dans Node.
import './utils'échoue ;import './utils.js'fonctionne. Les bundlers masquent ce détail, les runtimes natifs non. - Compter sur
__dirnameourequiredans un module ES. Ces éléments sont réservés à CommonJS. En ESM, utilisezimport.meta.urlet convertissez-le si vous avez besoin d'un chemin. - Imports circulaires qui accèdent à des valeurs non initialisées. Deux modules qui s'importent mutuellement, c'est autorisé, mais lire un export pas encore assigné vous renverra
undefined. Organisez votre code pour que le cycle ne soit pas traversé à l'initialisation, ou cassez-le. - Vouloir faire un
importconditionnel. L'instructionimportstatique ne le permet pas. Passez par l'import()dynamique pour tout ce qui dépend du runtime.
La suite : CommonJS vs ESM
Les modules ES sont devenus le standard, mais une bonne partie du code Node en circulation utilise encore CommonJS — avec require, module.exports, et une logique différente concernant le moment où le code s'exécute. Connaître les deux, et savoir comment ils s'interfacent, c'est le sujet de la page suivante.
Questions fréquentes
Comment utiliser les modules ES en JavaScript ?
On expose des valeurs depuis un fichier avec export ou export default, puis on les récupère ailleurs avec import. Côté navigateur, il faut charger le point d'entrée avec <script type="module" src="main.js"></script>. Côté Node, deux options : utiliser l'extension .mjs, ou ajouter "type": "module" dans le package.json.
Quelle différence entre export par défaut et exports nommés ?
Un module peut avoir autant d'exports nommés qu'on veut (export function foo() {}), mais un seul export par défaut (export default ...). Les exports nommés s'importent obligatoirement avec le même nom, entre accolades : import { foo } from './x.js'. L'export par défaut, lui, peut être importé sous le nom qu'on veut : import peuImporte from './x.js'.
C'est quoi l'import dynamique en JavaScript ?
import() utilisé comme une fonction renvoie une promesse qui se résout avec les exports du module. Contrairement au import statique, il s'exécute au moment de l'appel — on peut donc charger du code à la demande ou sous condition. C'est ce qui permet de faire du code-splitting et du lazy loading.
Faut-il mettre l'extension du fichier dans les chemins d'import ?
Avec les modules ES natifs (navigateur et loader ESM de Node), oui, c'est obligatoire : il faut écrire ./utils.js, pas ./utils. Les bundlers comme Vite ou webpack sont plus souples et résolvent les chemins sans extension, mais s'appuyer là-dessus rend le code non portable.