Ein Modul ist eine Datei mit eigenem Scope
Vor den ES Modules hat jeder <script>-Tag seine Variablen einfach in den globalen Namespace gekippt, und die Ladereihenfolge hat entschieden, wer was zu sehen bekommt. ES Modules räumen damit auf: Jede Datei bekommt ihren eigenen Scope. Nichts dringt nach außen, solange du es nicht ausdrücklich per export freigibst. Und nichts kommt herein, solange du es nicht per import explizit holst.
Zwei Dateien – eine exportiert, die andere importiert:
add und multiply leben in math.js. Sichtbar werden sie in main.js nur durch das import. Alles andere in math.js – interne Helfer, Konstanten, was auch immer – bleibt von außen unerreichbar.
Daraus ergeben sich zwei Regeln, die man sich früh einprägen sollte:
- Module laufen automatisch im Strict Mode. Ein
'use strict'ist überflüssig. thisauf oberster Ebene istundefined, nicht das globale Objekt.
Named Exports: direkt beim Schreiben exportieren
Die gängigste Variante. Einfach export vor eine function, class, const oder let setzen – und schon gehört das Ganze zur öffentlichen Schnittstelle des Moduls:
Die Namen in den geschweiften Klammern müssen exakt mit den exportierten Namen übereinstimmen – import { circlearea } würde hier scheitern. Wenn ein Name mit etwas kollidiert, das du bereits im Scope hast, kannst du ihn beim Import mit as umbenennen:
Du kannst deine Exporte auch gesammelt am Ende der Datei auflisten, statt sie direkt inline zu schreiben – manche bevorzugen das, weil man so auf einen Blick die "öffentliche API" des Moduls sieht:
Beide Schreibweisen führen zum selben Ergebnis. Entscheide dich im Projekt für eine Variante und bleib dabei.
Default Export: nur einer pro Modul
Ein Modul kann zusätzlich einen einzigen Default-Export haben. export default eignet sich für Dateien, die wirklich eine zentrale Sache liefern – eine Komponente, eine Klasse oder ein Konfigurationsobjekt:
Drei Dinge sind hier wichtig:
- Keine geschweiften Klammern um
logbeim Import. - Der importierte Name ist frei wählbar.
import shout from './logger.js'würde genauso funktionieren. - Pro Datei ist nur ein einziger Default-Export erlaubt. Sobald du einen zweiten hinzufügst, lässt sich die Datei nicht mehr parsen.
Named Exports und Default Export lassen sich übrigens problemlos kombinieren:
Zuerst der Default, danach die Named Imports in geschweiften Klammern. Diese Reihenfolge ist fix.
Was nimmt man also? Named Exports lassen sich leichter refactoren – ein Umbenennen quer durch die Codebase ist mit einem einzigen Find-and-Replace erledigt, weil jeder Import denselben Namen verwendet. Default Exports sind zwar flexibler, erlauben aber jedem Aufrufer, einen eigenen Namen zu vergeben, was die Suche per grep erschwert. Die meisten modernen Style Guides empfehlen daher Named Exports und reservieren den Default Export für Module mit wirklich nur einer Aufgabe.
Alles importieren, Re-Exports und Side Effects
Es gibt noch ein paar weitere Import-Varianten, die dir regelmäßig begegnen.
So packst du alle Named Exports in ein einziges Namespace-Objekt:
Re-Export aus einem anderen Modul, ohne den Import in den aktuellen Scope zu übernehmen:
Genau dieses Muster nutzen Bibliotheken am Einstiegspunkt, um einzelne Bausteine aus internen Dateien zu einer einheitlichen öffentlichen Schnittstelle zusammenzuführen.
Und zum Schluss noch ein Import ganz ohne Bindings – gedacht für Module, deren einziger Zweck Seiteneffekte sind (Polyfills, CSS-in-JS, das Registrieren von Handlern):
Die Datei wird einmal ausgeführt; namentlich wird nichts importiert.
Imports sind statisch und live
Zwei Eigenschaften von import, die manchmal für Überraschung sorgen.
Statisch. import-Deklarationen werden aufgelöst, bevor auch nur eine Zeile deines Codes läuft. Du kannst sie also nicht in ein if, eine Funktion oder ein try packen. Auch der Pfad muss ein String-Literal sein — keine Variable. Genau dadurch können Tools die Imports analysieren, ohne den Code tatsächlich auszuführen: Bundler, Type-Checker und Tree-Shaker bauen alle darauf auf.
// Nicht erlaubt — SyntaxError.
if (userWantsFancy) {
import { fancy } from './fancy.js';
}
Wenn du Module nur bedingt laden willst, nimm import() – das schauen wir uns gleich an.
Live-Binding. Ein Import ist keine Kopie, sondern eine schreibgeschützte Referenz auf den Export. Weist das exportierende Modul den Wert neu zu, sehen alle importierenden Module sofort den neuen Wert:
Auf der Consumer-Seite lässt sich ein Import ebenfalls nicht neu zuweisen — count = 5 in main.js würde einen Fehler werfen. Importe sind schreibgeschützte Sichten auf den Originalwert.
Dynamischer Import mit import() – Module bei Bedarf laden
Wenn du erst zur Laufzeit entscheiden willst, ob ein Modul geladen wird — etwa für umfangreiche Features, Code-Splitting nach Routen oder bedingte Polyfills — nutzt du import() wie eine Funktion. Sie gibt ein Promise zurück, das mit den Exports des Moduls aufgelöst wird:
Weil es ein ganz normaler Funktionsaufruf ist, kannst du:
- ihn in einer
async-Funktion mitawaitnutzen. - eine Variable als Pfad übergeben.
- ihn in einem
ifodertry/catchverwenden.
Das Destructuring des aufgelösten Objekts funktioniert genauso wie bei einem statischen Import:
default ist der Schlüssel für den Default-Export, wenn du destrukturierst. Benenne ihn um, wie du willst.
Typische Einsatzszenarien sind Code-Splitting (die Chart-Bibliothek wird erst geladen, wenn der Nutzer auf „Diagramm anzeigen" klickt), Feature-Detection-Polyfills und Plugins, die erst zur Laufzeit bekannt werden.
ES Modules ausführen: Browser und Node.js
Die Syntax ist überall gleich – was sich unterscheidet, ist die Art und Weise, wie die Laufzeitumgebung die Datei findet und lädt.
Im Browser kennzeichnest du das Einstiegsskript als Modul:
<script type="module" src="./main.js"></script>
Mit type="module" respektiert der Browser import und export, führt den Code im Strict Mode aus und wartet mit der Ausführung, bis das HTML geparst ist. Die Pfade müssen dabei relativ (./, ../) oder absolute URLs sein — sogenannte Bare Specifier wie import 'lodash' funktionieren ohne Import Map oder Bundler schlichtweg nicht.
In Node.js hast du zwei Möglichkeiten, ES Modules zu aktivieren:
- Die Datei mit der Endung
.mjsversehen, oder - In der nächstgelegenen
package.jsonden Eintrag"type": "module"setzen — damit wird jede.js-Datei automatisch als Modul behandelt.
Außerdem besteht Node auf vollständige Pfade inklusive Dateiendung: Also import './utils.js' statt import './utils'.
// package.json
{
"type": "module",
"main": "./index.js"
}
Beide Umgebungen verlangen bei nativen ESM-Modulen explizite Dateiendungen. Bundler wie Vite, webpack oder esbuild lösen Pfade ohne Endung zwar während der Entwicklung auf – das ist bequem, heißt aber auch: Dein Quellcode läuft nicht mehr ohne den Build-Schritt.
Typische Stolperfallen
Ein paar Dinge, über die viele stolpern:
type="module"im Browser vergessen. Ohne dieses Attribut wird<script>als klassisches Skript ausgeführt, undimportist dort ein Syntaxfehler.- Fehlende Dateiendungen in Node.
import './utils'schlägt fehl,import './utils.js'funktioniert. Bundler verstecken das, native Runtimes nicht. __dirnameoderrequirein einem ES-Modul erwarten. Beides gibt es nur in CommonJS. In ESM nimmst du stattdessenimport.meta.urlund wandelst es bei Bedarf in einen Pfad um.- Zirkuläre Imports, die auf Werte zugreifen, bevor sie bereit sind. Zwei Module, die sich gegenseitig importieren, sind erlaubt – aber wenn du einen Export liest, der noch nicht zugewiesen wurde, bekommst du
undefined. Strukturiere deinen Code so, dass der Zyklus während der Initialisierung nicht durchlaufen wird, oder zerlege ihn. - Versuchen,
importbedingt auszuführen. Das statischeimport-Statement lässt das nicht zu. Für alles, was zur Laufzeit entschieden wird, nutzt du den dynamischenimport().
Als Nächstes: CommonJS vs. ESM
ES Modules sind der Standard, doch eine Menge Node-Code da draußen setzt weiterhin auf CommonJS – also require, module.exports und einen anderen Satz an Regeln dazu, wann Code tatsächlich ausgeführt wird. Beide Welten zu kennen und zu verstehen, wie sie zusammenarbeiten, ist das Thema der nächsten Seite.
Häufig gestellte Fragen
Wie nutze ich ES Modules in JavaScript?
Du stellst Werte aus einer Datei per export oder export default bereit und holst sie in einer anderen Datei mit import rein. Im Browser lädst du die Einstiegsdatei über <script type="module" src="main.js"></script>. In Node entweder die Endung .mjs verwenden oder in der package.json "type": "module" setzen.
Was ist der Unterschied zwischen default und named exports?
Ein Modul kann beliebig viele named exports haben (export function foo() {}), aber nur einen einzigen default export (export default ...). Named exports musst du mit exakt dem gleichen Namen in geschweiften Klammern importieren: import { foo } from './x.js'. Einen default export kannst du dagegen unter jedem beliebigen Namen importieren: import whatever from './x.js'.
Was ist dynamisches import() in JavaScript?
import() als Funktion aufgerufen gibt ein Promise zurück, das mit den Exports des Moduls resolved. Anders als das statische import-Statement läuft es erst zur Laufzeit — du kannst Code also bedingt oder bei Bedarf nachladen. Genau so setzt man Code-Splitting und Lazy Loading um.
Muss ich in import-Pfaden die Dateiendung angeben?
In nativen ES Modules — also im Browser und im ESM-Loader von Node — ja. Du musst ./utils.js schreiben, nicht ./utils. Bundler wie Vite oder webpack sind da entspannter und lösen Pfade auch ohne Endung auf, aber verlässt du dich darauf, ist dein Code nicht mehr portabel.