Zwei Modulwelten

import oder require: wer entscheidet das eigentlich?

Die Endung und das Feld type in der nächstgelegenen package.json legen fest, wie Node deine Datei liest. Alles Weitere folgt daraus, auch die Fehlermeldungen.

KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Seit 1997 am Markt Kleine Gruppen Präsenz und Live-Online Zertifizierte Trainer
Worum es geht

Die Fehlermeldung nennt fast nie die eigentliche Ursache

Vier Meldungen decken den größten Teil der Fälle ab, und alle vier sagen dasselbe: Die Datei wird in einem anderen Format gelesen, als sie geschrieben ist. Cannot use import statement outside a module heißt, dass Node deine Datei als CommonJS liest. Unexpected token 'export' ist dieselbe Sache aus Sicht des Parsers. exports is not defined in ES module scope heißt umgekehrt, dass eine CommonJS-Datei als ESM gelesen wird, meist weil in der package.json type auf module steht und die Endung .cjs fehlt. Und ERR_REQUIRE_ESM bedeutet, dass ein require auf ein reines ESM-Paket trifft.

Weil die Meldungen nach Syntaxfehlern klingen, wird an der falschen Stelle gesucht. Typisch ist die Runde aus Endung ändern, Babel ergänzen, tsconfig anfassen und am Ende ein Werkzeug einbauen, das die Datei vorher umschreibt. Danach läuft es, aber niemand kann mehr sagen, welches Format wo gilt, und der nächste Umbau beginnt wieder von vorn.

Richtig teuer wird die Frage erst an der Paketgrenze. Immer mehr Bibliotheken erscheinen nur noch als ESM, während Testwerkzeuge, ältere Server und interne Pakete auf CommonJS stehen. Wer das Projekt deshalb halb umstellt, hat am Ende beide Formate im selben Prozess, und damit möglicherweise zweimal dasselbe Modul mit zwei getrennten Zuständen.

KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt
Der direkte Vergleich

Die beiden Formate und der Weg dazwischen

Die Gegenüberstellung lohnt sich weniger für die Frage, welches Format besser ist, sondern für die Frage, was dich der Wechsel im Alltag kostet. Genau diese Punkte sind es, an denen bestehende Projekte hängen bleiben.

ES Modules

der Standard der Sprache mit import und export, in Browsern und in Node zu Hause

CommonJS

das gewachsene Node-Format mit require und module.exports, synchron und zur Laufzeit auflösbar

Beides aus einer Quelle

ein Paket, das über das exports-Feld je nach Aufrufer die eine oder die andere Fassung liefert

Wie kommst du an eine Bibliothek, die es nur noch als ESM gibt?

ES Modules

Ein gewöhnlicher import, mehr ist nicht nötig. Das ist inzwischen der häufigere Fall bei neu veröffentlichten Paketen.

CommonJS

Seit Node 22.12 und, auf der Vorgängerlinie, seit Node 20.19 kann require auch ES-Module laden, ohne zusätzliches Flag, allerdings nur, wenn im gesamten Modulgraph kein await auf oberster Ebene steht, sonst kommt ERR_REQUIRE_ASYNC_MODULE. Der ältere und für noch ältere Laufzeiten sichere Weg ist ein dynamisches import(), das den Aufrufer asynchron macht.

Beides aus einer Quelle

Der Paketautor hat die Frage bereits beantwortet, jeder Aufrufer bekommt über die Bedingungen im exports-Feld die passende Fassung.

Kannst du erst zur Laufzeit entscheiden, was geladen wird?

ES Modules

import-Anweisungen werden vor der Ausführung aufgelöst und stehen immer ganz oben. Für bedingtes Laden gibt es import(), das ein Promise zurückgibt und damit eine asynchrone Stelle erzeugt.

CommonJS

require ist ein gewöhnlicher Funktionsaufruf, darf in einem if stehen, und das Ergebnis liegt sofort vor. Für Fallunterscheidungen nach Plattform oder Konfiguration ist das bequem.

Beides aus einer Quelle

Es gilt die Regel des Formats, in dem der Aufrufer läuft. Für dich als Paketautor ändert sich dadurch nichts, für deine Nutzer schon.

Wie gut entfernt ein Bundler ungenutzten Code?

ES Modules

Import und Export stehen statisch im Code, deshalb kann das Werkzeug ohne Ausführung feststellen, welcher Export nirgends verwendet wird.

