Lekcja 19: Moduły w JavaScript (ES6 Modules)

W miarę rozrastania się aplikacji JavaScript, utrzymanie całego kodu w jednym pliku staje się niepraktyczne i trudne do zarządzania. Moduły pozwalają na dzielenie kodu na mniejsze, reużywalne części, które można importować i eksportować między różnymi plikami. ES6 wprowadziło natywny system modułów, znany jako ES Modules (ESM).

Moduły ES6 pomagają w organizacji kodu, hermetyzacji (zmienne i funkcje zdefiniowane w module nie są domyślnie globalne) i zarządzaniu zależnościami.

Podstawy ES Modules

System modułów ES6 opiera się na dwóch głównych słowach kluczowych: export i import.

Aby przeglądarka traktowała plik JavaScript jako moduł, musi on być załadowany za pomocą atrybutu type="module" w tagu <script>.

<!-- Ładowanie głównego pliku aplikacji jako modułu -->
<script type="module" src="main.js"></script>

Ważne cechy modułów ES6:

Eksportowanie (export)

Istnieją dwa główne rodzaje eksportów:

1. Eksporty nazwane (Named Exports)

Pozwalają na wyeksportowanie wielu wartości (zmiennych, funkcji, klas) z jednego modułu pod ich oryginalnymi nazwami.

// Plik: utils.js

// Eksportowanie istniejących deklaracji
export const PI = 3.14159;

export function dodaj(a, b) {
    return a + b;
}

export class Uzytkownik {
    constructor(imie) {
        this.imie = imie;
    }
    przywitaj() {
        console.log(`Witaj, ${this.imie}!`);
    }
}

// Można też eksportować na końcu pliku
const mnoz = (a, b) => a * b; // Prywatna funkcja modułu
const wersja = "1.0";

// Lista eksportowanych elementów
// export { mnoz, wersja }; // Błąd: mnoz nie jest zdefiniowane w tym zakresie, jeśli jest const

// Poprawny sposób eksportowania zadeklarowanych wcześniej zmiennych/funkcji
function odejmij(a, b) {
    return a - b;
}
const autor = "KursJS";

export { odejmij, autor };

// Można też zmienić nazwę podczas eksportu
function dziel(a, b) {
    if (b === 0) throw new Error("Dzielenie przez zero");
    return a / b;
}
export { dziel as podziel }; // Eksportowane jako 'podziel'

2. Eksport domyślny (Default Export)

Każdy moduł może mieć co najwyżej jeden eksport domyślny. Jest on używany, gdy moduł dostarcza jedną główną funkcjonalność.

// Plik: kalkulator.js

// Eksport domyślny klasy
export default class Kalkulator {
    dodaj(a, b) {
        return a + b;
    }
    odejmij(a, b) {
        return a - b;
    }
}

// Można też eksportować domyślnie funkcję anonimową lub wartość
// export default function(a, b) { return a * b; };
// export default "Jakaś wartość";

// Moduł może mieć jednocześnie eksport domyślny i eksporty nazwane
export const nazwaKalkulatora = "Prosty Kalkulator";

Importowanie (import)

Aby użyć funkcjonalności wyeksportowanej z innego modułu, używamy instrukcji import. Istnieje kilka sposobów importowania:

1. Importowanie nazwanych eksportów

Importujemy konkretne wartości, podając ich nazwy w nawiasach klamrowych {}.

// Plik: main.js

// Importowanie konkretnych nazwanych eksportów z utils.js
import { PI, dodaj, Uzytkownik, podziel, autor } from './utils.js';
// Ścieżka musi być względna lub bezwzględna, zazwyczaj zaczyna się od ./ lub ../

console.log("Wartość PI:", PI);
console.log("Wynik dodawania:", dodaj(5, 3));

const user = new Uzytkownik("Anna");
user.przywitaj();

console.log("Wynik dzielenia:", podziel(10, 2));
console.log("Autor modułu utils:", autor);

