CommonJS contre les modules ES : la séparation structurelle à l’origine des erreurs d’import de Node
Découvrez pourquoi require et import sont des systèmes fondamentalement différents, comment l’analyse statique influence le tree-shaking, et pourquoi les exports par défaut et les imports circulaires se comportent de manière incohérente dans les deux cas.
Presque chaque erreur de module déroutante que vous rencontrez en travaillant avec Node est liée à un fait qui n’est que rarement expliqué clairement. Des messages tels qu’une erreur de référence indiquant que require n’existe pas dans le contexte du module, ou une erreur de syntaxe concernant l’utilisation de import en dehors d’un module, ou encore un paquet dont le comportement varie selon la manière dont il est chargé — tout cela renvoie à la même cause racine : CommonJS et ES Modules ne sont pas simplement deux façons d’écrire la même idée. Ce sont deux systèmes véritablement distincts. L’un est basé sur des appels de fonction qui s’exécutent au moment où ils sont lancés ; l’autre repose sur une structure que le moteur peut examiner avant que quoi que ce soit ne s’exécute réellement. Presque chaque problème que vous rencontrez lorsque ces deux systèmes interagissent est une conséquence directe de cette séparation.
CommonJS : require n’est qu’un appel de fonction
Il est facile d’oublier, une fois que l’on a tapé require() mille fois, qu’il n’y a rien de magique là-dedans. Il s’agit d’une fonction ordinaire, et module.exports est un objet ordinaire — tous deux fournis par Node au moment de l’exécution, et non intégrés directement dans le langage lui-même.
// math.js
function add(a, b) { return a + b; }
module.exports = { add };
// app.js
const math = require("./math.js"); // a plain function call, evaluated when this line runs
console.log(math.add(2, 3));
Puisque require se comporte comme n’importe quelle autre fonction, on peut l’appeler de manière conditionnelle : à l’intérieur d’un bloc if, dans un bloc try/catch, ou en utilisant un chemin calculé à partir d’une variable — tout ce que permet une appel de fonction ordinaire.
const driver = require(process.env.DB_DRIVER === "postgres" ? "./pg-driver" : "./sqlite-driver");
Ce niveau de flexibilité est vraiment pratique, et c’est justement ce que les ES Modules ont choisi de sacrifier.
ES Modules : le moteur lit la structure avant d’exécuter du code
import n’est pas une appel de fonction — c’est une déclaration, et elle est soumise à une règle qui prend presque tout le monde par surprise la première fois : elle doit se trouver au niveau le plus élevé d’un fichier. On ne peut pas la placer à l’intérieur d’une condition, d’une boucle ou du corps d’une fonction.
// math.mjs
export function add(a, b) { return a + b; }
// app.mjs
import { add } from "./math.mjs"; // not evaluated like a function call
console.log(add(2, 3));
if (needsMath) {
import { add } from "./math.mjs"; // SyntaxError, this is not allowed
}
Ce critère n’est pas le fruit d’un choix arbitraire. Il existe parce que les modules ES sont conçus pour être analysables statiquement : avant même qu’une seule ligne de votre programme ne s’exécute, le moteur examine chaque import et export dans l’ensemble du graphe des modules afin de créer une carte complète indiquant ce qui dépend de quoi. C’est cette carte statique qui permet le tree-shaking — un outil de bundling peut examiner ce graphe et supprimer en toute sécurité le code qui est exporté mais jamais importé nulle part, car les relations de dépendance sont connues à l’avance et non pas seulement lors de l’exécution. CommonJS ne peut pas faire la même promesse, car les appels à require() peuvent être conditionnels, calculés ou intégrés dans une logique qui ne se résout qu’au cours de l’exécution du programme — c’est précisément cette flexibilité qui rend impossible de connaître à l’avance le graphe des dépendances de CommonJS.
C’est là que l’interopérabilité commence réellement à poser des problèmes. Dans CommonJS, écrire module.exports = something remplace simplement ce que représente l’ensemble du module — il n’existe pas de notion distincte d’« export par défaut » séparée des autres exports :
// legacy.js
module.exports = function greet(name) {
return `Hello, ${name}`;
};
En revanche, ESM considère l’export par défaut comme un concept explicite et structuralement distinct :
// modern.mjs
export default function greet(name) {
return `Hello, ${name}`;
}
Lorsque la couche d’interopérabilité de Node charge un fichier CommonJS à partir de code ESM, elle prend toute la valeur de module.exports et l’encadre en tant qu’export par défaut. Ce comportement est généralement judicieux, mais il crée également exactement ce type de différence subtile qui embrouille les utilisateurs :
import greet from "./legacy.js"; // works: greet is the whole module.exports value
import { greet } from "./legacy.js"; // fails silently or throws, depending on the module
// named destructuring assumes CommonJS explicitly attached named properties,
// which module.exports = function... never did
Cette seule ambiguïté — savoir si ce que l’on importe est tout le module ou seulement une de ses parties nommées — explique une grande partie des erreurs du type « pourquoi ceci est-il non défini » dès lors qu’une base de code mélange des packages CommonJS plus anciens avec du code ESM plus récent.
Les imports circulaires se résolvent différemment, et cela a vraiment de l’importance
Le fait que deux modules s’importent mutuellement constitue déjà une situation délicate dans tout système de modules, mais CommonJS et ESM gèrent cette fragilité de manières différentes, ce qui signifie que du code apparemment identique peut échouer de façon différente en fonction du système dans lequel il s’exécute.
// a.js (CommonJS)
const b = require("./b.js");
console.log("b's value:", b.value);
module.exports = { value: "from a" };
// b.js (CommonJS)
const a = require("./a.js");
console.log("a's value:", a.value); // undefined — a hasn't finished exporting yet
module.exports = { value: "from b" };
CommonJS gère cela en retournant ce que contient module.exports du module nécessité de manière circulaire, à cet instant précis, même si ce module n’a pas encore terminé son exécution. C’est pourquoi a.value apparaît comme undefined lorsqu’il est lu depuis b.js : a.js n’avait pas encore atteint l’instruction d’affectation de module.exports au moment où b.js en a fait la demande.
ESM adopte une approche différente grâce à ce qu’on appelle les liens en temps réel : des références qui restent liées au module exportateur et s’actualisent automatiquement une fois que ce dernier a terminé son évaluation, au lieu d’un état figé au moment de l’importation. Cela signifie que certains schémas circulaires fonctionnent correctement sous ESM, alors qu’ils produiraient silencieusement undefined sous CommonJS. Mais cela ne fait pas des imports circulaires une bonne idée dans aucun des deux systèmes — cela modifie simplement la manière dont l’erreur apparaît, sans pour autant l’éliminer.
Le piège pratique : les mélanger dans un même projet
Au quotidien, le problème n’est pas vraiment conceptuel — il se résume à un ensemble spécifique et récurrent d’erreurs :
SyntaxError: Cannot use import statement outside a module
ReferenceError: require is not defined in ES module scope
ReferenceError: exports is not defined
Ces erreurs apparaissent parce que Node doit déterminer dans quel format de module un fichier donné est écrit, et il effectue cette vérification en examinant plusieurs critères : si le fichier se termine par .mjs, s’il se termine par .cjs, ou, à défaut de ces deux cas, ce que le package.json le plus proche indique via son champ "type". Chaque fois que la syntaxe réelle d’un fichier ne correspond pas à la manière dont Node a décidé de l’interpréter, ce sont précisément ces erreurs qui surviennent. Un package publié uniquement en format ESM ne peut pas être chargé à l’aide de require() depuis du code CommonJS. Pour l’utiliser, un projet a besoin soit de l’instruction asynchrone import() — qui, contrairement à la commande statique import, agit comme une véritable appel de fonction et peut être utilisée n’importe où, y compris dans des conditions — soit d’une migration complète du code qui l’utilise vers le format ESM.
// this works from CommonJS, because import() is a dynamic function call, not a static declaration
async function loadEsmOnlyPackage() {
const mod = await import("esm-only-package");
return mod.default;
}
Le fait fondamental derrière tout cela
Chaque point de friction — l’exigence que import se trouve au niveau le plus élevé, le fait que l’optimisation tree-shaking soit possible dans un système mais pas dans un autre, des exports par défaut incompatibles, ainsi que des imports circulaires qui se comportent différemment — remonte à une seule cause sous-jacente : CommonJS construit son graphe de modules dynamiquement, au fur et à mesure que le programme s’exécute, tandis qu’ESM construit son graphe statiquement, avant même que du code ne soit exécuté. Aucune de ces approches n’est une faille dans la conception de l’autre ; toutes deux répondent à la même question — « comment les fichiers dépendent-ils les uns des autres ? » — mais avec des contraintes et des garanties véritablement différentes. La difficulté que vous ressentez en les mélangeant ne vient pas d’un défaut de Node. C’est plutôt le résultat de deux systèmes internement cohérents qui sont contraints à communiquer au point où ils se rencontrent.
Lectures complémentaires
- Le cache des cartes de source de Node est un fuitement mémoriel silencieux en mode développement — Découvrez pourquoi l’activation de --enable-source-maps ou de NODE_V8_COVERAGE peut provoquer une croissance illimitée de la mémoire due à des appels eval répétés, ainsi que comment le diagnostiquer et l’atténuer dès aujourd’hui.
- Corréler les journaux lors d’appels asynchrones avec AsyncLocalStorage — Apprenez comment AsyncLocalStorage de Node.js suit le contexte par requête, comme requestId, au-delà des limites d’await, sans avoir à le transmettre manuellement dans chaque fonction.