Före ES-moduler hade JavaScript inget inbyggt modulsystem. Du hade script-taggar i rätt ordning, IIFE-mönster för att simulera inkapsling och sedan ett decennium av CommonJS i Node.js. ES-moduler — standardiserade i ES2015 och nu stödda överallt — är den verkliga lösningen. Den här guiden täcker allt från grunderna med namngivna vs standardexporter till dynamiska importer, tree-shaking och mönster som håller stora kodbaser underhållbara.

Namngivna exporter vs standardexporter

Det finns två sätt att exportera från en modul. Att förstå skillnaden — och avvägningarna — är grunden för allt annat:

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
Åsikt: Namngivna exporter är nästan alltid bättre för verktygmoduler. De är explicita, IDE-vänliga (autokomplettering och "gå till definition" fungerar perfekt) och säkra att refaktorisera — att byta namn på en funktion fångas av dina verktyg, inte dina tester. Standardexporter passar för komponenter som är "den enda saken" en fil exporterar, som React/Angular-komponenter eller rutt-handlerarfiler.

Barrel-filer — användbara och farliga

En barrel-fil är en index.js som re-exporterar från flera moduler och ger konsumenter en renare importsökväg. De finns överallt i stora projekt:

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

Faran: barrel-filer kan förstöra tree-shaking. Om du importerar en funktion från en barrel som re-exporterar 40 moduler, drar vissa bundler in alla 40 — även de du inte använder. Lösningen är att vara selektiv om vad du lägger i barrel och se till att din bundler stöder sideEffects: false i package.json.

Dynamisk import() — koddelning på begäran

Statiska importer (import x from '...') löses vid bundle-tid. Dynamisk import() är en funktion som laddar en modul lazily vid körning och returnerar ett Promise. Det är så du implementerar rutt-baserad koddelning:

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 och Vite förstår alla dynamisk import() och delar automatiskt den laddade modulen i ett separat chunk. Resultatet: mindre initialbundle, snabbare första laddning.

import.meta — modulmetadata

import.meta är ett objekt som ger dig metadata om den aktuella modulen. Den mest användbara egenskapen är import.meta.url — URL:en för den aktuella modulfilen:

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 stöter fortfarande på CommonJS (require()) i äldre Node.js-kodbaser och vissa npm-paket. Här är en snabb referens för skillnaderna som ställer till problem:

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;

Viktiga skillnader: ES-moduler är statiskt analyserbara (importer löses innan koden körs), medan CommonJS-require körs vid körning. ESM använder async-laddning som standard; CJS är synkron. I Node.js behandlas filer med filnamnstillägget .mjs eller "type": "module" i package.json som ESM.

Interop-fallgrop: Du kan import:a en CommonJS-modul från en ESM-fil (Node.js hanterar detta), men du kan inte require():a en ESM-modul från en CommonJS-fil. Om du behöver det, använd ett dynamiskt import()-anrop istället — det fungerar i båda modulsystemen.

Tree-shaking och namngivna exporter

Tree-shaking är bundler-processen för att ta bort kod du importerar men aldrig använder. Det kräver namngivna exporter och statiska import-satser — dynamiska importer och CommonJS är mycket svårare att tree-shaka. Så här skriver du moduler som tree-shakar väl:

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

För bibliotek publicerade på npm är tree-shaking-stöd skillnaden mellan ett bibliotek som folk rekommenderar och ett de undviker. MDN-modulguiden täcker de fullständiga statiska analysreglerna som möjliggör det.

Inbyggda ES-moduler i webbläsaren

Du kan använda ES-moduler direkt i webbläsaren — ingen bundler krävs. Lägg till type="module" i din script-tagg:

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();

Modulskript är alltid uppskjutna och körs i strikt läge som standard. Varje modul körs bara en gång, även om den importeras av flera andra moduler — webbläsaren cachar modulinstansen. Det är värt att veta när du har delat tillstånd i moduler.

Ett verkligt refaktoriseringsexempel

Så här växer en typisk utils-fil ur kontroll och delas upp 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)

Att dela upp efter ansvar innebär att varje fil har ett enda ansvar, tester är lättare att skriva och hitta, och bundler kan tree-shaka på filnivå. Barrel-filen håller importer rena för konsumenter som föredrar den enskilda importsökvägen.

Användbara verktyg

När du bygger modulär JavaScript håller JS Formatter dina importblock rena och konsekventa. För att förstå vad som finns i ditt slutliga bundle visar verktyg som Webpack Bundle Analyzer eller Rollups visualiserare vilka moduler som tar upp utrymme. TC39 ECMAScript Modules-specifikationen har den exakta upplösningssemantiken om du behöver förstå kantfall.

Sammanfattning

ES-moduler är grunden för modern JavaScript-arkitektur. Föredra namngivna exporter för verktygsfunktioner — de är explicita, tree-shakebara och IDE-vänliga. Använd dynamisk import() för att dela tung kod ur din initialbundle. Var försiktig med barrel-filer i stora kodbaser. Och om du fortfarande är på CommonJS i ett nytt Node.js-projekt är det dags att byta — ekosystemet har fullt ut skiftat till ESM.