Retour au cours

frontend / javascript

Modules ES vs CommonJS

Leçon 141 exercice

Explication

Ce que vous allez apprendre

  • Distinguer la syntaxe CommonJS (require/module.exports) de celle des modules ES (import/export)
  • Expliquer pourquoi require() est synchrone et pourquoi ça convient au serveur mais pas au navigateur
  • Décrire ce que permet l'analyse statique des imports ES (le tree-shaking)
  • Expliquer la différence entre un binding vivant (ES) et une copie figée (CommonJS)
  • Diagnostiquer une erreur require is not defined ou Cannot use import statement outside a module

Dans quel contexte ?

Un développeur ajoute une dépendance npm récente à son projet Node.js et obtient l'erreur Error [ERR_REQUIRE_ESM]: require() of ES Module not supported. La bibliothèque est distribuée uniquement en modules ES (import/export), alors que son projet utilise encore require() en CommonJS. Comprendre la différence entre les deux systèmes — et savoir que "type": "module" dans package.json bascule tout le projet vers les modules ES — est nécessaire pour résoudre ce genre d'incompatibilité, de plus en plus fréquente dans l'écosystème npm actuel.

1. Découper un programme en morceaux

Aucun programme sérieux ne tient dans un seul fichier : il faut un système pour découper le code en morceaux réutilisables ("modules") et les faire communiquer entre eux. L'histoire de JavaScript a produit deux systèmes qui coexistent encore aujourd'hui.

2. Le système historique : CommonJS

CommonJS (require/module.exports) est le système historique de Node.js, conçu pour un serveur où les fichiers sont disponibles localement, sans latence réseau. require() est donc synchrone : il bloque jusqu'à ce que le module soit chargé.

3. Pourquoi ce choix ne convient pas à un navigateur

Ce blocage est acceptable côté serveur, mais impensable dans un navigateur qui devrait télécharger le fichier via le réseau avant de continuer. D'où la nécessité d'un second système, pensé différemment dès le départ.

AspectCommonJSModules ES
Syntaxerequire() / module.exportsimport / export
ChargementSynchroneAsynchrone (adapté au navigateur)
AnalyseDynamique (imports conditionnels possibles)Statique (connue avant exécution)
Tree-shakingPeu fiableFiable
ExportCopie figée de la valeurBinding vivant

Prérequis

Aucune connaissance préalable spécifique n'est nécessaire, mais avoir déjà utilisé npm install et un fichier package.json aide à situer où ces deux systèmes de modules interviennent concrètement.

4. Le standard moderne : les modules ES

Les modules ES (import/export) sont le standard officiel du langage. Leur analyse est statique : l'ensemble des imports et exports d'un fichier est connu AVANT même l'exécution du code.

5. Ce que cette analyse statique permet

Cette propriété a une conséquence concrète : elle permet aux outils de build de faire du "tree-shaking", c'est-à-dire d'éliminer automatiquement le code jamais utilisé — une optimisation impossible à réaliser de façon fiable avec CommonJS, où les imports peuvent être conditionnels.

6. Une dernière différence à retenir

Les modules ES exportent des "bindings" vivants : si la valeur change dans le module source, le module qui l'importe voit la mise à jour. CommonJS, lui, copie la valeur au moment du require, une fois pour toutes.

Piège fréquent

Mélanger require() et import dans le même fichier provoque une erreur de syntaxe : le mode d'un fichier (CommonJS ou module ES) est déterminé par l'extension (.cjs/.mjs) ou le champ "type" de package.json, jamais mélangé au sein d'un même fichier.

Cette leçon prépare la suite : Fetch, puis les outils de bundling, s'appuient tous deux sur cette distinction.

Commandes & code

Modules ES vs CommonJS

Deux systèmes de modules coexistent dans l'écosystème JavaScript.

js
// ============ CommonJS (CJS) : historique, utilise par Node.js par defaut ============

// fichier math.js
function additionner(a, b) {
    return a + b;
}
const PI = 3.14159;

module.exports = { additionner, PI };
// ou export individuel :
// exports.additionner = additionner;

// fichier main.js
const { additionner, PI } = require("./math.js");
console.log(additionner(2, 3));

