CommonJS проти ES Modules: структурний розрив, що лежить в основі помилок імпорту в Node
Дізнайтеся, чому оператори require та import є фундаментально різними механізмами, як статичний аналіз впливає на процес усунення зайвих елементів, та чому значення за замовчуванням для експорту та циклічні імпорти поводяться неоднаково в обох системах.
Майже кожна заплутана помилка модуля, з якою ви стикаєтесь під час роботи з Node, пов’язана з одним фактом, який рідко пояснюється чітко. Повідомлення про те, що в межах модуля відсутнє об’єкт require, помилки синтаксису щодо використання import поза межами модуля чи пакет, який поводиться по-різному залежно від способу його імпорту — усе це вказує на одну й ту саму причину: CommonJS та ES Modules — це не просто два варіанти написання однієї ідеї. Це дві абсолютно різні системи. Одна заснована на викликах функцій, які виконуються миттєво після їх запуску; інша — на структурі, яку движок може перевірити *до* того, як щось по-справжньому запуститься. Майже кожна проблема, з якою ви стикаєтесь під час взаємодії цих двох систем, є прямим наслідком цього розходження.
CommonJS: require — це просто виклик функції
Легко забути, коли ви вже написали require() тисячу разів, що в цьому немає нічого магічного. Це звичайна функція, а module.exports — звичайний об’єкт; обидва надаються Node під час виконання, а не є частиною самої мови.
// 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));
Оскільки require поводиться як будь-яка інша функція, ви можете викликати її умовно: всередині блоку if, всередині конструкції try/catch, або з шляхом, обчисленим з мінливої — будь-що, що дозволяє звичайний виклик функції.
const driver = require(process.env.DB_DRIVER === "postgres" ? "./pg-driver" : "./sqlite-driver");
Ця гнучкість справді корисна, і саме її ES Modules вирішили пожертвувати.
ES Modules: двигун читає структуру перед тим, як виконувати будь-який код
import — це не виклик функції, а оголошення, і до нього існує правило, яке майже завжди здивовує новачків: воно має знаходитися на верхньому рівні файлу. Його не можна розміщувати всередині умови, циклу чи тіла функції.
// 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
}
Ця обмеження не є довільною вибірковістю. Вони існують тому, що ES Modules призначені для статичного аналізу: ще до того, як будь-який рядок вашої програми почне виконуватися, двигун пройде через кожен import та export у всій структурі модулів та створить повну карту залежностей. Саме ця статична карта дозволяє використовувати технологію tree-shaking — бандлер може переглянути цю структуру та безпечно видалити код, який експортується, але ніде не імпортується, оскільки залежності відомі заздалегідь, а не стають видимими лише під час виконання. CommonJS не може дати такої ж гарантії, оскільки виклики require() можуть бути умовними, обчислюваними чи розташованими всередині логіки, яка реалізується лише під час виконання програми — саме ця гнучкість робить структуру залежностей CommonJS неможливою до визначення заздалегідь.
Це там, де взаємодія починає створювати проблеми. У CommonJS запис module.exports = something просто замінює все, чим є весь модуль — не існує окремого поняття „стандартного експорту“, яке б відрізнялося від будь-якого іншого експорту:
// legacy.js
module.exports = function greet(name) {
return `Hello, ${name}`;
};
Натомість у ESM стандартний експорт розглядається як чітке, структурно окреме поняття:
// modern.mjs
export default function greet(name) {
return `Hello, ${name}`;
}
Коли шар взаємодії Node завантажує файл CommonJS з коду ESM, він бере всю значення module.exports та обгортає його як стандартний експорт. Це зазвичай є розумною поведінкою, але водночас створює саме ту приховану невідповідність, яка спричиняє проблеми:
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
Саме ця неоднозначність — чи імпортується весь модуль, чи лише окрема його частина — є причиною більшої частини помилок типу „чому це невизначене“, коли кодова база поєднує старі пакети CommonJS із новішим кодом, орієнтованим на ESM.
Циклічні імпорти вирішуються по-різному, і це справді має значення
Ситуація, коли два модулі імпортують один одного, вже є складною у будь-якій системі модулів, але CommonJS та ESM вирішують цю крихкість по-різному, що означає, що код з однаковим виглядом може працювати по-різному залежно від того, у якій системі він виконується.
// 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 вирішує цю проблему шляхом повернення того, що саме є значенням module.exports модуля, який потрібен у циклічній залежності, саме в ту мить, навіть якщо цей модуль ще не завершив виконання. Саме тому a.value має значення undefined, коли його читають зсередини b.js — до того, як b.js звернувся за ним, a.js ще не виконав оператор присвоєння module.exports.
ESM використовує інший підхід через так звані живі прив’язки: посилання, які залишаються пов’язаними з модулем-експортером та автоматично оновлюються після завершення обробки цього модуля, замість фіксованої копії на момент імпорту. Це означає, що певні циклічні схеми працюють коректно в ESM, тоді як у CommonJS вони призводять до значення undefined. Однак це не робить циклічні імпорти хорошою ідеєю в жодній з систем — це лише змінює спосіб прояву помилки, а не усуває її.
Практична пастка: поєднання їх у одному проекті
У повсякденній практиці проблема полягає не стільки у концептуальних аспектах, скільки у конкретному наборі постійно повторюваних помилок:
SyntaxError: Cannot use import statement outside a module
ReferenceError: require is not defined in ES module scope
ReferenceError: exports is not defined
Вони з’являються тому, що Node мусить визначити, у якому форматі модуля написаний даний файл, і для цього використовує кілька критеріїв: чи закінчується файл на .mjs, чи на .cjs, або, якщо жоден з цих варіантів не підходить, що вказано у найближчому файлі package.json у полі "type". Кожного разу, коли фактична синтаксис файлу не відповідає тому, як Node вирішив його інтерпретувати, з’являються саме такі помилки. Пакет, опублікований виключно у форматі ESM, просто не може бути завантажений за допомогою require() з коду CommonJS. Щоб його використати, проекту потрібен асинхронний import() — який, на відміну від статичного ключового слова import, поводиться як справжній виклик функції та може використовуватися будь-де, включаючи умовні оператори — або повна міграція коду, який його використовує, у формат 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;
}
Єдиний факт, який лежить в основі всього цього
Кожна окрема проблема — вимога розміщувати import на верхньому рівні, можливість використання технології tree-shaking у одній системі, але не в іншій, неузгоджені значення параметра default exports та різна поведінка циклічних імпортів — має єдину основну причину: CommonJS створює свою графіку модулів динамічно під час виконання програми, тоді як ESM створює її статично, ще до виконання будь-якого коду. Жоден з цих підходів не є недоліком іншого; обидва відповідають на одне й те саме запитання — „як файли залежать один від одного?“ — але з суттєво різними обмеженнями та гарантіями. Труднощі, які виникають під час їх поєднання, не пов’язані з пошкодженням Node — це дві внутрішньо послідовні системи, які змушені взаємодіяти на межі їхнього зустрічання.
Пов’язана література
- Кеш мап джерела коду Node є тихим витоком пам’яті у режимі розробки — Дізнайтеся, чому увімкнення параметрів --enable-source-maps або NODE_V8_COVERAGE може спричинити необмежене зростання об’єму пам’яті через постійні виклики eval, та як сьогодні діагностувати та усунути цю проблему.
- Співвідношення журналів подій між асинхронними викликами за допомогою AsyncLocalStorage — Дізнайтеся, як AsyncLocalStorage у Node.js відстежує контекст кожного запиту, такий як requestId, між асинхронними операціями без необхідності ручного передавання цих даних через кожну функцію.