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 изнутри b.js оно выступает как undefined — к моменту запроса из 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. Это следствие попытки заставить две внутренне последовательные системы обмениваться данными на границе их взаимодействия.