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