Глубокий interop CJS/ESM: require(esm), именованные экспорты и TLA
require() теперь может загрузить ESM-модуль синхронно — но лишь если в графе нет top-level await, иначе ERR_REQUIRE_ASYNC_MODULE. Шов CJS<->ESM полон ловушек: статический лексер не видит динамические экспорты, а один пакет может загрузиться дважды как два экземпляра.
Твой CJS-входной файл с тех пор, как ты обновился до Node 22, спокойно делает const { parse } = require("./config.mjs") — require() ES-модуля наконец работает, никакого больше ERR_REQUIRE_ESM, и команда тихо удалила костыль с динамическим import(). Потом контрибьютор добавляет в config.mjs одну строку: const flags = await loadFlags() на верхнем уровне, чтобы прочитать файл фича-флагов до экспорта. Следующий деплой падает на старте: Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM graph with top-level await. В самом require ничего не менялось. Изменилось то, что требуемый модуль больше не может завершиться синхронно — а require, по определению, ждать не умеет.
require() для ESM — и стена top-level await
Годами require() для .mjs (да и любого ESM) бросал ERR_REQUIRE_ESM, и единственным мостом из CommonJS в ESM был асинхронный динамический import(). Современный Node (22+, без флага и стабильно в 23/24) снимает это: require() теперь может загрузить ES-модуль синхронно — но лишь при одном жёстком условии. Весь граф ESM, достижимый из требуемого модуля, должен быть полностью синхронным, то есть нигде не содержать top-level await. Если он есть, загрузчик не может завершить вычисление, не уступив управление event loop, а синхронный require уступить не может — поэтому он бросает ERR_REQUIRE_ASYNC_MODULE.
Механизм: когда ты делаешь require() ESM-модуля, Node парсит и линкует граф, и если вычисление доказуемо синхронно, он прогоняет его до конца в том же вызове и отдаёт тебе результат. Этот результат — объект пространства имён модуля, а не CommonJS-овский module.exports. Так что default и именованные экспорты приходят как члены пространства имён:
// config.mjs — no top-level await: require() succeeds
export const port = 8080;
export default { name: "svc" };
// server.cjs
const ns = require("./config.mjs");
ns.port; // 8080 ← named export
ns.default; // { name: "svc" } ← default lives under .default, NOT ns itself
const { port } = require("./config.mjs"); // also fineДобавь один top-level await — и тот же require бросит:
// config.mjs — top-level await: now an async module
const flags = await loadFlags(); // ← TLA: graph is no longer synchronous
export const port = flags.port;
// server.cjs
const ns = require("./config.mjs");
// ⇒ throws ERR_REQUIRE_ASYNC_MODULEКомпромисс резкий и несущий: TLA — это фича, которая делает модуль непригодным для require. Если библиотеку предполагается потреблять CJS-вызывающими через require, top-level await где-либо в её графе — это ломающее изменение, и сбой всплывает на старте у потребителя, а не в тестах библиотеки. Запасной выход, когда асинхронный модуль действительно нужен из CJS, — вернуться к await import("./config.mjs") внутри асинхронной функции, которая умеет ждать event loop.
import для CJS — статический лексер, угадывающий именованные экспорты
У обратного направления свой шов. Когда ESM делает import модуля CommonJS, Node оборачивает его: объект module.exports модуля становится экспортом default. Поэтому import pkg from "cjs-dep" всегда работает, и pkg — ровно то, чем был module.exports. Тонкость — это именованные импорты: import { foo } from "cjs-dep". У CommonJS нет статического списка экспортов, поэтому, чтобы поддержать это, Node прогоняет cjs-module-lexer, быстрый статический анализатор, по исходнику до его выполнения, чтобы угадать имена, которые тот присваивает в exports/module.exports.
Поскольку это статический анализ, он видит только те экспорты, что может прочитать синтаксически — exports.foo = …, module.exports = { foo } и несколько распознаваемых шаблонов. Всё, что присвоено динамически, невидимо: вычисляемый ключ вроде module.exports[name] = … внутри цикла или Object.assign(exports, computedObject). Эти экспорты существуют в рантайме, но лексер не видел их имён, так что именованный импорт привязывается к undefined, хотя свойство прямо там, на default-экспорте.
// plugins.cjs — exports built dynamically
const names = ["alpha", "beta"];
for (const n of names) module.exports[n] = () => `run ${n}`;
// app.mjs
import { alpha } from "./plugins.cjs"; // alpha is undefined — lexer never saw it
import pkg from "./plugins.cjs";
pkg.alpha; // ✅ the function — it WAS exported, just not statically visible
const { beta } = pkg; // ✅ robust fallback: default-import, then destructureНадёжное правило для потребления любой CJS-зависимости из ESM, когда именованные импорты ведут себя плохо: default-импортируй целиком, затем деструктурируй (import pkg from "x"; const { foo } = pkg;). Это достаёт module.exports напрямую и полностью обходит статическую догадку лексера.
▸Почему это работает
Зачем вообще статический лексер, а не просто запустить модуль и прочитать его ключи? Потому что привязки import решаются на этапе линковки, до того как хоть один модуль в графе выполнится — движок должен заранее знать полный набор именованных экспортов каждого модуля, чтобы развести живые привязки. CommonJS же знает свои экспорты только после запуска. cjs-module-lexer решает эту дилемму: он сканирует исходник на этапе линковки и сообщает имена, которые может доказательно счесть присвоенными, так что у ESM-импортёра есть список привязок до выполнения. Он намеренно консервативен — лучше пропустит динамический экспорт (ложноотрицательно → undefined), чем выдумает его — именно поэтому всё вычисляемое проскальзывает мимо.
Interop транспайлера: почему import express from "express" врёт
Большинство команд никогда не писали нативный ESM, импортирующий нативный CJS — они писали TypeScript или Babel. Там import express from "express" работает безупречно, и это усыпляет: люди думают, что нативный ESM ведёт себя так же. Часто это не так. Транспилированный «ESM» на самом деле — CommonJS с маркером __esModule: Babel/TS выдают Object.defineProperty(exports, "__esModule", { value: true }) и, под esModuleInterop, оборачивают каждый default-импорт в шим _interopRequireDefault, который делает module.__esModule ? module : { default: module }. Этот шим сглаживает несовпадение формы default-экспорта — CJS-модуль, чей module.exports является функцией, переносится под .default, чтобы import x from его нашёл.
У нативного ESM в Node никакого такого шима нет. Он применяет правило обёртки один раз (module.exports → default) и останавливается. Поэтому код, полагавшийся на interop транспайлера, может сломаться в день, когда ты переключаешь "type": "module" и запускаешь его нативно: default-импорт, который транспайлер переформировал, теперь попадает на иначе устроенный объект. Урок: эргономика импортов в TypeScript — это слой совместимости, а не рантайм-поведение Node; проверяй зависимости под нативным ESM, прежде чем считать поведение одинаковым.
Более глубокая цена на уровне API возвращает к кэшу модулей (node, юнит 01): пакет, поставляемый и как CJS, и как ESM, может быть загружен как два разных экземпляра — один через require, другой через import — каждый со своей копией каждой переменной и класса. instanceof ломается через границу; синглтон инициализируется дважды; реестр, заполненный через одну половину, пуст в другой. Два экземпляра одного пакета также удваивают его расход памяти и вес бандла — нетривиальная цена в RSS для большой зависимости, загруженной дважды.
Из ESM-модуля тебе нужно потребить CJS-зависимость, чьи именованные экспорты строятся динамически (module.exports[name] = … в цикле), поэтому import { handler } from 'dep' даёт undefined. Каков надёжный фикс?
В ESM нет __dirname, __filename и require
Когда ты мигрируешь утилитный файл на ESM и он падает с __dirname is not defined — это не баг, а намеренное отсутствие. Последний шов — глобали, существующие только в CommonJS. В ESM-модуле __dirname, __filename и require просто не существуют — это были локальные переменные CJS-обёртки модуля. Замены — import.meta.url (собственный file://-URL модуля) и, когда нужно подтянуть CJS-только зависимость, у которой нет ESM-сборки, module.createRequire(import.meta.url), чтобы создать рабочий require, привязанный к текущему файлу:
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
import { createRequire } from "node:module";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); // ESM equivalent of __dirname
const require = createRequire(import.meta.url); // a real require, scoped here
const legacy = require("some-cjs-only-addon"); // pull a CJS dep into ESM, synchronouslyКартину дополняют два режима отказа. Первый: top-level await сериализует старт: модуль, который await-ит на верхнем уровне, блокирует каждого импортёра, пока не разрешится, а цикл TLA-модулей может зайти в дедлок — модуль A ждёт вычисления B, пока B ждёт A. TLA удобен, но он делает вычисление модуля асинхронным — а это ровно то, чего require (выше) не терпит и что растягивает время старта. Второй: условный require() не переводится в import: паттерн вроде if (DEBUG) require("./devtools") невозможен со статическим import, который поднят (hoisted) и безусловен — придётся использовать динамический await import("./devtools") внутри ветки, что снова вносит асинхронность туда, где могло быть синхронно.
CJS-файл делает require('./mod.mjs'). В CI это работает, но в проде бросает ERR_REQUIRE_ASYNC_MODULE после небольшого изменения в mod.mjs. Какое изменение вероятнее всего это вызвало?
- 01При каком условии require() может загрузить ES-модуль, что он возвращает и каков точный сбой, когда условие нарушено?
- 02Почему import { foo } из CommonJS-зависимости иногда возвращает undefined, хотя module.exports.foo существует, и каков надёжный фикс?
Современный Node наконец позволяет CommonJS-у делать require() ES-модуля синхронно, но только когда весь ESM-граф свободен от top-level await; возвращаемое значение — объект пространства имён модуля (именованные экспорты как члены, default под .default), и любой top-level await где-либо делает модуль асинхронным и приводит к ERR_REQUIRE_ASYNC_MODULE — а значит, добавление TLA в библиотеку это ломающее изменение для её require-потребителей. В другом направлении ESM, импортирующий CommonJS, превращает module.exports в default-экспорт и опирается на статический cjs-module-lexer для обнаружения именованных экспортов; поскольку этот анализ выполняется до запуска, он не видит динамически присвоенные экспорты (вычисляемые ключи, Object.assign), поэтому именованный импорт даёт undefined, а надёжный фикс — default-импортировать, затем деструктурировать. Транспилированный ESM на самом деле — CJS с маркером __esModule и шимами _interopRequireDefault, поэтому import express from "express" работает под TypeScript, а нативный ESM — без такого шима — может вести себя иначе; и пакет, опубликованный и как CJS, и как ESM, может загрузиться как два расходящихся экземпляра, что ломает instanceof, удваивает синглтоны и раздувает RSS. Наконец, в ESM нет __dirname/__filename/require, поэтому используй import.meta.url плюс createRequire(import.meta.url), чтобы подтянуть CJS-только зависимость, помня, что top-level await сериализует старт и может зациклиться в дедлок, а у условного require() нет статического эквивалента в import. Теперь, когда в следующий раз увидишь именованный импорт, вернувший undefined, или ERR_REQUIRE_ASYNC_MODULE после безобидного изменения, — у тебя есть ментальная модель, чтобы поставить диагноз меньше чем за минуту.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.