Voor ES Modules had JavaScript geen ingebouwd modulesysteem. Je had scripttags in de juiste volgorde, IIFE-patronen om inkapseling na te bootsen, en daarna een decennium aan CommonJS in Node.js. ES Modules — gestandaardiseerd in ES2015 en nu overal ondersteund — zijn de echte oplossing. Deze gids behandelt alles van de basis van benoemde vs. standaardexports tot dynamische imports, tree-shaking en de patronen die grote codebases beheersbaar houden.
Benoemde exports vs standaardexports
Er zijn twee manieren om vanuit een module te exporteren. Het verschil begrijpen — en de afwegingen — is de basis voor al het 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-bestanden — Nuttig en gevaarlijk
Een barrel-bestand is een index.js die vanuit meerdere modules herexporteert, wat consumenten een schoner importpad geeft. Ze zijn overal in grote projecten:
// 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';Het gevaar: barrel-bestanden kunnen tree-shaking om zeep helpen. Als je één functie importeert uit een barrel die 40 modules herexporteert, laden sommige bundlers alle 40 — ook de modules die je niet gebruikt. De oplossing is selectief zijn met wat je in een barrel plaatst, en ervoor zorgen dat je bundler sideEffects: false in package.json ondersteunt.
Dynamische import() — Code splitting op aanvraag
Statische imports (import x from '...') worden opgelost tijdens het bundelen. Dynamische import() is een functie die een module lui laadt tijdens runtime en een Promise teruggeeft. Zo implementeer je op routes gebaseerde 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 en Vite begrijpen alle dynamische import() en zullen de geladen module automatisch opsplitsen in een apart chunk. Het resultaat: kleinere initiële bundle, snellere eerste laadtijd.
import.meta — Modulemetadata
import.meta is een object dat metadata over de huidige module geeft. De meest nuttige eigenschap is import.meta.url — de URL van het huidige modulebestand:
// 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 Modules vs CommonJS
Je zult CommonJS (require()) nog tegenkomen in oudere Node.js-codebases en sommige npm-pakketten. Hier is een snelle referentie voor de verschillen die mensen in verwarring brengen:
// 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;Belangrijkste verschillen: ES Modules zijn statisch analyseerbaar (imports worden opgelost voordat code wordt uitgevoerd), terwijl CommonJS-requires tijdens runtime worden uitgevoerd. ESM gebruikt standaard async-laden; CJS is synchroon. In Node.js worden bestanden met de extensie .mjs of "type": "module" in package.json behandeld als ESM.
importeren vanuit een ESM-bestand (Node.js regelt dit), maar je kunt geen ESM-module require()en vanuit een CommonJS-bestand. Als je dat nodig hebt, gebruik dan in plaats daarvan een dynamische import()-aanroep — die werkt in beide modulesystemen.Tree-shaking en benoemde exports
Tree-shaking is het bundelproces waarbij code wordt verwijderd die je importeert maar nooit gebruikt. Het vereist benoemde exports en statische importinstructies — dynamische imports en CommonJS zijn veel moeilijker te tree-shaken. Zo schrijf je modules die goed 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 bundledVoor bibliotheken die op npm worden gepubliceerd, is tree-shaking-ondersteuning het verschil tussen een bibliotheek die mensen aanbevelen en een die ze vermijden. De MDN-modulegids behandelt de volledige statische analyseregels die dit mogelijk maken.
Native ES Modules in de browser
Je kunt ES Modules rechtstreeks in de browser gebruiken — geen bundler vereist. Voeg type="module" toe aan je scripttag:
<!-- 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();Modulescripts worden altijd uitgesteld en standaard uitgevoerd in strict mode. Elke module wordt slechts één keer uitgevoerd, zelfs als ze door meerdere andere modules worden geïmporteerd — de browser slaat de module-instantie op in de cache. Dit is de moeite waard te weten wanneer je gedeelde status in modules hebt.
Een echt refactoringvoorbeeld
Hier zie je hoe een typisch utils-bestand uit de hand loopt en correct wordt opgesplitst in modules:
// 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)Opsplitsen per verantwoordelijkheid betekent dat elk bestand één verantwoordelijkheid heeft, tests gemakkelijker te schrijven en te vinden zijn, en bundlers op bestandsniveau kunnen tree-shaken. Het barrel-bestand houdt imports schoon voor consumenten die de voorkeur geven aan het enkele importpad.
Handige hulpmiddelen
Bij het bouwen van modulaire JavaScript houdt JS Formatter je importblokken schoon en consistent. Om te begrijpen wat er in je uiteindelijke bundle zit, laten tools zoals Webpack Bundle Analyzer of Rollup's visualizer zien welke modules ruimte innemen. De TC39 ECMAScript Modules-specificatie bevat de exacte resolutiesemantieken als je randgevallen moet begrijpen.
Samenvatting
ES Modules zijn de basis van moderne JavaScript-architectuur. Geef de voorkeur aan benoemde exports voor utility-functies — ze zijn expliciet, tree-shakeable en IDE-vriendelijk. Gebruik dynamische import() om zware code buiten je initiële bundle te houden. Wees voorzichtig met barrel-bestanden in grote codebases. En als je in een nieuw Node.js-project nog op CommonJS zit, is het tijd om over te stappen — het ecosysteem is volledig overgeschakeld op ESM.