Strona główna / Artykuły / CommonJS kontra moduły ES: strukturalne podziały stojące za błędami importu w Node

CommonJS kontra moduły ES: strukturalne podziały stojące za błędami importu w Node

Dowiedz się, dlaczego require i import to zasadniczo różne systemy, jak analiza statyczna wpływa na proces tree-shaking, oraz dlaczego domyślne eksporty i importy cyrkularne zachowują się niespójnie w obu przypadkach.

1364 słów

Prawie każdy kłopotliwy błąd modułu, z którym spotykasz się podczas pracy z Node, wynika z jednego faktu, który rzadko jest jasno wyjaśniany. Komunikaty takie jak informacja o braku dostępu do funkcji require w zakresie modułu, błędy składniowe związane z użyciem import poza modułem lub pakiety, które zachowują się niespójnie w zależności od sposobu ich zaimportowania — wszystko to wskazuje na tę samą przyczynę: CommonJS i ES Modules to nie tylko dwa różne sposoby nazewnictwa tej samej koncepcji. Są to dwa zupełnie odrębne systemy. Jeden opiera się na wywoływaniu funkcji, które są realizowane w momencie ich wezwania; drugi natomiast na strukturze, którą silnik może przeanalizować przed uruchomieniem jakichkolwiek operacji. Prawie każda trudność, z którą się spotykasz przy współpracy tych dwóch systemów, jest bezpośrednim skutkiem tej różnicy.

CommonJS: require to po prostu wywołanie funkcji

Latwo zapomnieć, gdy wpiszesz require() tysiąc razy, że nie ma w tym nic magicznego. To zwykła funkcja, a module.exports to zwykły obiekt — oba dostarczane przez Node podczas wykonywania, a nie wbudowane w sam język.

// math.js
function add(a, b) { return a + b; }
module.exports = { add };

// app.js
const math = require("./math.js"); // a plain function call, evaluated when this line runs
console.log(math.add(2, 3));

Ponieważ require zachowuje się jak każda inna funkcja, możesz ją wywoływać warunkowo: w bloku if, w strukturze try/catch lub przy użyciu ścieżki obliczonej z zmiennej — cokolwiek pozwala na to zwykłe wywołanie funkcji.

const driver = require(process.env.DB_DRIVER === "postgres" ? "./pg-driver" : "./sqlite-driver");

Ta elastyczność jest naprawdę przydatna, i to właśnie ją postanowiły poświęcić ES Modules.

ES Modules: Silnik czyta strukturę przed uruchomieniem jakiegokolwiek kodu

import to nie wywołanie funkcji — to deklaracja, a obowiązuje wobec niej zasada, która przy pierwszym spotkaniu zaskakuje praktycznie wszystkich: musi znajdować się na najwyższym poziomie pliku. Nie można jej umieścić wewnątrz warunku, pętli ani ciała funkcji.

// math.mjs
export function add(a, b) { return a + b; }

// app.mjs
import { add } from "./math.mjs"; // not evaluated like a function call
console.log(add(2, 3));
if (needsMath) {
  import { add } from "./math.mjs"; // SyntaxError, this is not allowed
}

To ograniczenie nie jest arbitralną wybrednością. Istnieje dlatego, że moduły ES mają być poddawalne statycznej analizie: zanim jakikolwiek wiersz twojego programu faktycznie zostanie wykonyany, silnik przegląda każde import i export w całym grafie modułów i tworzy kompletny mapę tego, co od czego zależy. To statyczne mapowanie umożliwia technikę tree-shaking – narzędzie do pakowania może przeanalizować ten graf i bezpiecznie usunąć kod, który został wyeksportowany, ale nigdzie nie został zaimportowany, ponieważ zależności są znane z góry, a nie stają się widoczne dopiero w trakcie wykonywania programu. CommonJS nie może zaoferować takiej samej gwarancji, ponieważ wywołania require() mogą być warunkowe, obliczane lub ukryte w logice, która rozwiązuje się dopiero podczas działania programu – właśnie ta elastyczność sprawia, że graf zależności w CommonJS jest niemożliwy do określenia z góry.

To właśnie tutaj współpraca między systemami zaczyna sprawiać problemy. W CommonJS zapis module.exports = something po prostu zastępuje to, czym jest cały moduł — nie istnieje odrębne pojęcie „domyślnego eksportu” oddzielonego od innych eksportów:

// legacy.js
module.exports = function greet(name) {
  return `Hello, ${name}`;
};

Z drugiej strony ESM traktuje domyślny eksport jako wyraźne, strukturalnie odrębne pojęcie:

// modern.mjs
export default function greet(name) {
  return `Hello, ${name}`;
}

Gdy warstwa współpracy Node ładuje plik CommonJS z kodu ESM, bierze całą wartość module.exports i umieszcza ją jako domyślny eksport. To zazwyczaj rozsądne zachowanie, ale powoduje również dokładnie ten rodzaj subtelnych niezgodności, które wprowadzają ludzi w błąd:

