Dwa systemy modułów, jeden język
Na początku JavaScript nie miał żadnego systemu modułów. Node wypełnił tę lukę w 2009 roku za pomocą CommonJS (require, module.exports) i przez lata tak właśnie wyglądał kod w Node. Potem, w 2015 roku, sam język dostał standardowy system modułów, czyli ES Modules (import, export), który dziś obsługują zarówno przeglądarki, jak i Node.
Dlatego w praktyce spotkasz oba. Oto ten sam mały moduł napisany na dwa sposoby:
Ta sama funkcja, dwie różne koperty. Reszta tej strony wyjaśnia, kiedy koperta ma znaczenie i po którą sięgać.
Różnice w składni
Codzienne różnice zmieszczą się na pocztówce:
// CommonJS
const fs = require("fs");
const { readFile } = require("fs/promises");
module.exports = something;
module.exports.name = value;
exports.name = value;
// ES Modules
import fs from "fs";
import { readFile } from "fs/promises";
export default something;
export const name = value;
export { name };
require to zwykłe wywołanie funkcji. import to instrukcja: może pojawić się tylko na najwyższym poziomie modułu, a ścieżka musi być literałem tekstowym. To ograniczenie nie jest przypadkowe: właśnie ono pozwala ESM robić rzeczy, których CommonJS nie potrafi.
Prawdziwa różnica: statyczne vs dynamiczne
CommonJS wykonuje require() w chwili, gdy dochodzi do tej linii. Możesz go wstawić do if, wyliczyć ścieżkę w trakcie działania programu i ładować moduł warunkowo:
ES Modules są statyczne. Silnik parsuje wszystkie instrukcje import, zanim uruchomi jakikolwiek kod, buduje graf zależności i rozwiązuje wszystko z góry. Dlatego ścieżka musi być literałem i dlatego import pojawia się tylko na najwyższym poziomie.
Zysk: narzędzia widzą cały graf modułów bez wykonywania czegokolwiek. Dzięki temu bundlery robią tree-shaking (usuwają nieużywane eksporty), edytory dają trafne podpowiedzi, a przeglądarka może pobierać moduły równolegle.
Gdy naprawdę potrzebujesz dynamicznego ładowania w ESM, użyj import(): wyrażenia wyglądającego jak funkcja, które zwraca Promise:
Jak Node decyduje, którego systemu używa plik
Jeden projekt Node może zawierać pliki obu rodzajów. Node ustala system dla każdego pliku na podstawie dwóch rzeczy:
- Rozszerzenie pliku:
.mjsto zawsze ESM,.cjsto zawsze CommonJS. - Pole
"type"w najbliższympackage.json:"module"oznacza, że pliki.jsto ESM, a"commonjs"(wartość domyślna, gdy pola brak) oznacza, że to CJS.
// package.json
{
"name": "my-app",
"type": "module"
}
Przy "type": "module" zwykły hello.js w tej samej paczce używa import/export. Wrzuć obok hello.cjs, a ten jeden plik będzie używał require. Tak projekt może migrować stopniowo, a biblioteka publikować obie wersje obok siebie.
Pułapka dla początkujących: w pliku ESM require i module.exports po prostu nie istnieją. Jeśli sięgniesz po nie z przyzwyczajenia, dostaniesz ReferenceError.
Współpraca: mieszanie obu systemów
Często plik ESM musi użyć paczki CommonJS albo odwrotnie. Zasady nie są symetryczne.
ESM importujący CommonJS działa bezpośrednio. Obiekt module.exports z CJS staje się eksportem domyślnym:
// app.mjs
import greet from "./greet.cjs";
console.log(greet("Rosa"));
Importy nazwane z CommonJS czasem działają (Node próbuje statycznie wykryć nazwane eksporty), ale dla pewności weź eksport domyślny i go zdestrukturyzuj:
import pkg from "./utils.cjs";
const { parse, stringify } = pkg;
CommonJS importujący ESM to bolesny kierunek. Nie możesz zrobić require() modułu ES: rzuci ERR_REQUIRE_ESM. Wyjściem awaryjnym jest dynamiczny import(), który działa także w CJS i zwraca Promise:
Nowoczesny Node (22+) dodał w pewnych warunkach synchroniczny require() dla ESM, ale przenośnym rozwiązaniem pozostaje dynamiczny import().
Inne różnice w działaniu, które warto znać
Poza składnią oba systemy różnią się kilkoma szczegółami, które czasem potrafią ugryźć:
Kilka kolejnych:
thisna najwyższym poziomie. W CJSthistomodule.exports. W ESM toundefined. ESM zawsze działa w trybie ścisłym.__dirnamei__filename. CJS daje je za darmo. W ESM wyprowadzasz je zimport.meta.url:
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
- Rozszerzenia plików w importach. ESM wymaga rozszerzenia (
"./utils.js", a nie"./utils") w ścieżkach względnych. CJS jest pobłażliwy. - Żywe powiązania vs migawki. Importy ESM to żywe referencje do zmiennych modułu eksportującego. CJS daje kopię tego, co było przypisane do
module.exportsw chwili ładowania. Większość kodu nigdy tego nie zauważa, ale ma to znaczenie przy zależnościach cyklicznych.
Którego używać?
W nowym projekcie: ES Modules. Ustaw "type": "module" w package.json i nie oglądaj się za siebie. ESM to standard języka, działa tak samo w przeglądarkach i w Node, obsługuje await na najwyższym poziomie, a narzędzia powstają z myślą o nim.
Zostań przy CommonJS, gdy:
- Utrzymujesz istniejący kod w CJS, a migracja jeszcze się nie opłaca.
- Publikujesz bibliotekę, która musi wspierać bardzo stare wersje Node albo odbiorców, którzy nie mogą używać ESM.
- Kluczowa zależność jest dostępna tylko w CJS, a jej współpraca z ESM jest kłopotliwa. (Dziś rzadko, ale wciąż się zdarza.)
Nawet wtedy będziesz ciągle czytać kod ESM: wszystko, co trafiło na npm w ostatnich latach, zmierza w tę stronę. Znajomość obu nie jest opcjonalna; biegłość w idiomach tego, w którym faktycznie piszesz, również.
Szybka lista kontrolna
Gdy otwierasz nowy plik, zapytaj siebie:
- Czy ten plik używa
import/export, czyrequire/module.exports? Nie mieszaj ich. - Co mówi najbliższy
package.jsono polu"type"? - Jeśli importujesz paczkę, sprawdź jej
package.json: czy dostarcza ESM, CJS, czy oba? - Jeśli trafisz na
ERR_REQUIRE_ESM, jesteś w CJS i próbujesz załadować ESM. Przejdź na dynamicznyimport()albo przenieś wywołujący plik do ESM.
Dziewięćdziesiąt procent zamieszania z modułami w Node to jeden z tych czterech punktów.
Dalej: podstawy npm
Moduły pozwalają dzielić twój kod na pliki. Następny krok to korzystanie z kodu napisanego przez innych, i do tego służy npm. Omówimy instalowanie paczek, zakresy semver i te części pracy z npm, których naprawdę używasz na co dzień.
Najczęściej zadawane pytania
Czym różni się require od import w JavaScript?
require to sposób ładowania modułów w CommonJS: działa synchronicznie, wykonuje się w miejscu wywołania i zwraca to, co moduł przypisał do module.exports. import to składnia ES Modules: jest statyczny, przenoszony na początek pliku i analizowany, zanim kod się uruchomi. Oba systemy różnią się też tym, czym jest this, jak rozwiązują zależności cykliczne i czy pozwalają na await na najwyższym poziomie (pozwala tylko ESM).
CommonJS czy ES Modules w nowym projekcie Node?
Wybierz ES Modules. Ustaw "type": "module" w package.json i pisz import/export. ESM to oficjalny standard, działa w przeglądarkach i w Node oraz obsługuje await na najwyższym poziomie. CommonJS wciąż pojawia się w starszych paczkach i narzędziach, więc będziesz go czytać, nawet jeśli sam go nie piszesz.
Czy można mieszać require i import w jednym projekcie?
Tak, ale według zasad. Plik .mjs albo paczka z "type": "module" używa ESM; .cjs albo "type": "commonjs" używa CJS. ESM może zrobić import modułu CommonJS (jego module.exports staje się eksportem domyślnym). CommonJS nie może bezpośrednio zrobić require() modułu ESM: trzeba użyć dynamicznego import(), który zwraca Promise.