// require() est SYNCHRONE et peut etre appele conditionnellement / dynamiquement
if (process.env.NODE_ENV === "development") {
    const debugTools = require("./debug-tools.js");
}

// ============ ES Modules (ESM) : standard moderne, natif navigateur + Node (via "type": "module") ============

// fichier math.mjs (ou .js avec "type": "module" dans package.json)
export function additionnerESM(a, b) {
    return a + b;
}
export const PI_ESM = 3.14159;

export default function saluer() {          // UN SEUL export default par module
    return "Bonjour";
}

// fichier main.mjs
import saluer, { additionnerESM, PI_ESM } from "./math.mjs";
import * as maths from "./math.mjs";           // importer tout le module comme namespace

console.log(additionnerESM(2, 3));
console.log(maths.PI_ESM);

// Import avec renommage
import { additionnerESM as add } from "./math.mjs";

// Import dynamique : asynchrone, retourne une Promise -- utile pour le code-splitting/lazy loading
async function chargerModuleConditionnellement() {
    if (document.querySelector("#widget-avance")) {
        const { WidgetAvance } = await import("./widget-avance.mjs");
        new WidgetAvance();
    }
}

// --- Différences fondamentales ---
// 1. CJS est synchrone, ESM est concu pour etre asynchrone (import() dynamique)
// 2. ESM est analyse STATIQUEMENT (les imports sont hoisted et fixes) -> permet le tree-shaking
// 3. CJS copie les valeurs exportees au moment du require ; ESM garde des "bindings" LIVE

// Demonstration du "live binding" en ESM :
// fichier compteur.mjs
export let compte = 0;
export function incrementer() {
    compte++;
}
// fichier main.mjs
// import { compte, incrementer } from "./compteur.mjs";
// console.log(compte);   // 0
// incrementer();
// console.log(compte);    // 1 -- la valeur importee reste synchronisee (contrairement a CJS)

// --- Interop : utiliser du CJS depuis de l'ESM (Node.js) ---
// import monModuleCJS from "./legacy-module.cjs";   // fonctionne, avec export default implicite

// --- package.json : declarer le type de module par defaut ---
json
{
  "name": "mon-projet",
  "type": "module",
  "exports": {
    ".": "./index.mjs",
    "./utils": "./utils.mjs"
  }
}
js
// --- Tree-shaking : avantage majeur d'ESM pour les bundlers ---
// Comme les imports/exports ESM sont statiques (pas de require() conditionnel),
// un bundler (Webpack, Rollup, esbuild) peut analyser precisement ce qui est REELLEMENT
// utilise et eliminer le code mort a la compilation -- impossible de facon fiable avec CJS.

// import { fonctionUtilisee } from "./grosse-librairie.mjs";
// -> seul le code de "fonctionUtilisee" (et ses dependances) finit dans le bundle final

Résumé

  • CommonJS (require/module.exports) est synchrone et historique ; ES Modules (import/export) est le standard moderne.
  • ESM garde des bindings "live" entre modules ; CJS copie les valeurs au moment du require.
  • L'analyse statique des imports ESM permet le tree-shaking, impossible de façon fiable en CJS.
  • import() dynamique retourne une Promise, utile pour le lazy-loading et le code-splitting.

Exercices pratiques

1 disponible
1

Mission : le compteur partagé qui ne se synchronise plus

Objectif : Diagnostiquer une confusion entre binding live ESM et copie de valeur CommonJS, puis migrer un module vers le lazy-loading.

Contexte

Un développeur de Technologik migre un module compteur.js de CommonJS vers ES Modules :

js
// compteur.mjs
export let compte = 0;
export function incrementer() { compte++; }

// main.mjs
import { compte, incrementer } from "./compteur.mjs";
console.log(compte); // 0
incrementer();
console.log(compte); // il s'attend a 0, un collegue s'attend a 1

Une discussion éclate dans l'équipe sur ce que ce second console.log doit afficher. Il faut aussi ajouter un chargement paresseux d'un widget avancé, uniquement si un élément #widget-avance est présent sur la page.

Résoudre l’exercice →