Von CommonJS zu ESM: Warum JavaScript-Module endlich erwachsen werden

JavaScript Module 2025: Der große Wechsel von require() zu import/export
Abstract
- #JavaScript Module
- #ESM
- #CommonJS
- #Node.js
- #TypeScript
- #Modul-Systeme
- #Modul-Migration
- #JavaScript Best Practices
- #Softwareentwicklung
- #Webentwicklung
ESM vs CommonJS: Praktischer Wegweiser für moderne JavaScript-Entwicklung
Stell dir vor, du arbeitest seit Jahren mit JavaScript und plötzlich funktioniert dein bewährtes require()
nicht mehr. Genau das passiert vielen Entwicklern gerade: Die JavaScript-Welt wandelt sich von CommonJS zu ECMAScript Modules (ESM), und diese Transformation bringt sowohl Chancen als auch Herausforderungen mit sich.
In diesem Artikel schauen wir uns an, wie diese Modul-Evolution entstanden ist, warum Node.js ursprünglich auf CommonJS gesetzt hat und wie der Wechsel zu ESM unsere tägliche Entwicklungsarbeit verändert. Du erfährst praktische Tipps für die Migration, lernst typische Stolpersteine kennen und verstehst, warum ESM die Zukunft der JavaScript-Entwicklung ist.
Die Entstehungsgeschichte: Von Chaos zur Struktur
Warum Node.js CommonJS gewählt hat
Zurück im Jahr 2009 gab es in JavaScript schlichtweg keine standardisierte Modul-Syntax. Die Node.js-Entwickler standen vor einem Problem: Wie organisiert man serverseitigen Code sinnvoll, ohne dass alles in einem globalen Namensraum landet?
Die Lösung war CommonJS - ein aufkommender Standard, der mit require()
und module.exports
eine einfache, synchrone Herangehensweise bot. Perfekt für Server-Umgebungen, wo Module von der Festplatte geladen werden können, ohne den Hauptthread zu blockieren.
// Klassisches CommonJS
const fs = require('fs');
const path = require('path');
function readConfig() {
return JSON.parse(fs.readFileSync('./config.json', 'utf8'));
}
module.exports = { readConfig };
Für uns Entwickler war das eine Erlösung nach den chaotischen <script>
-Tag-Zeiten, wo globale Variablen-Kollisionen an der Tagesordnung standen.
Browser-Module: AMD und UMD als Übergangslösungen
Während Node.js auf CommonJS setzte, benötigten Browser-Entwickler asynchrone Lösungen. Hier kam AMD (Asynchronous Module Definition) mit RequireJS ins Spiel:
// AMD-Syntax (heute historisch)
define(['jquery', 'lodash'], function ($, _) {
return {
doSomething: function () {
// Code hier
},
};
});
UMD (Universal Module Definition) versuchte dann alle Welten zu vereinen - eine Art Schweizer Taschenmesser für Module, das AMD, CommonJS und globale Variablen unterstützte. Funktional, aber alles andere als elegant.
ECMAScript Modules: Der offizielle Standard
2015 brachte ES6 endlich native JavaScript-Module mit import
und export
. Im Gegensatz zu CommonJS sind ESM statisch analysierbar - das bedeutet, Bundler können zur Build-Zeit erkennen, welche Teile des Codes tatsächlich verwendet werden (Tree-Shaking).
// Moderne ESM-Syntax
import { readFileSync } from 'fs';
import path from 'path';
export function readConfig() {
return JSON.parse(readFileSync('./config.json', 'utf8'));
}
export default { readConfig };
Kernunterschiede zwischen CommonJS und ESM
Syntax und Ladeweise
Der offensichtlichste Unterschied liegt in der Syntax:
// CommonJS
const express = require('express');
const { Router } = require('express');
module.exports = myFunction;
// ESM
import express, { Router } from 'express';
export default myFunction;
Aber der wichtigere Unterschied ist das Ladeverhalten: CommonJS lädt Module synchron zur Laufzeit, während ESM Module asynchron zur Parse-Zeit lädt und analysiert.
Dateierweiterungen und Package-Konfiguration
Node.js unterscheidet Module anhand verschiedener Signale:
.js
ist standardmäßig CommonJS (außer bei"type": "module"
).mjs
ist immer ESM.cjs
ist immer CommonJS
// package.json
{
"type": "module", // Macht .js zu ESM
"main": "./index.js",
"exports": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
Interoperabilität: Die größte Hürde
Hier wird es knifflig: ESM kann CommonJS importieren, aber nicht umgekehrt:
// ESM kann CJS laden
import someLibrary from 'commonjs-library'; // Funktioniert
// CJS kann ESM NICHT laden
const esmLibrary = require('esm-only-library'); // Fehler!
// Stattdessen:
const esmLibrary = await import('esm-only-library'); // Dynamischer Import
Diese Asymmetrie ist der Grund für viele Migrationsprobleme.
Der Ecosystem-Wandel in der Praxis
Node.js: Vorsichtige Evolution
Node.js hat ESM schrittweise eingeführt - ab Version 12 experimentell, ab Version 14 stabil. Die Entwickler wollten niemanden zurücklassen, daher die parallele Unterstützung beider Systeme.
// Moderne Node.js-App (package.json: "type": "module")
import express from 'express';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
// ESM hat kein __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const app = express();
// Rest der App...
TypeScript: Der Wegbereiter
TypeScript hat die Transition stark beschleunigt. Mit Konfigurationen wie "module": "nodenext"
können TypeScript-Projekte sowohl ESM als auch CommonJS ausgeben:
// tsconfig.json
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "es2022"
}
}
Paket-Autoren: Drei Strategien
Library-Entwickler haben grundsätzlich drei Optionen:
- Dual Packages: Sowohl ESM als auch CommonJS bereitstellen
- ESM-only: Nur ESM unterstützen (moderner, aber exklusiver)
- CJS-only: Bei CommonJS bleiben (konservativ, aber limitiert)
Single Projects vs. Monorepos
Einzelprojekte: Die einfache Migration
Für Standard-Node.js-Projekte ist der Wechsel zu ESM relativ straightforward:
// Schritt 1: package.json anpassen
{
"type": "module",
"scripts": {
"start": "node index.js" // .js wird nun als ESM interpretiert
}
}
// Schritt 2: Imports umschreiben
// Vorher (CommonJS)
const config = require('./config');
// Nachher (ESM)
import config from './config.js'; // Dateierweiterung erforderlich!
Monorepos: Koordination ist alles
In Monorepos wird's komplexer. Du musst sicherstellen, dass alle Pakete konsistent sind:
// packages/shared/package.json
{
"name": "@myorg/shared",
"type": "module",
"exports": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
// packages/api/package.json
{
"type": "module",
"dependencies": {
"@myorg/shared": "workspace:*"
}
}
Next.js und TurboRepo: Framework-Integration
Next.js kann mit beiden Formaten umgehen, bevorzugt aber ESM für App-Code:
// next.config.mjs (statt .js für ESM)
export default {
experimental: {
esmExternals: true
}
};
// pages/api/hello.js (App-Code in ESM)
export default function handler(req, res) {
res.status(200).json({ message: 'Hello from ESM!' });
}
Testing in der ESM-Welt
Jest: Der klassische Weg mit Hindernissen
Jest war historisch CommonJS-fokussiert. ESM-Testing funktioniert, braucht aber Extra-Konfiguration:
// jest.config.js
export default {
preset: 'ts-jest/presets/default-esm',
extensionsToTreatAsEsm: ['.ts'],
globals: {
'ts-jest': {
useESM: true,
},
},
// Experimentelles ESM-Flag
testEnvironment: 'node',
};
Das Mocking wird komplizierter:
// Alter Jest-Weg (CJS)
jest.mock('fs');
// Neuer ESM-Weg
jest.unstable_mockModule('fs', () => ({
readFileSync: jest.fn(),
}));
const { readFileSync } = await import('fs');
Vitest: Der moderne Ansatz
Vitest ist ESM-first und macht vieles einfacher:
// vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
},
});
// Test-Datei
import { vi, test, expect } from 'vitest';
import { myFunction } from '../src/index.js';
vi.mock('../src/dependency.js', () => ({
default: vi.fn(),
}));
test('should work with ESM', () => {
expect(myFunction()).toBe('expected');
});
Node.js Built-in Test Runner
Node 18+ hat einen eingebauten Test Runner:
// test.js
import { test, describe } from 'node:test';
import assert from 'node:assert';
import { myFunction } from './index.js';
describe('myFunction', () => {
test('should return expected value', () => {
assert.strictEqual(myFunction(), 'expected');
});
});
// Ausführen: node --test
Häufige Stolpersteine und ihre Lösungen
Das Dual-Package-Problem
Wenn ein Paket sowohl ESM als auch CommonJS bereitstellt, kann es doppelt geladen werden:
// Problem: Doppeltes Laden
import library from 'dual-package'; // ESM-Version
const legacy = require('dual-package'); // CJS-Version
// library und legacy sind VERSCHIEDENE Instanzen!
console.log(library === legacy); // false
Lösung: Konsistente Import-Strategie im gesamten Projekt.
Falsche "type"-Feld-Verwendung
// package.json
{
"type": "module" // Aber Code ist CommonJS!
}
// index.js
const fs = require('fs'); // SyntaxError!
Lösung: Entweder Code zu ESM migrieren oder .cjs
-Erweiterungen verwenden.
Import-Pfad-Probleme
// Fehler: ESM erfordert Dateierweiterungen
import config from './config'; // ModuleNotFoundError
// Korrekt:
import config from './config.js';
Mocking-Herausforderungen
ESM-Imports sind read-only, daher funktioniert Monkey-Patching nicht:
// Funktioniert NICHT in ESM:
import fs from 'fs';
fs.readFileSync = jest.fn(); // TypeError
// Besser: Dependency Injection
export function readConfig(fileSystem = fs) {
return JSON.parse(fileSystem.readFileSync('./config.json', 'utf8'));
}
Migration: Praktische Schritte
Schritt-für-Schritt-Anleitung
- Bestandsaufnahme: Welche Dependencies sind ESM-ready?
- Schrittweise Umstellung: Mit Leaf-Modulen beginnen
- Testing: Beide Formate parallel testen
- Package.json:
"type": "module"
hinzufügen - Imports umschreiben:
require()
zuimport
ändern - Dateierweiterungen:
.js
in Imports hinzufügen
Werkzeuge für die Migration
# ESM-Kompatibilität prüfen
npm install --save-dev @typescript-eslint/parser
npx es-check es2015 './dist/**/*.js'
# Dual builds erstellen
npm install --save-dev tsup
// tsup.config.js
export default {
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
splitting: false,
sourcemap: true,
clean: true,
};
Zukunftsausblick: ESM wird Standard
Die JavaScript-Welt konvergiert eindeutig zu ESM. Neue Runtimes wie Deno und Bun setzen primär auf ESM, moderne Bundler bevorzugen ESM für Tree-Shaking, und selbst konservative Libraries beginnen mit der Migration.
Warum ESM die bessere Wahl ist:
- Statische Analyse ermöglicht bessere Tools
- Native Browser-Unterstützung ohne Transpilation
- Tree-Shaking für kleinere Bundle-Größen
- Top-level await für modernere Async-Patterns
- Zukunftssicherheit durch W3C/ECMA-Standards
CommonJS wird nicht verschwinden - zu viel Legacy-Code existiert. Aber für neue Projekte ist ESM klar die bessere Wahl.
Fazit
Die Migration von CommonJS zu ESM ist mehr als nur ein Syntax-Wechsel - es ist eine Modernisierung der JavaScript-Infrastruktur. Ja, die Übergangszeit bringt Komplexität mit sich, aber die langfristigen Vorteile überwiegen bei weitem.
Meine Empfehlung: Neue Projekte sollten direkt mit ESM starten. Bestehende Projekte können schrittweise migrieren, beginnend mit den unabhängigsten Modulen. Die Tooling-Landschaft wird stetig besser, und die Community-Unterstützung wächst kontinuierlich.
Die JavaScript-Module sind endlich erwachsen geworden. Zeit, dass wir auch erwachsen werden und den nächsten Schritt gehen!
Häufig gestellte Fragen (FAQ)
Kann ich CommonJS und ESM in einem Projekt mischen?
Ja, das geht, aber mit Einschränkungen. ESM kann CommonJS importieren (als Default Export), aber CommonJS kann ESM nur über dynamische Imports (await import()
) laden. In der Praxis führt das oft zu Verwirrung, daher empfehle ich eine konsistente Strategie pro Projekt.
Warum funktioniert mein jest.mock()
nicht mehr mit ESM?
ESM-Imports werden zur Parse-Zeit aufgelöst, noch bevor Jest-Mocks greifen können. Du musst entweder jest.unstable_mockModule()
verwenden oder auf Vitest wechseln, das ESM-natives Mocking bietet. Alternativ kannst du Dependency Injection nutzen, um Abhängigkeiten testbar zu machen.
Muss ich alle meine npm-Dependencies auf ESM-Versionen upgraden?
Nein, das ist nicht notwendig. ESM kann CommonJS-Module problemlos importieren - sie erscheinen einfach als Default Export. Du solltest nur dann explizit auf ESM-Versionen wechseln, wenn du spezielle Features wie Named Exports oder Tree-Shaking nutzen möchtest.
- Technologien
- Programmiersprachen
- Tools