CommonJS

module.exports wird zur Laufzeit zusammengesetzt und kann von Bedingungen abhängen. Der Bundler muss im Zweifel alles behalten, deshalb landen ganze Bibliotheken im Build.

Beides aus einer Quelle

Nur wenn die Bedingung import auf echtes ESM zeigt. Ein Paket, das unter dieser Bedingung übersetztes CommonJS ausliefert, bringt für das Entfernen ungenutzten Codes nichts.

Was passiert bei zirkulären Abhängigkeiten?

ES Modules

Die Bindungen sind live, Funktionsdeklarationen stehen bereits vor der Auswertung zur Verfügung. Greift der Code beim Auswerten auf eine noch nicht initialisierte Konstante zu, gibt es einen ReferenceError, also einen sichtbaren Fehler an der richtigen Stelle.

CommonJS

Der Zirkel liefert das halbfertige module.exports, meist ein leeres Objekt. Der Fehler zeigt sich später als undefined is not a function, oft in einer ganz anderen Datei.

Beides aus einer Quelle

Es gilt die Regel des jeweiligen Formats, zusätzlich kann derselbe Modulzustand doppelt existieren, wenn beide Fassungen im Prozess landen.

Wie viel Aufwand steckt in Test- und Bauwerkzeugen?

ES Modules

Vite, Vitest, Rollup und esbuild arbeiten von Haus aus damit, in einem gewachsenen Aufbau rund um Jest ist es dagegen die teuerste Ecke: Der ESM-Betrieb läuft dort über die Option experimental-vm-modules, und ein Import lässt sich nachträglich nicht umbiegen, das Ersetzen von Modulen im Test muss also anders gelöst werden als mit require.

CommonJS

Der über Jahre gewachsene Werkzeugkasten arbeitet ohne Zutun damit, inklusive dem Leeren des Modul-Caches im Test. Dafür fehlen zunehmend Pakete, die es nur noch als ESM gibt.

Beides aus einer Quelle

Für jede Kombination aus Werkzeug und Umgebung musst du prüfen, welche Fassung tatsächlich geladen wird. Genau hier entsteht der doppelte Zustand, der später schwer zu finden ist.

Was steht dir an Sprachmitteln zur Verfügung?

ES Modules

await auf oberster Ebene, import.meta.url für den eigenen Pfad, live gehaltene Bindungen und durchgehend strikter Modus ohne eigenes Zutun.

CommonJS

__dirname, __filename, require.resolve und ein zugänglicher Modul-Cache, dafür kein await auf oberster Ebene und keine live gehaltenen Bindungen.

Beides aus einer Quelle

Du musst dich auf den kleinsten gemeinsamen Nenner beschränken, sonst funktioniert eine der beiden Fassungen nicht.

Was passt wann

Wenn du ein neues Projekt aufsetzt
nimm ESM, setz type auf module und schreib in relativen Importen die Dateiendung konsequent mit.
Wenn eine gewachsene Node-Anwendung stabil läuft und nur ein einzelnes ESM-Paket fehlt
hol genau dieses Paket über ein dynamisches import(), statt das ganze Projekt umzustellen.
Wenn du eine Bibliothek veröffentlichst, die andere in beiden Welten einbinden
beschreib die Einstiegspunkte im exports-Feld und halte Zustand aus den Modulen heraus, damit zwei geladene Fassungen nicht auseinanderlaufen.

Fünf Prüfungen, und das Format steht fest

  1. 01 Endet die Datei auf .mjs oder .cjs? Dann ist die Frage schon beantwortet.
  2. 02 Sonst zählt das Feld type in der nächstgelegenen package.json.
  3. 03 Fehlt dieses Feld, behandelt Node die Datei als CommonJS.
  4. 04 Im Browser entscheidet allein das Attribut type am script-Element.
  5. 05 Bundler lesen dieselben Felder und zusätzlich die Bedingungen im exports-Feld.
Was du mitnimmst

Was du danach ohne Ausprobieren entscheiden kannst

Die Entscheidung hängt an drei Fragen: Was liest Node in deinem Verzeichnis, was verlangen deine Abhängigkeiten, und wer bindet dein Ergebnis später ein. Sind die beantwortet, ist die Wahl des Formats keine Geschmacksfrage mehr, sondern ergibt sich.

