Головна / Статті / CommonJS проти ES Modules: структурний розрив, що лежить в основі помилок імпорту в Node

CommonJS проти ES Modules: структурний розрив, що лежить в основі помилок імпорту в Node

Дізнайтеся, чому оператори require та import є фундаментально різними механізмами, як статичний аналіз впливає на процес усунення зайвих елементів, та чому значення за замовчуванням для експорту та циклічні імпорти поводяться неоднаково в обох системах.

1364 слів

Майже кожна заплутана помилка модуля, з якою ви стикаєтесь під час роботи з 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 — це дві внутрішньо послідовні системи, які змушені взаємодіяти на межі їхнього зустрічання.

Пов’язана література