import greet from "./legacy.js"; // works: greet is the whole module.exports value
import { greet } from "./legacy.js"; // fails silently or throws, depending on the module
// named destructuring assumes CommonJS explicitly attached named properties,
// which module.exports = function... never did

To pojedyncze niejednoznaczność — czy to, co importujemy, to cały moduł, czy tylko jego określona część — jest przyczyną dużej liczby błędów typu „dlaczego to jest nieskończone”, gdy w bazie kodu łączą się starsze pakiety CommonJS z nowszym kodem opartym na ESM.

Importy cyrkularne rozwiązuje się inaczej, i to rzeczywiście ma znaczenie

Dwa moduły importujące się nawzajem to już delikatna sytuacja w każdym systemie modułowym, ale CommonJS i ESM radzą sobie z tą kruchością w różny sposób, co oznacza, że kod wyglądający identycznie może zachowywać się inaczej w zależności od systemu, w którym jest uruchamiany.

// a.js (CommonJS)
const b = require("./b.js");
console.log("b's value:", b.value);
module.exports = { value: "from a" };

// b.js (CommonJS)
const a = require("./a.js");
console.log("a's value:", a.value); // undefined — a hasn't finished exporting yet
module.exports = { value: "from b" };

CommonJS radzi sobie z tym, zwracając to, czym akurat jest module.exports modułu wymaganego w pętli, w tym dokładnym momencie, nawet jeśli ten moduł jeszcze nie zakończył swojej eksploatacji. Dlatego a.value ma wartość undefined, gdy jest odczytywany z wnętrza b.jsa.js jeszcze nie osiągnął linii przypisania module.exports, zanim b.js o to poprosił.

ESM stosuje inne podejście poprzez tak zwane żywe powiązania: referencje, które pozostają połączone z modułem eksportującym i automatycznie się aktualizują po zakończeniu jego obliczeń, zamiast stanu z chwili importu. Dzięki temu pewne wzory cyrkularne działają poprawnie w ESM, podczas gdy w CommonJS powodowałyby wartość undefined. Jednak to nie czyni importów cyrkularnych dobrym rozwiązaniem w żadnym z tych systemów — jedynie zmienia sposób pojawiania się błędu, zamiast go eliminować.

Praktyczna pułapka: łączenie ich w jednym projekcie

W codziennej praktyce problem nie wynika z kwestii konceptualnych — sprowadza się do konkretnej, powtarzającej się serii błędów:

SyntaxError: Cannot use import statement outside a module
ReferenceError: require is not defined in ES module scope
ReferenceError: exports is not defined

Występują one dlatego, że Node musi określić, w jakim formacie modułu zapisany jest dany plik, i dokonuje tego wywołania za pomocą kilku sygnałów: czy plik kończy się na .mjs, czy na .cjs, a jeśli żadne z tych warunków nie jest spełnione, to co deklaruje najbliższy plik package.json w polu "type". Zawsze wtedy, gdy rzeczywista składnia pliku nie odpowiada temu, jak Node postanowił ją zinterpretować, pojawiają się dokładnie te błędy. Pakiet opublikowany wyłącznie w formacie ESM po prostu nie może być zaimportowany za pomocą require() z kodu CommonJS. Aby go użyć, projekt musi mieć albo asynchroniczną funkcję import() — która, w odróżnieniu od statycznej klauzuli import, zachowuje się jak prawdziwe wywołanie funkcji i może być używana wszędzie, włączając warunki — albo całkowitą migrację kodu używającego tego pakietu na format ESM.

// this works from CommonJS, because import() is a dynamic function call, not a static declaration
async function loadEsmOnlyPackage() {
  const mod = await import("esm-only-package");
  return mod.default;
}

Jedyny fakt leżący u podstaw tego wszystkiego

Każdy pojedynczy problem związany z integracją tych systemów — wymóg umieszczenia instrukcji import na najwyższym poziomie, możliwość usuwania niepotrzebnego kodu w jednym systemie, a brak takiej możliwości w drugim, niezgodne domyślne wartości eksportowane przez moduły oraz różne zachowanie importów cyklicznych — ma swoje źródło w jednej podstawowej przyczynie: CommonJS buduje swój graf modułów dynamicznie, w trakcie wykonywania programu, podczas gdy ESM buduje go statycznie, przed uruchomieniem jakiegokolwiek kodu. Żaden z tych podejść nie stanowi wady w projekcie drugiego; oba odpowiadają na to samo pytanie — „jak pliki mogą od siebie zależeć?” — ale przy zupełnie różnych ograniczeniach i gwarancjach. Trudności, które odczuwasz podczas łączenia tych systemów, nie wynikają z awarii Node’a. Są one skutkiem przymusowego komunikowania się dwóch wewnętrznie spójnych systemów na granicy ich spotkania.

Literatura pokrewna