Przed ES Modules JavaScript nie miał wbudowanego systemu modułów. Był porządek tagów script, wzorce IIFE do imitowania enkapsulacji, a potem dekada CommonJS w Node.js. ES Modules — standaryzowane w ES2015 i teraz obsługiwane wszędzie — to prawdziwe rozwiązanie. Ten przewodnik omawia wszystko: od podstaw eksportów nazwanych vs domyślnych, po dynamiczne importy, tree-shaking i wzorce, które sprawiają, że duże bazy kodu pozostają łatwe w utrzymaniu.

Eksporty nazwane vs eksporty domyślne

Istnieją dwa sposoby eksportowania z modułu. Zrozumienie różnicy — i kompromisów — jest fundamentem dla wszystkiego innego:

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
Opinia: Eksporty nazwane są prawie zawsze lepsze dla modułów narzędziowych. Są jawne, przyjazne dla IDE (autouzupełnianie i „przejdź do definicji" działają idealnie) i bezpieczne przy refaktoryzacji — zmiana nazwy funkcji jest wykrywana przez narzędzia, a nie testy. Eksporty domyślne mają sens dla komponentów będących „jedyną rzeczą", którą plik eksportuje, jak komponenty React/Angular czy pliki obsługi tras.

Pliki barrel — przydatne i niebezpieczne

Plik barrel to index.js, który re-eksportuje z wielu modułów, dając konsumentom czystszą ścieżkę importu. Są wszędzie w dużych projektach:

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

Niebezpieczeństwo: pliki barrel mogą zniszczyć tree-shaking. Jeśli zaimportujesz jedną funkcję z pliku barrel, który re-eksportuje 40 modułów, niektóre bundlery wciągają wszystkie 40 — nawet te, których nie używasz. Rozwiązaniem jest selektywne budowanie plików barrel i upewnienie się, że bundler obsługuje sideEffects: false w package.json.

Dynamiczny import() — podział kodu na żądanie

Statyczne importy (import x from '...') są rozwiązywane podczas budowania bundla. Dynamiczny import() to funkcja, która ładuje moduł leniwie w czasie wykonywania, zwracając Promise. W ten sposób implementuje się podział kodu oparty na trasach:

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 i Vite rozumieją dynamiczny import() i automatycznie dzielą ładowany moduł na osobny fragment. Efekt: mniejszy początkowy bundle, szybsze pierwsze ładowanie.

import.meta — metadane modułu

import.meta to obiekt dający dostęp do metadanych bieżącego modułu. Najużyteczniejszą właściwością jest import.meta.url — URL bieżącego pliku modułu:

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 Modules vs CommonJS

Nadal możesz spotkać CommonJS (require()) w starszych bazach kodu Node.js i niektórych pakietach npm. Oto szybka ściągawka różnic, które sprawiają trudności:

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;

Kluczowe różnice: ES Modules są statycznie analizowalne (importy są rozwiązywane przed uruchomieniem kodu), podczas gdy CommonJS require wykonuje się w czasie wykonywania. ESM domyślnie używa ładowania async; CJS jest synchroniczny. W Node.js pliki z rozszerzeniem .mjs lub z "type": "module" w package.json są traktowane jako ESM.

Pułapka interoperacyjności: Możesz używać import modułu CommonJS z pliku ESM (Node.js obsługuje to), ale nie możesz użyć require() modułu ESM z pliku CommonJS. Jeśli potrzebujesz tego, użyj zamiast tego dynamicznego wywołania import() — działa w obu systemach modułów.

Tree-shaking i eksporty nazwane

Tree-shaking to proces bundlera polegający na usuwaniu kodu, który importujesz, ale nigdy nie używasz. Wymaga eksportów nazwanych i statycznych instrukcji importu — dynamiczne importy i CommonJS są znacznie trudniejsze do tree-shakowania. Oto jak pisać moduły, które dobrze się tree-shakują:

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

W przypadku bibliotek publikowanych na npm obsługa tree-shakingu to różnica między biblioteką, którą ludzie polecają, a tą, której unikają. Przewodnik po modułach MDN omawia pełne reguły analizy statycznej, które to umożliwiają.

Natywne ES Modules w przeglądarce

Możesz używać ES Modules bezpośrednio w przeglądarce — bez potrzeby bundlera. Dodaj type="module" do tagu script:

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

Skrypty modułowe są zawsze odroczone i domyślnie działają w trybie strict. Każdy moduł jest wykonywany tylko raz, nawet jeśli jest importowany przez wiele innych modułów — przeglądarka buforuje instancję modułu. Warto o tym wiedzieć, gdy masz współdzielony stan w modułach.

Realistyczny przykład refaktoryzacji

Oto jak typowy plik utils rozrasta się poza kontrolę i zostaje prawidłowo podzielony na moduły:

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)

Podział według odpowiedzialności oznacza, że każdy plik ma jedną odpowiedzialność, testy są łatwiejsze do pisania i lokalizowania, a bundlery mogą wykonywać tree-shaking na poziomie pliku. Plik barrel utrzymuje czystość importów dla konsumentów preferujących jedną ścieżkę importu.

Przydatne narzędzia

Podczas budowania modularnego JavaScript, JS Formatter utrzymuje bloki importów w czystości i spójności. Aby zrozumieć, co znajduje się w końcowym bundlu, narzędzia takie jak Webpack Bundle Analyzer lub wizualizator Rollup pokazują, które moduły zajmują miejsce. Specyfikacja TC39 ECMAScript Modules zawiera dokładną semantykę rozwiązywania modułów, jeśli potrzebujesz zrozumieć przypadki brzegowe.

Podsumowanie

ES Modules są fundamentem nowoczesnej architektury JavaScript. Preferuj eksporty nazwane dla funkcji narzędziowych — są jawne, obsługują tree-shaking i są przyjazne dla IDE. Używaj dynamicznego import() do wyodrębniania ciężkiego kodu z początkowego bundla. Uważaj na pliki barrel w dużych bazach kodu. A jeśli nadal używasz CommonJS w nowym projekcie Node.js — czas na zmianę. Ekosystem w pełni przeszedł na ESM.