Перейти к содержимому

CJS vs ESM, циклические зависимости

В Node.js до сих пор живут две системы модулей. Не «старая и новая, и старая скоро умрёт» — а две полноценные системы с разной механикой, которые будут сосуществовать ещё долго: вся экосистема npm написана на CommonJS, а весь современный инструментарий (Vitest, Vite, TypeScript нативно) — на ESM. Инженер, который понимает как каждая система грузит модули, предсказывает поведение циклических зависимостей и конфигурирует exports, экономит себе дни дебага загадочных undefined и ERR_REQUIRE_ESM.

Краткая версия дала таблицу отличий. Здесь — механика загрузки под капотом, практика интеропа и главное: циклические зависимости, которые всплывают в проде и не воспроизводятся локально.

require('./mod.js') — это функция, выполняющаяся во время работы программы. Механика:

  1. Node резолвит путь (./mod.js → абсолютный путь, с учётом node_modules).
  2. Если модуль уже есть в кэше (require.cache) — возвращает закэшированный module.exports.
  3. Иначе: создаётся объект module с пустым module.exports, код файла выполняется синхронно (обёрнутый в функцию с require, module, exports, __dirname, __filename в области видимости).
  4. После выполнения module.exports возвращается вызывающему.

Это объясняет классику CJS: экспорт — это просто объект, который мутируется во время выполнения. Поэтому работает и module.exports = fn, и exports.helper = 1 (пока ты не перезаписал module.exports целиком — тогда exports отвязывается).

import — не функция, а декларация, которую V8 парсит до выполнения кода. Загрузка модуля проходит три фазы:

  1. Конструкция (Construction): парсер строит дерево зависимостей: из entry-point рекурсивно находятся все import’ы, файлы загружаются (асинхронно!), для каждого создаётся record.
  2. Инстанцирование (Instantiation): для каждого модуля выделяются переменные, связываются import’ы с export’ами — до выполнения какого-либо кода. Здесь рождаются Live Bindings: import { config } from './config.js' — это не копия значения, а живая ссылка на ячейку модуля-источника.
  3. Выполнение (Evaluation): модули выполняются в постфиксном порядке графа (зависимости раньше потребителей), каждый — ровно один раз. Все три фазы подробно описаны в документации Node.js по ESM.

Отсюда три жёстких следствия:

  • Hoisting: import «поднимается» — модуль загружается до того, как начнёт выполняться код файла, независимо от места записи import’а (верх, середина — не важно; линтеры всё равно требуют верх).
  • Топ-левел await: так как загрузка асинхронна по своей природе, await на верхнем уровне модуля легален — выполнение модуля просто приостановится, а его потребители дождутся в фазе evaluation.
  • Циклы разрешаются ссылками, а не значениями — об этом ниже.
config.js
export const config = { port: 3000 };
// модули выполняются один раз: повторный import не пере-выполняет файл

В CJS модули оборачивались в функцию с удобными переменными. В ESM их нет, замены такие:

CommonJS ESM-эквивалент
__dirname path.dirname(fileURLToPath(import.meta.url))
__filename fileURLToPath(import.meta.url)
require createRequire(import.meta.url)
module.exports export / export default
require.resolve import.meta.resolve (или createRequire)
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