Das geltende Format bestimmen

Die nächstgelegene package.json oberhalb der Datei entscheidet über alle .js-Dateien darunter. Ein type auf module macht sie zu ES-Modulen, ein fehlendes Feld zu CommonJS. Die Endungen .mjs und .cjs stechen diese Regel in beide Richtungen.

Eine einzelne ESM-Abhängigkeit einbinden

In einer CommonJS-Anwendung holst du ein reines ESM-Paket mit einem dynamischen import(), das ein Promise liefert. Das macht nur die aufrufende Funktion asynchron, nicht das ganze Projekt.

Die Umstellung an einer Grenze führen

Ein halb umgestelltes Projekt ist teurer als beide Reinformen. Stell lieber ein abgegrenztes Paket vollständig um und lass den Rest, als überall einzelne Dateien zu wechseln.

Importe vollständig schreiben

In ES-Modulen brauchen relative Importe die Dateiendung, und ein Verzeichnisname wird nicht automatisch zur index-Datei ergänzt. Bundler sind hier nachsichtig, Node ist es nicht, und genau daran scheitert die erste Ausführung außerhalb des Bundlers.

Den Ersatz für __dirname kennen

In ES-Modulen gibt es weder __dirname noch __filename noch require.resolve. Neuere Node-Versionen bieten dafür import.meta.dirname und import.meta.filename, sonst führt der Weg über fileURLToPath(import.meta.url).

Beim Veröffentlichen die Einstiegspunkte festlegen

Das exports-Feld beschreibt, welche Datei ein Aufrufer je nach Format bekommt, und sperrt gleichzeitig alle nicht aufgeführten Unterpfade. Wer beide Formate ausliefert, hält Zustand aus den Modulen heraus, damit zwei geladene Fassungen nicht auseinanderlaufen.

KI-generiertDieses Bild wurde mit KI erzeugt · Yves Hoppe / KI / cmt

Wer entscheidet, was deine Datei ist

Node beantwortet diese Frage in zwei Schritten. Zuerst die Endung: .mjs ist immer ein ES-Modul, .cjs immer CommonJS. Für .js gilt die nächstgelegene package.json oberhalb der Datei. Steht dort type mit dem Wert module, sind alle .js-Dateien darunter ES-Module, sonst CommonJS. Das Feld wirkt also verzeichnisweise, und ein Unterverzeichnis mit eigener package.json kann eine andere Antwort geben als das Projekt darüber.

Genau das ist der übliche Ausweg für Werkzeugdateien: eine kleine package.json mit type auf commonjs in einem Unterverzeichnis, damit eine alte Konfigurationsdatei weiterläuft, während der Rest bereits umgestellt ist. Sauberer ist die Endung .cjs für einzelne Dateien, weil sie im Dateibaum sofort sichtbar ist.

Bei TypeScript kommt eine zweite Ebene dazu. Was der Compiler ausgibt, hängt an module und moduleResolution, und mit den Endungen .mts und .cts lässt sich dasselbe pro Datei festlegen. Wichtig ist die Unterscheidung: Der Compiler prüft nur, die Laufzeit entscheidet. Wer in der tsconfig auf ESM stellt, aber ohne type auf module ausliefert, bekommt genau dieselben Meldungen wie ohne TypeScript.

Die Richtung, die lange nicht funktioniert hat

Aus einem ES-Modul heraus lässt sich CommonJS schon immer laden. import fs from 'node:fs' liefert dabei module.exports als Standardexport. Benannte Importe funktionieren ebenfalls, aber nur, weil Node die Datei vorher statisch nach Zuweisungen an exports durchsucht. Baut ein Paket seine Exporte in einer Schleife oder hinter einer Bedingung auf, findet diese Analyse nichts, und du musst den Standardexport importieren und die Eigenschaften daraus entnehmen.

Die Gegenrichtung war jahrelang versperrt. Seit Node 22.12 und, auf der Vorgängerlinie, seit Node 20.19 lädt require auch ES-Module ohne zusätzliches Flag, sofern im gesamten Modulgraph kein await auf oberster Ebene vorkommt, andernfalls kommt ERR_REQUIRE_ASYNC_MODULE. Zurück bekommst du das Namensraumobjekt des Moduls, der Standardexport liegt also unter der Eigenschaft default und nicht direkt auf dem Rückgabewert. Wer noch ältere Stände bedienen muss, bleibt beim dynamischen import().

