Startseite / Artikel / CommonJS gegen ES Modules: Die strukturelle Trennung hinter den Import-Fehlern von Node

CommonJS gegen ES Modules: Die strukturelle Trennung hinter den Import-Fehlern von Node

Erfahren Sie, warum „require“ und „import“ grundlegend unterschiedliche Systeme sind, wie die statische Analyse das Tree-Shaking beeinflusst und warum Standard-Exports sowie zyklische Imports in beiden Systemen unterschiedlich funktionieren.

1364 Wörter

Jeder verwirrende Modulfehler, dem man beim Arbeiten mit Node begegnet, lässt sich auf einen Umstand zurückführen, der nur selten klar dargelegt wird. Meldungen wie eine Fehlermeldung, die darauf hinweist, dass require im Modulumfeld nicht existiert, oder ein Syntaxfehler wegen der Verwendung von import außerhalb eines Moduls, oder ein Paket, das je nach Art und Weise seiner Einbindung unterschiedlich funktioniert – all das weist auf denselben Ursprung hin: CommonJS und ES Modules sind nicht einfach nur zwei verschiedene Schreibweisen derselben Idee. Es handelt sich dabei um zwei völlig unterschiedliche Systeme. Das eine basiert auf Funktionsaufrufen, die sofort ausgeführt werden, sobald sie aufgerufen werden; das andere basiert auf einer Struktur, die der Engine bereits *vor* dem eigentlichen Ausführen untersuchen kann. Fast jede Unzulänglichkeit, der man begegnet, wenn diese beiden Systeme zusammenkommen, ist ein direkter Nebeneffekt dieser Trennung.

CommonJS: require Ist Einfach Ein Funktionsaufruf

Es ist leicht zu vergessen, sobald man require() tausendmal eingegeben hat, dass daran nichts Magisches ist. Es handelt sich um eine gewöhnliche Funktion, und module.exports ist ein gewöhnliches Objekt – beides wird von Node zur Laufzeit bereitgestellt, nicht direkt in die Sprache selbst integriert.

// 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));

Weil require sich wie jede andere Funktion verhält, kann man es bedingungslos aufrufen: innerhalb eines if-Blocks, innerhalb von try/catch, oder mit einem aus einer Variablen berechneten Pfad – alles, was eine normale Funktionsaufrufmöglichkeit zulässt.

const driver = require(process.env.DB_DRIVER === "postgres" ? "./pg-driver" : "./sqlite-driver");

Diese Flexibilität ist tatsächlich sehr nützlich – und genau das hat ES Modules bewusst geopfert.

ES Modules: Der Engine liest die Struktur, bevor er Code ausführt

import ist keine Funktionsaufruf – es handelt sich um eine Deklaration, und es gibt eine Regel, die fast jeden beim ersten Mal überrascht: Sie muss auf der obersten Ebene einer Datei stehen. Man kann sie nicht in eine Bedingung, einen Schleifenzyklus oder den Funktionskörper einfügen.

// 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
}

Diese Einschränkung ist keine willkürliche Präzision. Sie existiert, weil ES Modules dafür gedacht sind, statisch analysierbar zu sein: Bevor auch nur eine Zeile Ihres Programms tatsächlich ausgeführt wird, durchläuft der Interpreter alle import- und export-Anweisungen im gesamten Modulgraphen und erstellt eine vollständige Übersicht darüber, was von was abhängt. Diese statische Übersicht ermöglicht das Tree-Shaking – ein Bundler kann den Graphen prüfen und sicher Code entfernen, der exportiert wurde, aber nirgends importiert wird, da die Abhängigkeitsbeziehungen im Voraus bekannt sind und nicht erst während der Ausführung sichtbar werden. CommonJS kann dieses Versprechen nicht einhalten, da require()-Aufrufe bedingungsbehaftet sein können, berechnet werden oder in Logik versteckt sind, die erst während der Programmausführung gelöst wird – genau diese Flexibilität macht es unmöglich, den Abhängigkeitsgraphen von CommonJS im Voraus zu kennen.

dvance.

Standardexporte bedeuten in den beiden Systemen nicht dasselbe

Hier fängt die Interoperabilität tatsächlich an, Probleme zu verursachen. In CommonJS ersetzt das Schreiben von module.exports = something einfach das, was der gesamte Modul ist – es gibt kein separates Konzept eines „Standardexports“, das von anderen Exporten getrennt wäre:

// legacy.js
module.exports = function greet(name) {
  return `Hello, ${name}`;
};

ESM hingegen betrachtet den Standardexport als ein explizites, strukturell getrenntes Konzept:

// modern.mjs
export default function greet(name) {
  return `Hello, ${name}`;
}