// Importowanie z aliasem (zmiana nazwy)
import { dodaj as suma } from './utils.js';
console.log("Wynik sumy (alias):", suma(10, 20));

// Importowanie wszystkiego jako obiekt (namespace import)
import * as Utils from './utils.js';
console.log("PI z namespace:", Utils.PI);
console.log("Dodawanie z namespace:", Utils.dodaj(1, 2));
const user2 = new Utils.Uzytkownik("Piotr");
user2.przywitaj();

2. Importowanie eksportu domyślnego

Importując eksport domyślny, możemy nadać mu dowolną nazwę (bez nawiasów klamrowych).

// Plik: main.js (kontynuacja)

// Importowanie eksportu domyślnego z kalkulator.js
import MojKalkulator from './kalkulator.js';
// Nazwa 'MojKalkulator' jest dowolna, mogłaby być np. 'Calc'

const calc = new MojKalkulator();
console.log("Wynik kalkulatora (domyślny export):", calc.dodaj(100, 50));

// Importowanie jednocześnie eksportu domyślnego i nazwanych
import SuperKalkulator, { nazwaKalkulatora } from './kalkulator.js';

const superCalc = new SuperKalkulator();
console.log("Nazwa kalkulatora:", nazwaKalkulatora);
console.log("Odejmowanie super kalkulatorem:", superCalc.odejmij(10, 3));

3. Import dynamiczny (Dynamic Import)

Standardowe instrukcje import muszą znajdować się na najwyższym poziomie modułu (nie wewnątrz funkcji, pętli czy warunków). Czasami jednak chcemy załadować moduł warunkowo lub na żądanie (np. po kliknięciu przycisku). Służy do tego import dynamiczny, który używa składni podobnej do funkcji: import('ścieżka/do/modułu.js').

Dynamiczny import() zwraca obietnicę (Promise), która rozwiązuje się do obiektu modułu (zawierającego wszystkie jego eksporty, w tym default).

// Plik: main.js (kontynuacja)

document.getElementById('ladujModulBtn').addEventListener('click', async () => {
    console.log("Próba dynamicznego załadowania modułu...");
    try {
        // Dynamiczny import zwraca Promise
        const modulLogowania = await import('./logger.js');
        // Domyślny export jest dostępny pod kluczem 'default'
        modulLogowania.default("Moduł logger.js załadowany dynamicznie!");
        // Nazwane exporty są dostępne bezpośrednio
        modulLogowania.logError("To jest dynamiczny błąd!");

        // Można też użyć destrukturyzacji
        // const { default: log, logError } = await import('./logger.js');
        // log("Załadowano przez destrukturyzację");
        // logError("Błąd przez destrukturyzację");

    } catch (error) {
        console.error("Nie udało się załadować modułu dynamicznie:", error);
    }
});

// Przykładowy plik logger.js
/*
// logger.js
export default function log(message) {
    console.log(`[LOG] ${new Date().toISOString()}: ${message}`);
}

export function logError(message) {
    console.error(`[ERROR] ${new Date().toISOString()}: ${message}`);
}
*/

Dynamiczne importy są przydatne do optymalizacji ładowania aplikacji (code splitting), gdzie ładujemy tylko te części kodu, które są aktualnie potrzebne.

Zadanie praktyczne

Stwórz dwa pliki:

  1. matematyka.js: Zdefiniuj i wyeksportuj (jako eksporty nazwane) dwie funkcje: potega(liczba, wykladnik) (obliczającą potęgę) i losowa(min, max) (zwracającą losową liczbę całkowitą z podanego zakresu).
  2. app.js: Zaimportuj obie funkcje z matematyka.js. Wywołaj potega(2, 10) i losowa(1, 100), a wyniki wyświetl w konsoli. Pamiętaj, aby załadować app.js w HTML za pomocą <script type="module">.
Pokaż rozwiązanie

