Vor ES-Modulen hatte JavaScript kein eingebautes Modulsystem. Es gab Script-Tags in der richtigen Reihenfolge, IIFE-Muster zur Simulation von Kapselung und dann ein Jahrzehnt CommonJS in Node.js. ES-Module — standardisiert in ES2015 und jetzt überall unterstützt — sind die echte Lösung. Dieser Leitfaden deckt alles ab, vom Grundprinzip benannter vs. Standard-Exporte bis hin zu dynamischen Importen, Tree-Shaking und den Mustern, die große Codebasen wartbar halten.
Benannte Exporte vs. Standard-Exporte
Es gibt zwei Möglichkeiten, aus einem Modul zu exportieren. Die Unterschiede — und die Trade-offs — zu verstehen, ist die Grundlage für alles andere:
// utils/currency.js
// Named exports — explicit names, multiple per file
export function formatPrice(amount, currency = 'USD') {
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount);
}
export function parseCents(cents) {
return cents / 100;
}
export const DEFAULT_CURRENCY = 'USD';// Importing named exports — names must match
import { formatPrice, parseCents } from './utils/currency.js';
// You can rename on import
import { formatPrice as fmt } from './utils/currency.js';
// Import the entire namespace as an object
import * as Currency from './utils/currency.js';
Currency.formatPrice(49.99); // works// components/ProductCard.js
// Default export — one per file, no name required
export default function ProductCard({ product }) {
return `<div class="card">${product.name}</div>`;
}// Importing a default export — you choose the name
import ProductCard from './components/ProductCard.js';
import Card from './components/ProductCard.js'; // same thing, different nameBarrel-Dateien — Nützlich und gefährlich
Eine Barrel-Datei ist eine index.js, die aus mehreren Modulen re-exportiert und den Konsumenten einen saubereren Importpfad gibt. Sie sind in großen Projekten überall zu finden:
// utils/index.js — the barrel
export { formatPrice, parseCents, DEFAULT_CURRENCY } from './currency.js';
export { validateEmail, validatePhone } from './validation.js';
export { debounce, throttle } from './timing.js';
export { fetchWithRetry, buildQueryString } from './http.js';// Consumer — clean import path, no need to know the file structure
import { formatPrice, debounce, fetchWithRetry } from './utils/index.js';
// or with bundlers that resolve index.js automatically:
import { formatPrice, debounce, fetchWithRetry } from './utils';Die Gefahr: Barrel-Dateien können das Tree-Shaking zerstören. Wenn Sie eine Funktion aus einem Barrel importieren, das 40 Module re-exportiert, laden einige Bundler alle 40 — auch die, die Sie nicht verwenden. Die Lösung ist, selektiv vorzugehen, was Sie in den Barrel aufnehmen, und sicherzustellen, dass Ihr Bundler sideEffects: false in package.json unterstützt.
Dynamisches import() — Code-Splitting on Demand
Statische Importe (import x from '...') werden zur Build-Zeit aufgelöst. Dynamisches import() ist eine Funktion, die ein Modul zur Laufzeit lazy lädt und ein Promise zurückgibt. So implementieren Sie routenbasiertes Code-Splitting:
// Load a heavy chart library only when the user navigates to the analytics page
async function loadAnalyticsDashboard() {
const { Chart } = await import('chart.js');
const { buildDashboardData } = await import('./analytics/data.js');
const data = await buildDashboardData();
const canvas = document.getElementById('dashboard-chart');
new Chart(canvas, { type: 'bar', data });
}
// Attach to a route change
router.on('/analytics', loadAnalyticsDashboard);// Feature flags — only load a module if the feature is enabled
async function initFeature(featureName) {
const flags = await getFeatureFlags();
if (flags[featureName]) {
const module = await import(`./features/${featureName}.js`);
module.init();
}
}Webpack, Rollup und Vite verstehen dynamisches import() und teilen das geladene Modul automatisch in einen separaten Chunk auf. Das Ergebnis: kleineres initiales Bundle, schnelleres erstes Laden.
import.meta — Modul-Metadaten
import.meta ist ein Objekt, das Ihnen Metadaten über das aktuelle Modul liefert. Die nützlichste Eigenschaft ist import.meta.url — die URL der aktuellen Moduldatei:
// Get the directory of the current module (Node.js)
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Now you can do path resolution the same way you would with CommonJS
const configPath = join(__dirname, '../config/app.json');// In Vite projects, import.meta.env gives you environment variables
const apiBase = import.meta.env.VITE_API_BASE_URL;
const isProd = import.meta.env.PROD; // boolean
// import.meta.glob — Vite-specific, import multiple files at once
const modules = import.meta.glob('./pages/*.js');
for (const path in modules) {
const module = await modules[path]();
console.log(path, module);
}ES-Module vs. CommonJS
In älteren Node.js-Codebasen und einigen npm-Paketen werden Sie weiterhin auf CommonJS (require()) stoßen. Hier ist eine schnelle Referenz für die Unterschiede, die Menschen verwirren:
// CommonJS (CJS) — still common in Node.js
const express = require('express');
const { join } = require('path');
module.exports = { myFunction };
module.exports.default = MyClass;
// ES Modules (ESM) — the standard
import express from 'express';
import { join } from 'path';
export { myFunction };
export default MyClass;Wesentliche Unterschiede: ES-Module sind statisch analysierbar (Importe werden aufgelöst, bevor Code läuft), während CommonJS zur Laufzeit ausgeführt wird. ESM verwendet standardmäßig async-Laden; CJS ist synchron. In Node.js werden Dateien mit der Erweiterung .mjs oder "type": "module" in package.json als ESM behandelt.
importieren (Node.js behandelt das), aber Sie können kein ESM-Modul aus einer CommonJS-Datei mit require() laden. Falls nötig, verwenden Sie stattdessen einen dynamischen import()-Aufruf — er funktioniert in beiden Modulsystemen.Tree-Shaking und benannte Exporte
Tree-Shaking ist der Bundler-Prozess zum Entfernen von Code, den Sie importieren, aber nie verwenden. Es erfordert benannte Exporte und statische Import-Anweisungen — dynamische Importe und CommonJS sind viel schwieriger zu tree-shaken. So schreiben Sie Module, die gut tree-shaken:
// ✅ Tree-shakeable — bundler can see exactly what's exported
export function formatDate(date) { /* ... */ }
export function parseDate(str) { /* ... */ }
export function addDays(date, n) { /* ... */ }
// If you only import formatDate, parseDate and addDays are excluded from the bundle
import { formatDate } from './utils/dates.js';// ❌ Harder to tree-shake — the whole object is one export
const dateUtils = {
formatDate: (date) => { /* ... */ },
parseDate: (str) => { /* ... */ },
addDays: (date, n) => { /* ... */ }
};
export default dateUtils;
// Even if you only use dateUtils.formatDate, the whole object may be bundledFür auf npm veröffentlichte Bibliotheken ist Tree-Shaking-Unterstützung der Unterschied zwischen einer Bibliothek, die die Leute empfehlen, und einer, die sie meiden. Der MDN-Modulguide behandelt die vollständigen statischen Analyseregeln, die das ermöglichen.
Native ES-Module im Browser
Sie können ES-Module direkt im Browser verwenden — kein Bundler erforderlich. Fügen Sie type="module" zu Ihrem Script-Tag hinzu:
<!-- index.html -->
<!-- type="module" enables ES Module semantics -->
<!-- deferred by default — runs after HTML is parsed -->
<script type="module" src="./app.js"></script>// app.js — can use import/export directly
import { initRouter } from './router.js';
import { setupAuth } from './auth.js';
import { loadDashboard } from './dashboard.js';
async function main() {
await setupAuth();
initRouter();
await loadDashboard();
}
main();Modul-Skripte werden standardmäßig immer verzögert und im Strict Mode ausgeführt. Jedes Modul wird nur einmal ausgeführt, selbst wenn es von mehreren anderen Modulen importiert wird — der Browser cacht die Modul-Instanz. Das ist wichtig zu wissen, wenn Sie gemeinsamen Zustand in Modulen haben.
Ein echtes Refactoring-Beispiel
So wächst eine typische utils-Datei außer Kontrolle und wird ordentlich in Module aufgeteilt:
// Before: one giant utils.js with everything
export function formatPrice(amount) { /* ... */ }
export function validateEmail(email) { /* ... */ }
export function debounce(fn, ms) { /* ... */ }
export function fetchWithRetry(url, opts) { /* ... */ }
export function parseCSV(text) { /* ... */ }
export function buildQueryString(params) { /* ... */ }
// After: split by concern
// utils/
// currency.js → formatPrice, parseCents
// validation.js → validateEmail, validatePhone
// timing.js → debounce, throttle
// http.js → fetchWithRetry, buildQueryString
// csv.js → parseCSV, formatCSV
// index.js → re-exports all of the above (optional barrel)Die Aufteilung nach Verantwortlichkeit bedeutet, dass jede Datei eine einzige Aufgabe hat, Tests einfacher zu schreiben und zu finden sind, und Bundler Tree-Shaking auf Dateiebene durchführen können. Die Barrel-Datei hält die Importe für Konsumenten sauber, die den einzelnen Importpfad bevorzugen.
Nützliche Werkzeuge
Beim Erstellen von modularem JavaScript hält der JS-Formatierer Ihre Import-Blöcke sauber und konsistent. Um zu verstehen, was in Ihrem finalen Bundle steckt, zeigen Werkzeuge wie Webpack Bundle Analyzer oder Rollups Visualizer, welche Module Platz beanspruchen. Die TC39 ECMAScript-Module-Spezifikation enthält die genaue Auflösungssemantik, wenn Sie Edge-Cases verstehen müssen.
Fazit
ES-Module sind das Fundament moderner JavaScript-Architektur. Bevorzugen Sie benannte Exporte für Utility-Funktionen — sie sind explizit, tree-shakeable und IDE-freundlich. Verwenden Sie dynamisches import(), um schweren Code aus Ihrem initialen Bundle auszulagern. Seien Sie vorsichtig mit Barrel-Dateien in großen Codebasen. Und wenn Sie in einem neuen Node.js-Projekt noch CommonJS verwenden, ist es Zeit zum Wechseln — das Ökosystem hat vollständig auf ESM umgestellt.