Før ES-moduler havde JavaScript intet indbygget modulsystem. Du havde script-tags i den rigtige rækkefølge, IIFE-mønstre til at simulere indkapsling og derefter et årti med CommonJS i Node.js. ES-moduler — standardiseret i ES2015 og nu understøttet overalt — er den rigtige løsning. Denne guide dækker alt fra grundlæggende navngivne vs. standardeksporter til dynamiske importer, tree-shaking og mønstre, der holder store kodebaser vedligeholdelige.

Navngivne eksporter vs. standardeksporter

Der er to måder at eksportere fra et modul på. At forstå forskellen — og afvejningerne — er grundlaget for alt andet:

js
// 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';
js
// 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
js
// components/ProductCard.js

// Default export — one per file, no name required
export default function ProductCard({ product }) {
  return `<div class="card">${product.name}</div>`;
}
js
// Importing a default export — you choose the name
import ProductCard from './components/ProductCard.js';
import Card        from './components/ProductCard.js'; // same thing, different name
Holdning: Navngivne eksporter er næsten altid bedre til hjælpemoduler. De er eksplicitte, IDE-venlige (autoudfyldning og "gå til definition" fungerer perfekt) og sikre at refaktorere — omdøbning af en funktion fanges af dine værktøjer, ikke dine tests. Standardeksporter giver mening til komponenter, der er "den ene ting" en fil eksporterer, som React/Angular-komponenter eller rutehåndterer-filer.

Barrel-filer — nyttige og farlige

En barrel-fil er en index.js, der re-eksporterer fra flere moduler og giver forbrugere en renere importsti. De er overalt i store projekter:

js
// 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';
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';

Faren: barrel-filer kan ødelægge tree-shaking. Hvis du importerer én funktion fra en barrel, der re-eksporterer 40 moduler, trækker nogle bundlers alle 40 ind — selv dem, du ikke bruger. Løsningen er at være selektiv om, hvad du lægger i barrel, og sørge for, at din bundler understøtter sideEffects: false i package.json.

Dynamisk import() — kodeopdeling efter behov

Statiske importer (import x from '...') løses på bundle-tid. Dynamisk import() er en funktion, der indlæser et modul dovent ved kørsel og returnerer et Promise. Det er sådan, du implementerer rutebaseret kodeopdeling:

js
// 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);
js
// 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 og Vite forstår alle dynamisk import() og opdeler automatisk det indlæste modul i et separat chunk. Resultatet: mindre startbundle, hurtigere første indlæsning.

import.meta — modulmetadata

import.meta er et objekt, der giver dig metadata om det aktuelle modul. Den mest nyttige egenskab er import.meta.url — URL'en for den aktuelle modulfil:

js
// 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');
js
// 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-moduler vs. CommonJS

Du vil stadig støde på CommonJS (require()) i ældre Node.js-kodebaser og nogle npm-pakker. Her er en hurtig reference til de forskelle, der driller folk:

js
// 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;

Vigtige forskelle: ES-moduler er statisk analyserbare (importer løses, før koden kører), mens CommonJS require kører ved kørsel. ESM bruger async-indlæsning som standard; CJS er synkron. I Node.js behandles filer med filtypenavnet .mjs eller med "type": "module" i package.json som ESM.

Interop-faldgrube: Du kan import:ere et CommonJS-modul fra en ESM-fil (Node.js håndterer dette), men du kan ikke require():ere et ESM-modul fra en CommonJS-fil. Hvis du har brug for det, brug et dynamisk import()-kald i stedet — det fungerer i begge modulsystemer.

Tree-shaking og navngivne eksporter

Tree-shaking er bundler-processen med at fjerne kode, du importerer men aldrig bruger. Det kræver navngivne eksporter og statiske import-sætninger — dynamiske importer og CommonJS er meget sværere at tree-shake. Her er, hvordan man skriver moduler, der tree-shaker godt:

js
// ✅ 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';
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 bundled

For biblioteker publiceret til npm er tree-shaking-understøttelse forskellen mellem et bibliotek, folk anbefaler, og et de undgår. MDN Modules-guiden dækker de fulde statiske analyseregler, der muliggør det.

Native ES-moduler i browseren

Du kan bruge ES-moduler direkte i browseren — ingen bundler kræves. Tilføj type="module" til dit script-tag:

js
<!-- index.html -->
<!-- type="module" enables ES Module semantics -->
<!-- deferred by default — runs after HTML is parsed -->
<script type="module" src="./app.js"></script>
js
// 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();

Modulscripts er altid udskudt og kører i strict mode som standard. Hvert modul udføres kun én gang, selv hvis det importeres af flere andre moduler — browseren cacher modulinstansen. Det er værd at vide, når du har delt tilstand i moduler.

Et ægte refaktorizeringseksempel

Her er, hvordan en typisk utils-fil vokser ud af kontrol og opdeles ordentligt i moduler:

js
// 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)

Opdeling efter ansvar betyder, at hver fil har et enkelt ansvar, tests er nemmere at skrive og finde, og bundlers kan tree-shake på filniveau. Barrel-filen holder importer rene for forbrugere, der foretrækker den enkelte importsti.

Nyttige værktøjer

Når du bygger modulær JavaScript, holder JS Formatter dine importblokke rene og konsistente. For at forstå, hvad der er i dit endelige bundle, viser værktøjer som Webpack Bundle Analyzer eller Rollups visualizer, hvilke moduler der fylder. TC39 ECMAScript Modules-specifikationen har den præcise løsningssemantik, hvis du har brug for at forstå kanttilfælde.

Afslutning

ES-moduler er fundamentet for moderne JavaScript-arkitektur. Foretræk navngivne eksporter til hjælpefunktioner — de er eksplicitte, tree-shakeable og IDE-venlige. Brug dynamisk import() til at dele tung kode ud af din startbundle. Vær forsigtig med barrel-filer i store kodebaser. Og hvis du stadig bruger CommonJS i et nyt Node.js-projekt, er det tid til at skifte — økosystemet er fuldt ud skiftet til ESM.