Plik: matematyka.js

// matematyka.js
export function potega(liczba, wykladnik) {
    return Math.pow(liczba, wykladnik);
}

export function losowa(min, max) {
    min = Math.ceil(min);
    max = Math.floor(max);
    return Math.floor(Math.random() * (max - min + 1)) + min;
}

Plik: app.js

// app.js
import { potega, losowa } from './matematyka.js';

const wynikPotegi = potega(2, 10);
console.log(`2 do potęgi 10 = ${wynikPotegi}`); // 1024

const liczbaLosowa = losowa(1, 100);
console.log(`Losowa liczba z zakresu 1-100: ${liczbaLosowa}`);

Plik: index.html (fragment)

<!DOCTYPE html>
<html>
<head>
    <title>Test Modułów</title>
</head>
<body>
    <h1>Testowanie modułów JavaScript</h1>
    <!-- Ładowanie app.js jako modułu -->
    <script type="module" src="app.js"></script>
</body>
</html>

Zadanie do samodzielnego wykonania

Stwórz moduł konfiguracja.js, który będzie eksportował domyślnie obiekt konfiguracyjny, np. { apiUrl: "https://api.example.com", jezyk: "pl", motyw: "ciemny" }. Stwórz również nazwany eksport WERSJA_API = "v2".

W pliku main.js zaimportuj zarówno domyślny obiekt konfiguracyjny (pod nazwą config), jak i nazwany eksport WERSJA_API. Wyświetl w konsoli wartości config.apiUrl i WERSJA_API.

FAQ - Moduły w JavaScript (ES6 Modules)

Jaka jest różnica między ES Modules a CommonJS (używanym w Node.js)?

CommonJS (require/module.exports) jest systemem modułów używanym historycznie w Node.js. Jest synchroniczny. ES Modules (import/export) to standardowy system modułów JavaScript, jest asynchroniczny i wspierany zarówno w przeglądarkach, jak i nowoczesnych wersjach Node.js. Składnia i sposób działania są różne.

Czy mogę używać `import` i `export` w zwykłych skryptach (bez `type="module"`)?

Nie, składnia import i export jest dozwolona tylko w plikach traktowanych jako moduły ES6 (czyli ładowanych z type="module" w przeglądarce lub będących modułami ES w Node.js).

Czy ścieżki w `import` muszą mieć rozszerzenie `.js`?

W przeglądarkach zazwyczaj tak, ścieżka do modułu powinna być pełna, włącznie z rozszerzeniem (np. ./utils.js). W środowisku Node.js i niektórych narzędziach budujących (bundlerach) rozszerzenie może być czasami pomijane, ale jawne podawanie jest bezpieczniejszą praktyką.

Co jeśli zaimportuję ten sam moduł dwa razy?

Moduł zostanie pobrany i wykonany tylko raz. Kolejne importy tego samego modułu (nawet w różnych częściach aplikacji) będą odnosić się do tej samej, już istniejącej instancji modułu i jego eksportów.

Czy mogę modyfikować zaimportowane wartości?

Zaimportowane wartości (zarówno nazwane, jak i domyślne) są traktowane jako "żywe" powiązania (live bindings), ale są tylko do odczytu. Próba przypisania nowej wartości do zaimportowanej zmiennej (np. import { PI } from './math.js'; PI = 3;) spowoduje błąd TypeError.

Do czego służą bundlery modułów (np. Webpack, Rollup, Parcel)?

Bundlery to narzędzia, które przetwarzają kod źródłowy aplikacji (w tym moduły ES6, CommonJS, style CSS, obrazy itp.) i łączą go w jeden lub kilka zoptymalizowanych plików (tzw. "bundles"), gotowych do wdrożenia w przeglądarce. Umożliwiają korzystanie z modułów i innych nowoczesnych funkcji nawet w starszych przeglądarkach oraz optymalizują rozmiar i ładowanie aplikacji.