Wenn die Interoperabilitätsschicht von Node eine CommonJS-Datei aus ESM-Code lädt, nimmt sie den gesamten Wert von module.exports und umhüllt ihn als Standardexport. Das ist in der Regel das sinnvolle Verhalten, führt aber auch genau zu solchen subtilen Unstimmigkeiten, die Menschen in Verwirrung bringen:

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

Genau diese Uneindeutigkeit – ob man den gesamten Modulcode importiert oder nur einen bestimmten Teil davon – ist für einen großen Anteil der Fehler vom Typ „Warum ist das undefiniert?“ verantwortlich, sobald eine Codebasis ältere CommonJS-Pakete mit neuerem, ESM-fokussiertem Code mischt.

Zyklische Importe werden unterschiedlich gelöst – und das ist tatsächlich wichtig

Zwei Module, die sich gegenseitig importieren, stellen bereits in jedem Modulsystem eine heikle Situation dar, doch CommonJS und ESM gehen mit dieser Fragilität unterschiedlich um, wodurch derselbe scheinbar identische Code je nach verwendetem System unterschiedlich versagen kann.

// 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 handhabt dies, indem es genau das zurückgibt, was der zyklisch benötigte Modul-Objektteil module.exports in diesem genauen Moment enthält – selbst wenn das Modul noch nicht vollständig ausgeführt wurde. Deshalb erscheint a.value als undefined, wenn es aus b.js gelesen wird – a.js hatte zum Zeitpunkt, an dem b.js darauf zugriff, seine Zuweisung an module.exports noch nicht erreicht.

ESM verfolgt einen anderen Ansatz durch sogenannte Live-Bindings: Referenzen, die weiterhin mit dem exportierenden Modul verbunden bleiben und sich automatisch aktualisieren, sobald dieses Modul seine Auswertung abgeschlossen hat, anstatt ein bei der Importzeit erstelltes „Snapshot“. Das bedeutet, dass bestimmte zyklische Muster unter ESM korrekt funktionieren, während sie unter CommonJS stillschweigend undefined zurückgeben würden. Doch das macht zyklische Importe in keinem der Systeme zu einer guten Idee – es ändert lediglich, wie das Versagen auftreten wird, anstatt es zu beseitigen.

Die praktische Falle: Ihre Verwendung in einem Projekt

Täglichlich liegt das Problem nicht wirklich im Konzept – es reduziert sich auf eine bestimmte, wiederkehrende Gruppe von Fehlern:

SyntaxError: Cannot use import statement outside a module
ReferenceError: require is not defined in ES module scope
ReferenceError: exports is not defined

Diese Fehler treten auf, weil Node herausfinden muss, in welchem Modulformat eine bestimmte Datei geschrieben ist, und dazu mehrere Kriterien heranzieht: Ob die Datei auf .mjs endet, ob sie auf .cjs endet, oder – falls keines dieser Kriterien zutrifft – was in der nächstgelegenen package.json-Datei über das Feld "type" angegeben ist. Immer dann, wenn die tatsächliche Syntax einer Datei nicht mit der übereinstimmt, wie Node sie interpretieren will, treten genau diese Fehler auf. Ein Paket, das ausschließlich als ESM veröffentlicht wurde, kann nicht mit require() aus CommonJS-Code eingebunden werden. Um es verwenden zu können, benötigt ein Projekt entweder den asynchronen import() – der sich im Gegensatz zum statischen import-Schlüssel wie ein echter Funktionsaufruf verhält und überall, einschließlich in bedingten Anweisungen, verwendet werden kann – oder eine vollständige Migration des verwendenden Codes auf 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;
}

Die eine grundlegende Tatsache hinter all dem

Jeder einzelne Konfliktpunkt – die Anforderung, dass import auf der obersten Ebene steht, dass Tree-Shaking in einem System möglich ist und in einem anderen nicht, unübereinstimmende Standardexporte sowie unterschiedliches Verhalten von zyklischen Importen – lässt sich auf eine einzige Ursache zurückführen: CommonJS erstellt sein Modulgraphen dynamisch, während das Programm läuft, während ESM seine Graphen statisch erstellt, noch bevor Code ausgeführt wird. Keiner dieser Ansätze ist ein Mangel im Design des anderen; beide beantworten dieselbe Frage – „Wie hängen Dateien voneinander ab?“ – doch mit völlig unterschiedlichen Einschränkungen und Garantien. Der Widerstand, den man beim Mischen beider Systeme verspürt, liegt nicht daran, dass Node fehlerhaft ist. Es handelt sich vielmehr um zwei intern konsistente Systeme, die gezwungen werden, an der Schnittstelle, an der sie aufeinandertreffen, miteinander zu kommunizieren.

Zusätzliche Literatur