Zwei Modulsysteme, eine Sprache
Ursprünglich hatte JavaScript überhaupt kein Modulsystem. Node hat die Lücke 2009 mit CommonJS geschlossen (require, module.exports), und jahrelang sah Node-Code genau so aus. 2015 bekam die Sprache dann ein eigenes, standardisiertes Modulsystem spendiert — ES Modules (import, export) —, das inzwischen sowohl Browser als auch Node unterstützen.
In freier Wildbahn begegnen dir deshalb beide Varianten. Hier ist dasselbe Minimodul einmal in jedem Stil:
Gleiche Funktion, nur anders verpackt. Auf den restlichen Abschnitten dieser Seite geht es darum, wann welche Verpackung zählt – und zu welcher du im Alltag greifen solltest.
Die Unterschiede in der Syntax
Die Unterschiede, die dir täglich begegnen, passen locker auf eine Postkarte:
// CommonJS
const fs = require("fs");
const { readFile } = require("fs/promises");
module.exports = something;
module.exports.name = value;
exports.name = value;
// ES Modules
import fs from "fs";
import { readFile } from "fs/promises";
export default something;
export const name = value;
export { name };
require ist ein ganz normaler Funktionsaufruf. import dagegen ist ein Statement – es darf nur auf der obersten Ebene eines Moduls stehen, und der Pfad muss ein String-Literal sein. Diese Einschränkung ist kein Zufall: Genau sie macht möglich, was ESM kann und CommonJS nicht.
Der eigentliche Unterschied: statisch vs. dynamisch
Bei CommonJS wird require() in dem Moment ausgewertet, in dem die Zeile ausgeführt wird. Du kannst den Aufruf also in ein if packen, den Pfad zur Laufzeit zusammenbauen oder ein Modul nur unter bestimmten Bedingungen laden:
ES Modules sind statisch. Bevor irgendein Code läuft, parst die Engine erst mal alle import-Anweisungen, baut daraus einen Abhängigkeitsgraphen und löst alles im Voraus auf. Genau deshalb muss der Pfad ein literaler String sein, und genau deshalb darf import nur auf oberster Ebene stehen.
Der Vorteil: Tools sehen den kompletten Modulgraph, ohne auch nur eine Zeile Code auszuführen. So machen Bundler Tree-Shaking (unbenutzte Exports fliegen raus), Editoren liefern dir zuverlässige Autovervollständigung, und der Browser kann Module parallel laden.
Wenn du in ESM wirklich mal dynamisch laden musst, nimmst du dynamisches import() — ein funktionsähnlicher Ausdruck, der ein Promise zurückgibt:
Wie Node entscheidet, welches Modulsystem eine Datei nutzt
Ein einzelnes Node-Projekt kann problemlos beide Modulsysteme gleichzeitig enthalten. Welches System Node für eine bestimmte Datei anwendet, macht es an zwei Dingen fest:
- An der Dateiendung:
.mjsist immer ESM,.cjsist immer CommonJS. - Am Feld
"type"in der nächstgelegenenpackage.json: Bei"module"werden.js-Dateien als ESM behandelt, bei"commonjs"(der Standard, wenn das Feld fehlt) als CJS.
// package.json
{
"name": "my-app",
"type": "module"
}
Mit "type": "module" verwendet eine normale hello.js im selben Package import/export. Legst du daneben eine hello.cjs ab, arbeitet genau diese eine Datei mit require. So lässt sich ein Projekt Schritt für Schritt migrieren – oder eine Library kann beide Varianten parallel ausliefern.
Typische Anfängerfalle: In einer ESM-Datei existieren require und module.exports schlicht nicht. Wer sie aus Gewohnheit trotzdem benutzt, fängt sich einen ReferenceError ein.
Interop: CommonJS und ES Modules mischen
Früher oder später musst du aus einer ESM-Datei ein CommonJS-Package einbinden – oder andersherum. Die Regeln dafür sind nicht symmetrisch.
ESM importiert CommonJS funktioniert direkt. Das module.exports-Objekt aus CJS landet als Default-Export:
// app.mjs
import greet from "./greet.cjs";
console.log(greet("Rosa"));
Named Imports aus CommonJS klappen manchmal – Node versucht, benannte Exports statisch zu erkennen –, aber verlässlich wird's erst, wenn du den Default holst und destrukturierst:
import pkg from "./utils.cjs";
const { parse, stringify } = pkg;
CommonJS, das ESM einbindet — das ist die schmerzhafte Richtung. Ein ES-Modul per require() zu laden geht nicht, das wirft sofort ERR_REQUIRE_ESM. Der rettende Ausweg ist das dynamische import(), das auch in CJS funktioniert und ein Promise zurückgibt:
Moderne Node-Versionen (22+) haben unter bestimmten Bedingungen ein synchrones require() für ESM eingeführt – portabel ist aber nach wie vor das dynamische import().
Weitere Verhaltensunterschiede, die du kennen solltest
Abseits der Syntax unterscheiden sich die beiden Systeme noch in ein paar Details, die einen gelegentlich auf dem falschen Fuß erwischen:
Noch ein paar Punkte:
thisauf oberster Ebene. In CJS zeigtthisaufmodule.exports. In ESM ist esundefined– ESM läuft grundsätzlich im Strict Mode.__dirnameund__filename. In CJS bekommst du beide geschenkt. In ESM musst du sie dir ausimport.meta.urlselbst ableiten:
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
- Dateiendungen bei Imports. ESM verlangt bei relativen Pfaden die Endung (
"./utils.js", nicht"./utils"). CJS ist da nachsichtiger. - Live Bindings statt Snapshots. ESM-Imports sind lebende Referenzen auf die Variablen des exportierenden Moduls. CJS gibt dir dagegen eine Kopie dessen, was zum Ladezeitpunkt in
module.exportsstand. Den meisten Code interessiert das nicht – bei zirkulären Abhängigkeiten wird es aber relevant.
Welches System solltest du nehmen?
Für ein neues Projekt: ES Modules. Setz "type": "module" in der package.json und gut ist. ESM ist der Sprachstandard, funktioniert in Browser und Node gleich, unterstützt Top-Level-await und das gesamte Tooling ist darauf ausgelegt.
Bei CommonJS bleibst du, wenn:
- du eine bestehende CJS-Codebasis pflegst und die Migration den Aufwand (noch) nicht wert ist.
- du eine Library veröffentlichst, die sehr alte Node-Versionen oder Konsumenten ohne ESM-Support bedienen muss.
- eine zentrale Dependency nur CJS ausliefert und der Interop-Teil unsauber ist. (Heute selten, kommt aber vor.)
Selbst dann wirst du ständig ESM-Code lesen – alles, was in den letzten Jahren auf npm veröffentlicht wurde, geht in diese Richtung. An beiden Welten vorbeizukommen ist keine Option; fließend in den Idiomen der Welt zu sein, in der du gerade schreibst, schon.
Eine kurze mentale Checkliste
Wenn du eine neue Datei öffnest, frag dich:
- Verwendet diese Datei
import/exportoderrequire/module.exports? Nicht mischen. - Was sagt die nächstgelegene
package.jsonzum Feld"type"? - Wenn du ein Paket importierst: Wirf einen Blick in dessen
package.json– liefert es ESM, CJS oder beides? - Kommt ein
ERR_REQUIRE_ESM, bist du in CJS und versuchst ESM zu laden. Wechsel auf dynamischesimport()oder zieh den Aufrufer nach ESM um.
Neunzig Prozent aller Modul-Verwirrungen in Node fallen in eine dieser vier Kategorien.
Als Nächstes: npm-Grundlagen
Module sind der Weg, um deinen eigenen Code auf mehrere Dateien aufzuteilen. Im nächsten Schritt holst du dir Code von anderen Leuten ins Projekt – genau dafür ist npm da. Wir schauen uns an, wie du Pakete installierst, was es mit semver-Ranges auf sich hat und welche Teile des npm-Workflows du im Alltag wirklich brauchst.
Häufig gestellte Fragen
Was ist der Unterschied zwischen require und import in JavaScript?
require ist die CommonJS-Variante: synchron, wird genau an der Stelle ausgeführt, an der sie steht, und gibt zurück, was das Modul an module.exports gehängt hat. import gehört zu den ES Modules — statisch, wird an den Anfang der Datei gehoben und schon vor der Ausführung analysiert. Dazu kommen Unterschiede bei this, beim Auflösen zyklischer Abhängigkeiten und beim Top-Level-await, das nur ESM erlaubt.
CommonJS oder ES Modules für ein neues Node-Projekt?
Nimm ES Modules. Trag einfach "type": "module" in die package.json ein und arbeite mit import/export. ESM ist der offizielle Standard, läuft im Browser wie in Node und unterstützt Top-Level-await. CommonJS wirst du trotzdem lesen müssen — in älteren Paketen und Tooling ist es nach wie vor überall.
Kann ich require und import im selben Projekt mischen?
Ja, aber mit klaren Regeln. Eine .mjs-Datei oder ein Paket mit "type": "module" läuft als ESM, .cjs oder "type": "commonjs" als CJS. Aus ESM heraus kannst du ein CommonJS-Modul ganz normal importen — module.exports landet dann als Default-Export. Umgekehrt geht das nicht direkt: CommonJS kann ESM nicht per require() laden, du brauchst das dynamische import(), und das liefert ein Promise zurück.