Für den Alltag ergibt sich daraus eine einfache Reihenfolge. Prüf zuerst, ob das Paket überhaupt eine CommonJS-Fassung mitbringt, denn dann erübrigt sich alles Weitere. Wenn nicht, nimm ein dynamisches import() an genau einer Stelle und reich das Ergebnis weiter, statt es an zwanzig Stellen zu wiederholen. Und wenn beides nicht geht, ist das ein Argument für die Umstellung des betroffenen Pakets, nicht für einen Umbau des gesamten Projekts.

Was sich am eigenen Code ändert

Der erste Unterschied betrifft die Pfade. In ES-Modulen sind Dateiendungen in relativen Importen Pflicht, und ein Import auf ein Verzeichnis wird nicht automatisch zur index-Datei ergänzt. Bundler sind an dieser Stelle großzügig, weshalb der Fehler oft erst auftritt, wenn dasselbe Modul zum ersten Mal ohne Bundler läuft, etwa in einem Testlauf oder in einem Skript.

Der zweite Unterschied betrifft die Umgebung des Moduls. __dirname, __filename, require und require.resolve gibt es nicht, dafür import.meta.url und in neueren Node-Versionen import.meta.dirname sowie import.meta.filename. this ist auf oberster Ebene undefined statt module.exports, und der strikte Modus gilt immer. JSON-Dateien lassen sich nicht mehr einfach importieren, sondern brauchen ein Importattribut in der Form with { type: 'json' }.

Der dritte Unterschied ist der subtilste: ES-Module halten ihre Bindungen live. Exportiert ein Modul eine Zählvariable und erhöht sie später, sehen alle Importeure den neuen Wert. In CommonJS bekommst du beim require eine Momentaufnahme des Objekts, und eine später neu zugewiesene Variable erreicht dich nicht mehr. Genau daran scheitern Umstellungen, bei denen ein Modul einen veränderlichen Wert exportiert statt einer Funktion, die ihn liefert.

Das exports-Feld und der doppelte Zustand

Das exports-Feld einer package.json beschreibt, welche Datei ein Aufrufer bekommt, und zwar getrennt nach Bedingungen: import für ES-Module, require für CommonJS, types für die Typdeklarationen, default als Auffangfall. Die Reihenfolge ist bedeutsam, denn die erste passende Bedingung gewinnt. types gehört deshalb nach vorn, default immer ans Ende.

Das Feld hat eine zweite, oft überraschende Wirkung: Sobald es existiert, sind alle nicht aufgeführten Unterpfade gesperrt. Ein Import auf paket/lib/hilfsmittel.js scheitert dann mit Package subpath is not defined by exports, obwohl die Datei im Verzeichnis liegt. Das ist der häufigste Grund dafür, dass ein Update ohne erkennbare Änderung am Code plötzlich nichts mehr findet.

Wer beide Formate ausliefert, handelt sich den doppelten Zustand ein. Landen im selben Prozess sowohl die ESM- als auch die CommonJS-Fassung eines Pakets, existiert jede Klasse zweimal. Ein instanceof schlägt dann fehl, obwohl das Objekt aussieht wie erwartet, und ein Modul, das intern eine Registrierung oder einen Zwischenspeicher hält, führt zwei getrennte davon. Die praktikablen Auswege: nur ein Format ausliefern, oder den Zustand in ein eigenes CommonJS-Paket legen, das beide Fassungen gemeinsam nutzen.

Im Browser gelten eigene Regeln

Ein script-Element mit type="module" ist automatisch verzögert, läuft also erst nach dem Aufbau des Dokuments, hat einen eigenen Gültigkeitsbereich und ist immer strikt. Ein zweites Skript sieht die Variablen des ersten nicht, was im Vergleich zu klassischen Skripten regelmäßig überrascht.

Der Browser kennt außerdem keine bloßen Paketnamen. import { z } from 'lodash-es' funktioniert nur, wenn ein Bundler den Namen vorher in einen Pfad übersetzt oder wenn eine Import Map im Dokument die Zuordnung festlegt. Ohne beides bekommst du eine Meldung über einen ungültigen Specifier, und daran ändert auch ein laufender Entwicklungsserver nichts, der intern genau diese Übersetzung vornimmt.