import.meta.url — это URL модуля (file:///home/user/proj/config.js), а не путь; конвертировать нужно всегда через fileURLToPath, особенно на Windows, где прямое использование URL в path.* ломается (см. import.meta.url в документации ESM).

Правила асимметричны, запомни их как есть:

ESM импортирует CJS — работает всегда, Node делает «default interop»:

// logger.cjs (CommonJS)
module.exports = { log: (m) => console.log(m) };
module.exports.level = 'info';
app.mjs
import logger from './logger.cjs'; // default = module.exports целиком
import { level } from './logger.cjs'; // named — работает через статический анализ cjs-module-lexer
logger.log(level);

Node пытается распарсить named-экспорты из CJS через cjs-module-lexer; с простыми присваиваниями exports.foo = это работает, с динамическими — нет (тогда только default).

CJS импортирует ESM — по умолчанию нельзя: require('./mod.mjs')ERR_REQUIRE_ESM. Динамический import() из CJS — можно (это рантайн-вызов):

// старый CJS-файл хочет новый ESM-пакет
async function main() {
const { nanoid } = await import('nanoid'); // динамический import — асинхронен
return nanoid();
}

В Node.js 20.10+/22 появился require(esm) за флагом --experimental-require-module, а в v23+ — без флага для модулей без топ-левел await. Тренд ясен: интероп станет двусторонним, но в коде, который должен работать на LTS-версиях (v20/v22), рассчитывать на require(esm) нельзя.

Циклические зависимости: болезнь и лечение

Заголовок раздела «Циклические зависимости: болезнь и лечение»

Модуль A импортирует B, B импортирует A. Возникает из «утилит, импортирующих друг друга», сервисов, тянущих конфиг, а конфиг тянущего логгер из сервисов. Поведение систем разное, и это источник багов «на проде не воспроизводится».

В момент, когда B выполняется внутри загрузки A, A находится наполовину выполненным. require('./a.js') внутри B возвращает текущий module.exports A — а это то, что уже успели присвоить. Обычно — пустой объект {}.

a.js
const { getB } = require('./b.js');
module.exports.getA = () => 'A';
console.log(getB()); // {} — b получил пустой exports a
// b.js
const a = require('./a.js');
module.exports.getB = () => a; // a здесь — {} на момент выполнения

Заметь: код не падает. Он молча работает с неполными данными. Баг проявится, когда кто-то вызовет getB() позже и получит {} вместо ожидаемого. Локально при определённом порядке require’ов в entry-point может «везти» — на проде порядок другой.

ESM вычисляет граф до выполнения и разрывает цикл ссылками. Значение import { a } from './a.js' «оживёт» позже, но на момент выполнения B может быть ещё не инициализировано — область временной мёртвой зоны (TDZ):

a.mjs
import { b } from './b.mjs';
export const a = 'A';
console.log(b); // ReferenceError: Cannot access 'b' before initialization
// b.mjs
import { a } from './a.mjs';
export const b = 'B';
console.log(a);

Здесь хотя бы падает явно — это честнее CJS. Но есть и мягкий вариант: если avar-подобное объявление через export let a; a = 'A' — получишь undefined без ошибки.

Лекарство одно в обеих системах — разорвать цикл на уровне структуры, механика выбора зависит от причины:

  1. Вынести общее в третий модуль. Классика: A и B оба импортируют C, а не друг друга. Решает 80% случаев.
  2. Инвертировать зависимость через параметр/инъекцию. B не импортирует A, а получает нужное через аргумент функции или DI-контейнер (в NestJS это устроено из коробки — см. главу про DI).
  3. Отложенный импорт. Если A нужен B только внутри одной функции — импортируй лениво в момент вызова (в CJS: require внутри функции; в ESM: await import()). Цикл остаётся в графе, но выполнение откладывается до готовности обоих модулей.
  4. События/реестр. Общий event emitter или service locator, куда модули регистрируются после инициализации. Приём последний — он скрывает зависимости, но иногда оправдан (плагинные системы).

Поле exports в package.json — современный способ описать, что и как можно импортировать из пакета (формальная спецификация условий и сабпасов — в документации Node.js «Package exports»). Оно заменяет старую связку main + ручное лазанье в node_modules, даёт условный резолвинг и скрывает внутренности.

{
"name": "my-lib",
"type": "module",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.mjs"
},
"./utils": {
"import": "./dist/utils.mjs",
"require": "./dist/utils.cjs"
},
"./package.json": "./package.json"
}
}
  • Условия import/require выбирают сборку под систему модулей потребителя — так пакеты поставляют и CJS, и ESM версии одновременно.
  • Ключ "./utils" даёт сабпас импорта my-lib/utils, а всё, что не перечислено в exports, недоступно: import 'my-lib/dist/internal.js' упадёт с ERR_PACKAGE_PATH_NOT_EXPORTED. Это фича: внутренняя структура больше не публичный API.
  • Поле exports совместимо с main: старые версии Node и резолверы без поддержки exports используют main как fallback.

