📍 Le guide du parcours : JavaScript moderne : le guide complet
Quatrième tutoriel de la série JavaScript moderne. Il rassemble dans des fichiers le code écrit aux tutoriels précédents.
Introduction
Jusqu’ici, tout votre code de CarnetTâches tenait dans un seul endroit. Ça va vite devenir ingérable : la modélisation, les fonctions, l’accès aux données et l’affichage mélangés dans un fichier de mille lignes, où tout dépend de tout. Les modules ES résolvent ce chaos. Chaque fichier devient une unité autonome qui déclare ce qu’il offre (export) et ce dont il a besoin (import). À la fin de ce tutoriel, vous saurez découper une application en fichiers cohérents, distinguer exports nommés et export par défaut, et même charger un module à la demande avec l’import dynamique.
🎯 Ce que vous allez apprendre
- Activer les modules ES dans le navigateur et avec Node.js.
- Exporter et importer des valeurs, en distinguant exports nommés et export par défaut.
- Comprendre que les liaisons importées sont vivantes et que les modules ne s’exécutent qu’une fois.
- Charger un module à la demande avec l’import dynamique
import(). - Organiser CarnetTâches en modules à responsabilité unique.
🛠️ Ce que vous allez construire
Vous allez éclater CarnetTâches en quatre fichiers : modele.js (la fabrique et la validation), donnees.js (le chargement asynchrone), stats.js (un module chargé à la demande) et main.js (le point d’entrée qui assemble tout). Le résultat est une application qui démarre depuis une seule balise <script type="module">, sans aucun outil de construction.
Prérequis
- Un éditeur de texte et un navigateur récent. Pour la partie Node.js : Node 20 ou plus récent (au moment d’écrire, la version 24 est en support long terme actif).
- Savoir écrire des fonctions et de l’asynchrone. Test express : si vous savez ce que renvoie une fonction
async, vous êtes prêt ; sinon, voyez Promesses et async/await. - ⏱️ Temps estimé : ~40 minutes.
Étape 1 — Activer les modules
Un fichier JavaScript n’est pas un module par défaut : il faut le déclarer. Dans le navigateur, on ajoute l’attribut type="module" à la balise <script>. Cela change plusieurs choses d’un coup : le fichier a sa propre portée (plus de pollution globale), le mode strict est automatique, et les import deviennent disponibles. Créons la coquille de l’application.
<!-- index.html -->
<!DOCTYPE html>
<html lang="fr">
<head><meta charset="utf-8"><title>CarnetTâches</title></head>
<body>
<ul id="liste"></ul>
<!-- type="module" : ce script peut importer d'autres modules -->
<script type="module" src="main.js"></script>
</body>
</html>
Un point pratique important : les modules sont soumis à la politique de même origine, donc ouvrir le fichier directement avec file:// échoue souvent. Servez la page via un petit serveur local. Côté Node.js, deux options : nommer vos fichiers .mjs, ou ajouter "type": "module" dans le package.json du projet, ce qui fait traiter tous les .js comme des modules.
// package.json — pour que Node traite les .js comme des modules ES
{
"name": "carnet-taches",
"version": "1.0.0",
"type": "module"
}
✅ Point d’étape — Lancez un serveur local dans le dossier (par exemple
npx serveou l’extension de votre éditeur) et ouvrezindex.html. La console ne doit afficher aucune erreur de type CORS ou « Failed to load module ». Sifile://vous donne une erreur, c’est attendu : passez par un serveur.
Étape 2 — Exports nommés
L’export nommé est le plus courant : un fichier expose plusieurs valeurs identifiées par leur nom. On crée modele.js, qui rassemble la fabrique et la validation des tutoriels précédents. Tout ce qui n’est pas exporté reste privé au module — une encapsulation gratuite.
// modele.js
let prochain = 1; // privé : invisible hors du module
export function creerTache(titre, options = {}) {
return {
id: prochain++,
titre: titre.trim(),
fait: options.fait ?? false,
priorite: options.priorite ?? 2,
etiquettes: options.etiquettes ?? [],
echeance: options.echeance ?? null
};
}
export function validerTache(t) {
const erreurs = [];
if (typeof t.titre !== "string" || t.titre.trim() === "") erreurs.push("titre manquant");
if (typeof t.fait !== "boolean") erreurs.push("fait doit être un booléen");
if (![1, 2, 3].includes(t.priorite)) erreurs.push("priorité invalide");
return erreurs;
}
export const PRIORITES = { HAUTE: 1, MOYENNE: 2, BASSE: 3 }; // export d'une constante
On importe ensuite ces valeurs par leur nom, entre accolades. L’ordre n’a pas d’importance, et on peut renommer à l’import avec as pour éviter une collision. La variable prochain, non exportée, demeure totalement inaccessible de l’extérieur : le module joue le rôle d’encapsulation que la closure assurait au tutoriel précédent.
// dans un autre fichier
import { creerTache, validerTache, PRIORITES } from "./modele.js";
const t = creerTache("Réviser les modules", { priorite: PRIORITES.HAUTE });
console.log(validerTache(t)); // [] → valide
Étape 3 — Export par défaut
Un module peut désigner un export principal avec export default. On l’importe alors sans accolades, et on choisit librement son nom à l’import. La convention répandue : un export par défaut pour « la chose principale » du module, des exports nommés pour les utilitaires associés. Créons donnees.js, dont l’export par défaut est le chargeur de tâches.
// donnees.js
function chargerTachesSimule() {
return new Promise(r => setTimeout(() => r([
{ id: 1, titre: "Réviser", fait: false, priorite: 1, etiquettes: ["étude"], echeance: null }
]), 300));
}
// export par défaut : la fonction principale du module
export default async function chargerTaches() {
try {
return await chargerTachesSimule();
} catch (e) {
console.error("Chargement impossible :", e.message);
return [];
}
}
// export nommé en complément
export const SOURCE = "api/taches";
// import : le défaut prend le nom qu'on veut, les nommés gardent le leur
import chargerTaches, { SOURCE } from "./donnees.js";
console.log("Source :", SOURCE);
const taches = await chargerTaches();
Remarquez la liberté de nommage : à l’import du défaut, j’aurais pu écrire import charger from "./donnees.js" et ça aurait marché tout autant. C’est à la fois pratique et un piège : un export par défaut renommé à chaque import nuit à la lisibilité d’un projet. Beaucoup d’équipes préfèrent donc les exports nommés partout, pour garantir des noms cohérents.
✅ Point d’étape — Importez
chargerTachessous deux noms différents dans deux fichiers. Les deux fonctionnent, ce qui prouve que c’est bien le même export par défaut. Si l’un échoue, vérifiez que vous n’avez pas mis d’accolades autour de l’import du défaut.
Étape 4 — Liaisons vivantes et exécution unique
Deux comportements des modules surprennent souvent et méritent une démonstration. D’abord, un module n’est exécuté qu’une seule fois, quel que soit le nombre de fois où il est importé : le résultat est mis en cache et partagé. Ensuite, les liaisons importées sont vivantes : si le module source change la valeur exportée, l’importateur voit la nouvelle valeur, car il référence la liaison, pas une copie figée.
// compteur.js
export let total = 0;
export function incrementer() { total++; }
console.log("compteur.js exécuté"); // s'affiche UNE seule fois, même importé partout
// main.js
import { total, incrementer } from "./compteur.js";
console.log(total); // 0
incrementer();
incrementer();
console.log(total); // 2 — la liaison est vivante, on voit la valeur à jour
Ce comportement explique pourquoi importer un module de configuration partagé donne partout le même état : tous les fichiers parlent à la même instance. Attention en revanche : on ne peut pas réassigner une liaison importée depuis l’extérieur (total = 5 lèverait une erreur) — elle est en lecture seule côté importateur. Pour modifier l’état, on passe par une fonction exportée, comme incrementer().
Étape 5 — L’import dynamique
Les import en haut de fichier sont statiques : ils sont résolus avant l’exécution. Mais parfois, on veut charger un module seulement si besoin — par exemple un module de statistiques qu’on n’utilise que lorsque l’utilisateur ouvre un onglet « rapport ». L’import dynamique import() renvoie une promesse et charge le module à la demande, ce qui allège le chargement initial.
// stats.js — module potentiellement lourd, chargé seulement au besoin
export function compterParPriorite(taches) {
return taches.reduce((acc, t) => {
acc[t.priorite] = (acc[t.priorite] ?? 0) + 1;
return acc;
}, {});
}
// main.js — on ne charge stats.js que lorsqu'on en a vraiment besoin
async function afficherRapport(taches) {
const { compterParPriorite } = await import("./stats.js"); // chargé maintenant
console.log("Répartition :", compterParPriorite(taches));
}
// stats.js n'est téléchargé qu'au premier appel de afficherRapport()
await afficherRapport([{ priorite: 1 }, { priorite: 1 }, { priorite: 3 }]);
L’import dynamique est la base du « découpage de code » (code splitting) : on ne télécharge le code d’une fonctionnalité que lorsqu’elle est utilisée. Sur une connexion lente, c’est un gain net — la page s’affiche avec le strict nécessaire, le reste vient à la demande. Notez que import() renvoie une promesse dont la valeur est l’objet module entier, d’où la déstructuration { compterParPriorite }.
✅ Point d’étape — Dans l’onglet « Réseau » des outils de développement, vérifiez que
stats.jsn’est téléchargé qu’au moment oùafficherRapport()s’exécute, pas au chargement de la page. C’est tout l’intérêt du chargement à la demande.
Étape 6 — Assembler l’application
Réunissons tout dans main.js, le point d’entrée pointé par index.html. Il importe le modèle et les données, charge les tâches, en ajoute une, et déclenche le rapport en chargement différé. C’est l’ossature complète de CarnetTâches, désormais répartie sur quatre fichiers clairs.
// main.js — point d'entrée
import { creerTache, validerTache } from "./modele.js";
import chargerTaches from "./donnees.js";
async function demarrer() {
const taches = await chargerTaches();
console.log("Chargées :", taches.length);
const nouvelle = creerTache("Découper en modules", { priorite: 1 });
if (validerTache(nouvelle).length === 0) {
taches.push(nouvelle);
}
console.log("Total :", taches.length);
// rapport chargé à la demande
const { compterParPriorite } = await import("./stats.js");
console.log(compterParPriorite(taches));
}
demarrer();
Chaque fichier a une responsabilité unique : modele.js définit la donnée, donnees.js la récupère, stats.js l’analyse, main.js orchestre. On peut désormais modifier la validation sans toucher au réseau, ou changer la source de données sans toucher au modèle. C’est exactement ce que les modules apportent : des frontières nettes entre les morceaux d’une application.
Un dernier conseil d’organisation, qui devient crucial quand le projet grandit : surveillez les dépendances circulaires. Si modele.js importe donnees.js qui, à son tour, importe modele.js, vous créez une boucle. Les modules ES la tolèrent dans certains cas, mais elle mène souvent à une valeur undefined au moment où l’un des deux fichiers n’a pas fini de s’initialiser — un bug déroutant à diagnostiquer. La parade est architecturale : faites dépendre les modules « de haut niveau » (orchestration) des modules « de bas niveau » (données, modèle), jamais l’inverse. Le modèle ne doit rien savoir de l’interface ; l’interface peut tout savoir du modèle. Cette discipline de sens unique dans le graphe des imports garde une base de code saine sur la durée, et c’est précisément ce que le découpage en modules vous permet de visualiser et de contrôler.
🐞 Pièges fréquents
| Symptôme / erreur | Cause probable | Correctif |
|---|---|---|
Cannot use import statement outside a module |
Fichier traité comme script classique | Ajouter type="module" (navigateur) ou "type": "module" (Node) |
CORS / blocage avec file:// |
Les modules exigent une origine HTTP | Servir via un serveur local (npx serve) |
| Import qui échoue sans extension | Le navigateur exige le chemin complet | Toujours écrire "./modele.js", avec l’extension |
| Accolades manquantes ou en trop | Confusion entre import nommé et par défaut | Accolades pour les nommés, sans pour le défaut |
| Réassignation d’un import qui lève une erreur | Les liaisons importées sont en lecture seule | Modifier l’état via une fonction exportée |
🌍 Réalités du terrain
Les modules ES fonctionnent nativement dans le navigateur, sans aucun outil de construction : une balise <script type="module"> suffit pour démarrer un vrai projet. C’est un atout quand on apprend ou qu’on développe sur une bande passante limitée — pas de téléchargement de chaîne d’outils. L’import dynamique pousse l’avantage plus loin en ne chargeant chaque fonctionnalité qu’au moment utile. Pour de plus gros projets, des outils comme Vite ou esbuild regroupent et optimisent les modules ; installez-les une fois, mettez en cache node_modules, et le reste du travail se fait localement. Mais gardez en tête qu’un projet entier peut vivre uniquement avec des modules ES natifs.
✅ Récapitulatif
Vous savez maintenant activer les modules dans le navigateur (type="module") et avec Node ("type": "module" ou .mjs). Vous distinguez exports nommés et export par défaut, et vous connaissez deux comportements clés : l’exécution unique et les liaisons vivantes. Vous chargez un module à la demande avec import(). Surtout, vous avez réorganisé CarnetTâches en quatre fichiers à responsabilité unique — une base saine pour faire grandir l’application.
🧾 Aide-mémoire
| Élément | Rôle |
|---|---|
export function f() {} |
Export nommé |
export default ... |
Export par défaut (un seul par module) |
import { a, b } from "./x.js" |
Importer des exports nommés |
import nom from "./x.js" |
Importer l’export par défaut |
import { a as b } from ... |
Importer en renommant |
await import("./x.js") |
Import dynamique (renvoie une promesse) |
type="module" / "type": "module" |
Activer les modules (navigateur / Node) |
💪 À vous de jouer
Créez un module stockage.js qui exporte sauvegarder(taches) et restaurer() en s’appuyant sur localStorage, puis importez-le dans main.js pour persister les tâches d’une session à l’autre. Indice : localStorage ne stocke que des chaînes.
Voir une solution
// stockage.js
const CLE = "carnet-taches";
export function sauvegarder(taches) {
localStorage.setItem(CLE, JSON.stringify(taches)); // objet → chaîne JSON
}
export function restaurer() {
const brut = localStorage.getItem(CLE);
return brut ? JSON.parse(brut) : []; // chaîne JSON → objet, ou liste vide
}
// dans main.js
import { sauvegarder, restaurer } from "./stockage.js";
const taches = restaurer(); // récupère la session précédente
taches.push(creerTache("Persister l'état"));
sauvegarder(taches); // conservé pour la prochaine visite
Tutoriels liés
- Promesses et async/await — le tutoriel précédent.
- Manipuler le DOM et consommer une API avec fetch — où ces modules pilotent une vraie interface.
Pour aller plus loin
- 🔝 Retour au guide : JavaScript moderne : le guide complet
- MDN — Les modules JavaScript
- Tutoriel suivant : Manipuler le DOM et consommer une API avec fetch
FAQ
Quelle différence entre modules ES et CommonJS (require) ?
CommonJS (require/module.exports) est l’ancien système de Node.js, synchrone et historique. Les modules ES (import/export) sont le standard du langage, fonctionnent dans le navigateur comme dans Node, et permettent l’analyse statique et l’import dynamique. Pour un nouveau projet, choisissez les modules ES.
Pourquoi dois-je mettre l’extension .js dans les imports ?
Dans le navigateur et en mode module strict de Node, le chemin doit être complet — le résolveur ne devine pas l’extension. Les outils comme Vite sont plus permissifs, mais écrire l’extension reste la pratique portable et sûre.
Le mode strict est-il vraiment automatique dans un module ?
Oui. Tout module ES s’exécute en mode strict sans avoir à écrire "use strict". Cela interdit certaines erreurs silencieuses (variables non déclarées, par exemple) et rend le code plus sûr.
Puis-je utiliser await directement dans un module, hors fonction async ?
Oui, au niveau supérieur d’un module ES : c’est le top-level await. Pratique pour initialiser un module avec une donnée chargée de façon asynchrone. Ce n’est pas possible dans un script classique.