Und schließlich gelten die Regeln der gleichen Herkunft: Module werden mit CORS geladen, ein Modul von einer fremden Adresse braucht also die passenden Header. Für das gezielte Nachladen von Programmteilen ist import() im Browser dasselbe Werkzeug wie in Node, nur dass es dort zusätzlich die Grenze für ein eigenes Bündel markiert.

Dazu passende Kurse

Wie sich das im Bestand umstellen lässt, ohne den Betrieb anzuhalten, ist Thema der JavaScript-Kurse bei cmt, dort geht es unter anderem um import gegen require im laufenden Projekt .

Weil der TypeScript-Compiler mitentscheidet, welches Format am Ende ausgeliefert wird, gehören diese Einstellungen in Kurse zu Typen, Konfiguration und Ausgabeformaten .

Wissen prüfen

Wie sicher bist du beim Thema wirklich?

Lesen fühlt sich schnell nach Können an. Ein kurzer Test zeigt dir, was davon schon sitzt und wo sich ein Kurs lohnt. Kostenlos, ohne Anmeldung, mit einer Erklärung zu jeder Antwort.

Ein sehr gutes, praxisorientiertes und nachhaltiges Seminar. So sollte es immer sein. Vielen Dank.
LimeSurvey - Anwendertraining Teil 1 (Grundlagen)
Die Schulung ist gerade für Einsteiger oder Entwickler*innen mit eingestaubtem Basiswissen sehr hilfreich.
Vue.js 3 Grundkurs
Ich bin sehr glücklich Franz als Trainer gehabt zu haben. Er hat offensichtlich unglaublich viel Wissen zu den Themen, und schafft es, dieses auch interaktiv und verständlich weiterzugeben.
React Komplettausbildung

Häufige Fragen

Muss ich die Endung .js in Importen wirklich mitschreiben?
In ES-Modulen unter Node ja, bei relativen Pfaden ist sie Pflicht, und ein Verzeichnis wird nicht automatisch zur index-Datei ergänzt. Bundler ergänzen beides stillschweigend, und genau deshalb fällt der Unterschied erst auf, wenn dieselbe Datei einmal ohne Bundler ausgeführt wird.
Ich schreibe TypeScript. Betrifft mich das überhaupt?
Ja, denn TypeScript entscheidet nur, was ausgegeben wird, und die Laufzeit entscheidet, wie das Ergebnis gelesen wird. Passen module in der tsconfig und type in der package.json nicht zusammen, bekommst du dieselben Meldungen wie in einem Projekt ohne TypeScript. Mit verbatimModuleSyntax bleibt wenigstens sichtbar, was der Compiler tatsächlich erzeugt.
Was ist der schnellste Weg, ein ESM-Paket in eine bestehende CommonJS-Anwendung zu holen?
Ein dynamisches import() innerhalb einer async-Funktion, dessen Ergebnis du an einer Stelle zwischenspeicherst. Damit bleibt der Rest des Projekts unverändert. Ab Node 22.12 und 20.19 geht auch ein direktes require, solange im Modulgraph kein await auf oberster Ebene steht, und der Standardexport liegt dann unter default statt direkt auf dem Rückgabewert.
Warum finde ich nach einem Update Unterpfade eines Pakets nicht mehr?
Weil das Paket ein exports-Feld bekommen hat. Damit sind alle Pfade gesperrt, die dort nicht aufgeführt sind, auch wenn die Dateien weiterhin ausgeliefert werden. Der richtige Weg ist der offizielle Einstiegspunkt, im Notfall ein Eintrag über die Bedingungen des Pakets beim Paketautor.
Persönlich für dich da

Deine Ansprechpartner

Du bist dir nicht sicher, welcher Kurs oder welches Level zu dir passt? Wir beraten dich persönlich und kostenlos.

Yves Hoppe

Yves Hoppe

Weiterbildung & Beratung

Hilft dir, aus dem Webentwicklung-Programm den passenden Kurs für deinen Stand zu finden.

Norbert Jansen

Norbert Jansen

Beratung & Inhouse

Plant mit dir Inhouse-Trainings, die auf eure Abläufe und euren Datenbestand zugeschnitten sind.

Module, die in jeder Umgebung dasselbe bedeuten

In den JavaScript- und Node-Kursen bei cmt stellst du ein bestehendes Projekt Schritt für Schritt um und siehst dabei, an welchen Stellen die beiden Formate tatsächlich auseinandergehen.