Есть и более специализированные условия: node/browser (платформа), development/production (сборки с/без проверок, включаются через --conditions=development), types (пути к .d.ts для TS — ставится первым).

  1. Перенос проекта на ESM по мелочам. Поменял require на import, а __dirname остался — ReferenceError. Чеклист миграции: __dirname/__filename, require.resolve, динамические require внутри функций (замена — await import(), и помни про асинхронность).
  2. Ложное спасение от цикла через require внутри функции. Работает, но скрывает структурную проблему: граф остаётся циклическим, рефакторинг превращается в минное поле. Используй как временную меру с TODO.
  3. export default там, где нужен named. export default { a, b, c } ломает Tree Shaking и live bindings; бандлер не может выкинуть неиспользуемые поля. Правило: default — для одной сущности, named — для наборов.
  4. Импорт CJS named-экспортов «наугад». import { something } from 'old-cjs-lib' может молча дать undefined, если cjs-module-lexer не смог распарсить динамическое присваивание. Проверяй: либо default-импорт целиком, либо убедись, что named-экспорты статические.
  5. Публикация пакета без условий import/require. Пакет собран только в ESM: все CJS-потребители ловят ERR_REQUIRE_ESM. Для библиотек публикуй обе сборки и укажи условия в exports (инструменты: tsup, unbuild делают это из коробки).
  6. Игнорирование циклов «потому что работает». В CJS повезло с порядком загрузки — на проде entry-point другой, порядок другой, module.exports полупустой. Любой цикл — технический долг с процентами.
  7. Расширения в ESM-импортах. Node.js в ESM требует полное расширение файла: import './util' упадёт с ERR_MODULE_NOT_FOUND, нужно ./util.js. Бандлеры это прощают, нативный Node — нет. Типичная боль при переносе кода с фронта.
  1. Чем require отличается от import механически? require — рантайн-функция: синхронно выполняет модуль и возвращает module.exports. import — статическая декларация: три фазы (конструкция, инстанцирование, выполнение), live bindings, топ-левел await, выполнение модуля ровно один раз.
  2. Что такое live bindings? В ESM импорты — ссылки на ячейки памяти экспортирующего модуля, а не копии. Если модуль-источник изменит export let x, потребители увидят новое значение. В CJS require возвращает снимок объекта на момент вызова.
  3. Как в ESM получить __dirname? path.dirname(fileURLToPath(import.meta.url)). import.meta.url — URL модуля; конвертация через fileURLToPath обязательна (особенно на Windows).
  4. Как CJS-код импортировать ESM-пакет? Только динамическим import() — он асинхронен, поэтому потребители становятся async. require(esm) появляется в свежих версиях, но для LST-поддержки рассчитывать нельзя.
  5. Почему циклическая зависимость в CJS даёт {}? Модуль наполовину выполнен, module.exports на момент требования — то, что уже присвоено (обычно пустой объект). Код не падает, работает с неполными данными — баг всплывает позже и зависит от порядка загрузки.
  6. Что такое TDZ в циклах ESM? При цикле инстанцирование прошло, а выполнение нет. Доступ к ещё не инициализированному const/let экспорту — ReferenceError. Честнее CJS, но всё равно требует разрыва цикла.
  7. Зачем нужно поле exports и что оно ломает? Условный резолвинг (import/require/browser), сабпасы, сокрытие внутренностей. Ломает: любой импорт путей вне перечисленных — ERR_PACKAGE_PATH_NOT_EXPORTED, плюс старые резолверы без поддержки exports игнорируют поле (нужен main как fallback).
  8. Что поставить в package.json нового проекта? "type": "module", расширения .js для ESM-файлов, для библиотек — сборка CJS+ESM и exports с условиями. В приложениях — только ESM.
  1. Лаборатория циклов. Создай два CJS-модуля с циклом, воспроизведи {} в импорте. Теперь поменяй порядок require в entry-point — поведение изменилось? Повтори эксперимент в ESM (с TDZ), зафиксируй разницу. Разреши оба цикла выносом общего кода в третий модуль.
  2. Миграция модуля. Возьми свой pet-проект или модуль и переведи его с CJS на ESM по чеклисту: импорты, __dirname, динамические require. Запусти madge --circular до и после — циклов не прибавилось?
  3. Пакет с двумя входами. Собери мини-библиотеку через tsup (CJS + ESM + types), настрой exports с условиями import/require/types. Проверь из CJS-потребителя (require) и ESM-потребителя (import) — оба должны работать. Убедись, что my-lib/internal не импортируется.
  4. require(esm) эксперимент. На Node v22+ попробуй require('./esm-module.mjs') с топ-левел await и без. Зафиксируй, в каком случае работает, и почему это нельзя использовать в библиотеках с поддержкой v20.
  5. madge на CI. Добавь в pet-проект npm-скрипт "check:deps": "madge --circular --exit-code src" и подключи его к pre-push хуку (husky). Сломай его намеренным циклом, убедись, что пуш отклоняется.