CJS vs ESM, циклические зависимости
В Node.js до сих пор живут две системы модулей. Не «старая и новая, и старая скоро умрёт» — а две полноценные системы с разной механикой, которые будут сосуществовать ещё долго: вся экосистема npm написана на CommonJS, а весь современный инструментарий (Vitest, Vite, TypeScript нативно) — на ESM. Инженер, который понимает как каждая система грузит модули, предсказывает поведение циклических зависимостей и конфигурирует exports, экономит себе дни дебага загадочных undefined и ERR_REQUIRE_ESM.
Краткая версия дала таблицу отличий. Здесь — механика загрузки под капотом, практика интеропа и главное: циклические зависимости, которые всплывают в проде и не воспроизводятся локально.
Механика загрузки: два разных мира
Заголовок раздела «Механика загрузки: два разных мира»CommonJS: синхронный рантайм-конвейер
Заголовок раздела «CommonJS: синхронный рантайм-конвейер»require('./mod.js') — это функция, выполняющаяся во время работы программы. Механика:
- Node резолвит путь (
./mod.js→ абсолютный путь, с учётомnode_modules). - Если модуль уже есть в кэше (
require.cache) — возвращает закэшированныйmodule.exports. - Иначе: создаётся объект
moduleс пустымmodule.exports, код файла выполняется синхронно (обёрнутый в функцию сrequire,module,exports,__dirname,__filenameв области видимости). - После выполнения
module.exportsвозвращается вызывающему.
Это объясняет классику CJS: экспорт — это просто объект, который мутируется во время выполнения. Поэтому работает и module.exports = fn, и exports.helper = 1 (пока ты не перезаписал module.exports целиком — тогда exports отвязывается).
ESM: статический анализ и три фазы
Заголовок раздела «ESM: статический анализ и три фазы»import — не функция, а декларация, которую V8 парсит до выполнения кода. Загрузка модуля проходит три фазы:
- Конструкция (Construction): парсер строит дерево зависимостей: из entry-point рекурсивно находятся все
import’ы, файлы загружаются (асинхронно!), для каждого создаётся record. - Инстанцирование (Instantiation): для каждого модуля выделяются переменные, связываются import’ы с export’ами — до выполнения какого-либо кода. Здесь рождаются Live Bindings:
import { config } from './config.js'— это не копия значения, а живая ссылка на ячейку модуля-источника. - Выполнение (Evaluation): модули выполняются в постфиксном порядке графа (зависимости раньше потребителей), каждый — ровно один раз. Все три фазы подробно описаны в документации Node.js по ESM.
Отсюда три жёстких следствия:
- Hoisting:
import«поднимается» — модуль загружается до того, как начнёт выполняться код файла, независимо от места записи import’а (верх, середина — не важно; линтеры всё равно требуют верх). - Топ-левел await: так как загрузка асинхронна по своей природе,
awaitна верхнем уровне модуля легален — выполнение модуля просто приостановится, а его потребители дождутся в фазе evaluation. - Циклы разрешаются ссылками, а не значениями — об этом ниже.
export const config = { port: 3000 };// модули выполняются один раз: повторный import не пере-выполняет файлimport.meta, __dirname и прочие бытовые мелочи
Заголовок раздела «import.meta, __dirname и прочие бытовые мелочи»В 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).
Интероп: CJS ↔ ESM
Заголовок раздела «Интероп: CJS ↔ ESM»Правила асимметричны, запомни их как есть:
ESM импортирует CJS — работает всегда, Node делает «default interop»:
// logger.cjs (CommonJS)module.exports = { log: (m) => console.log(m) };module.exports.level = 'info';import logger from './logger.cjs'; // default = module.exports целикомimport { level } from './logger.cjs'; // named — работает через статический анализ cjs-module-lexerlogger.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. Возникает из «утилит, импортирующих друг друга», сервисов, тянущих конфиг, а конфиг тянущего логгер из сервисов. Поведение систем разное, и это источник багов «на проде не воспроизводится».
В CommonJS: частично выполненный экспорт
Заголовок раздела «В CommonJS: частично выполненный экспорт»В момент, когда B выполняется внутри загрузки A, A находится наполовину выполненным. require('./a.js') внутри B возвращает текущий module.exports A — а это то, что уже успели присвоить. Обычно — пустой объект {}.
const { getB } = require('./b.js');module.exports.getA = () => 'A';console.log(getB()); // {} — b получил пустой exports a
// b.jsconst a = require('./a.js');module.exports.getB = () => a; // a здесь — {} на момент выполненияЗаметь: код не падает. Он молча работает с неполными данными. Баг проявится, когда кто-то вызовет getB() позже и получит {} вместо ожидаемого. Локально при определённом порядке require’ов в entry-point может «везти» — на проде порядок другой.
В ESM: live bindings и TDZ
Заголовок раздела «В ESM: live bindings и TDZ»ESM вычисляет граф до выполнения и разрывает цикл ссылками. Значение import { a } from './a.js' «оживёт» позже, но на момент выполнения B может быть ещё не инициализировано — область временной мёртвой зоны (TDZ):
import { b } from './b.mjs';export const a = 'A';console.log(b); // ReferenceError: Cannot access 'b' before initialization
// b.mjsimport { a } from './a.mjs';export const b = 'B';console.log(a);Здесь хотя бы падает явно — это честнее CJS. Но есть и мягкий вариант: если a — var-подобное объявление через export let a; a = 'A' — получишь undefined без ошибки.
Как лечить
Заголовок раздела «Как лечить»Лекарство одно в обеих системах — разорвать цикл на уровне структуры, механика выбора зависит от причины:
- Вынести общее в третий модуль. Классика: A и B оба импортируют C, а не друг друга. Решает 80% случаев.
- Инвертировать зависимость через параметр/инъекцию. B не импортирует A, а получает нужное через аргумент функции или DI-контейнер (в NestJS это устроено из коробки — см. главу про DI).
- Отложенный импорт. Если A нужен B только внутри одной функции — импортируй лениво в момент вызова (в CJS:
requireвнутри функции; в ESM:await import()). Цикл остаётся в графе, но выполнение откладывается до готовности обоих модулей. - События/реестр. Общий event emitter или service locator, куда модули регистрируются после инициализации. Приём последний — он скрывает зависимости, но иногда оправдан (плагинные системы).
Пакетные exports: один пакет — много входов
Заголовок раздела «Пакетные exports: один пакет — много входов»Поле 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 — ставится первым).
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»- Перенос проекта на ESM по мелочам. Поменял
requireнаimport, а__dirnameостался —ReferenceError. Чеклист миграции:__dirname/__filename,require.resolve, динамическиеrequireвнутри функций (замена —await import(), и помни про асинхронность). - Ложное спасение от цикла через require внутри функции. Работает, но скрывает структурную проблему: граф остаётся циклическим, рефакторинг превращается в минное поле. Используй как временную меру с TODO.
export defaultтам, где нужен named.export default { a, b, c }ломает Tree Shaking и live bindings; бандлер не может выкинуть неиспользуемые поля. Правило: default — для одной сущности, named — для наборов.- Импорт CJS named-экспортов «наугад».
import { something } from 'old-cjs-lib'может молча датьundefined, еслиcjs-module-lexerне смог распарсить динамическое присваивание. Проверяй: либо default-импорт целиком, либо убедись, что named-экспорты статические. - Публикация пакета без условий import/require. Пакет собран только в ESM: все CJS-потребители ловят
ERR_REQUIRE_ESM. Для библиотек публикуй обе сборки и укажи условия вexports(инструменты: tsup, unbuild делают это из коробки). - Игнорирование циклов «потому что работает». В CJS повезло с порядком загрузки — на проде entry-point другой, порядок другой,
module.exportsполупустой. Любой цикл — технический долг с процентами. - Расширения в ESM-импортах. Node.js в ESM требует полное расширение файла:
import './util'упадёт сERR_MODULE_NOT_FOUND, нужно./util.js. Бандлеры это прощают, нативный Node — нет. Типичная боль при переносе кода с фронта.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Чем require отличается от import механически?
require— рантайн-функция: синхронно выполняет модуль и возвращаетmodule.exports.import— статическая декларация: три фазы (конструкция, инстанцирование, выполнение), live bindings, топ-левел await, выполнение модуля ровно один раз. - Что такое live bindings? В ESM импорты — ссылки на ячейки памяти экспортирующего модуля, а не копии. Если модуль-источник изменит
export let x, потребители увидят новое значение. В CJSrequireвозвращает снимок объекта на момент вызова. - Как в ESM получить __dirname?
path.dirname(fileURLToPath(import.meta.url)).import.meta.url— URL модуля; конвертация черезfileURLToPathобязательна (особенно на Windows). - Как CJS-код импортировать ESM-пакет? Только динамическим
import()— он асинхронен, поэтому потребители становятся async.require(esm)появляется в свежих версиях, но для LST-поддержки рассчитывать нельзя. - Почему циклическая зависимость в CJS даёт
{}? Модуль наполовину выполнен,module.exportsна момент требования — то, что уже присвоено (обычно пустой объект). Код не падает, работает с неполными данными — баг всплывает позже и зависит от порядка загрузки. - Что такое TDZ в циклах ESM? При цикле инстанцирование прошло, а выполнение нет. Доступ к ещё не инициализированному
const/letэкспорту —ReferenceError. Честнее CJS, но всё равно требует разрыва цикла. - Зачем нужно поле exports и что оно ломает? Условный резолвинг (import/require/browser), сабпасы, сокрытие внутренностей. Ломает: любой импорт путей вне перечисленных —
ERR_PACKAGE_PATH_NOT_EXPORTED, плюс старые резолверы без поддержки exports игнорируют поле (нуженmainкак fallback). - Что поставить в package.json нового проекта?
"type": "module", расширения.jsдля ESM-файлов, для библиотек — сборка CJS+ESM иexportsс условиями. В приложениях — только ESM.
Практика
Заголовок раздела «Практика»- Лаборатория циклов. Создай два CJS-модуля с циклом, воспроизведи
{}в импорте. Теперь поменяй порядокrequireв entry-point — поведение изменилось? Повтори эксперимент в ESM (с TDZ), зафиксируй разницу. Разреши оба цикла выносом общего кода в третий модуль. - Миграция модуля. Возьми свой pet-проект или модуль и переведи его с CJS на ESM по чеклисту: импорты,
__dirname, динамические require. Запустиmadge --circularдо и после — циклов не прибавилось? - Пакет с двумя входами. Собери мини-библиотеку через
tsup(CJS + ESM + types), настройexportsс условиямиimport/require/types. Проверь из CJS-потребителя (require) и ESM-потребителя (import) — оба должны работать. Убедись, чтоmy-lib/internalне импортируется. - require(esm) эксперимент. На Node v22+ попробуй
require('./esm-module.mjs')с топ-левел await и без. Зафиксируй, в каком случае работает, и почему это нельзя использовать в библиотеках с поддержкой v20. - madge на CI. Добавь в pet-проект npm-скрипт
"check:deps": "madge --circular --exit-code src"и подключи его к pre-push хуку (husky). Сломай его намеренным циклом, убедись, что пуш отклоняется.
Что почитать
Заголовок раздела «Что почитать»- Node.js: ECMAScript Modules — фазы загрузки, разрешение спецификаторов, интероп.
- Node.js: Modules — CJS — механика require и кэша.
- ESM в V8: live bindings и инстанцирование — как движок грузит модули.
- Package exports — официальная документация — условия, сабпасы, patterns.
- madge — граф зависимостей и поиск циклов.
- Are The Types Wrong? — онлайн-проверка корректности типов и exports пакета.