Конец двойных сборок CJS/ESM в Node.js
Node.js теперь поддерживает require(esm), и ESM-only стал вариантом по умолчанию для многих библиотек. Когда отказаться от CJS, избежать top-level await и мигрировать безопасно.
Начиная с июня 2026 года, каждый поддерживаемый релиз Node.js поддерживает require() для ES-модулей, что устраняет единственную причину, по которой большинство библиотек когда-либо поставлялось в виде двойных сборок CommonJS/ESM. Для всё большего числа пакетов ESM-only теперь является правильным выбором по умолчанию. Асимметрия, определявшая десятилетие мучений с упаковкой — CommonJS не мог ни import, ни require ничего из мира ESM — больше не существует ни в одной среде выполнения, которую вы должны поддерживать. Двойная карта exports, параллельные выходные данные tsup/unbuild, жонглирование объявлениями .d.cts/.d.ts — большая часть этого механизма существовала для решения проблемы, которую Node теперь решил на уровне ядра.
Эта статья делает аргумент 2026 года, который старые руководства по двойным сборкам не могут сделать: здесь приведена точная временная шкала версий, где умерла асимметрия, что на самом деле делает require(esm) и одно жёсткое ограничение, которое он несёт, а также система принятия решений о том, нужна ли вам вообще сборка CommonJS. Ограничение не исчезло — оно переместилось. Новый контракт совместимости — не «поставляй два формата»; это «держи синхронный путь загрузки свободным от top-level await».
Ключевые выводы
- Начиная с Node.js 25.4.0 (выпущен 19 января 2026 года),
require(esm)помечен как стабильный, и то же изменение было бэкпортировано в активные LTS-ветки — это означает, что каждый поддерживаемый в настоящее время релиз Node.js поставляется с возможностьюrequire()ES-модуля. require(esm)впервые появился за флагом--experimental-require-moduleв Node 22, был разблокирован в Node 23, бэкпортирован в LTS в v22.12.0 (3 декабря 2024 года) и v20.19.0, и объявлен стабильным в конце 2025 года.require(esm)имеет ровно одно жёсткое ограничение: он не может загрузить ES-модуль, граф которого использует top-level await, что выбрасываетERR_REQUIRE_ASYNC_MODULEи предписывает использовать вместо негоimport().- Для автора ESM-only первый top-level
awaitв любом месте вашего require-доступного графа является критическим изменением для каждого потребителя CommonJS — рассматривайте его как semver-major. - Если ваш пакет ориентирован на Node 22.12+ и избегает top-level await в коде, который пользователи CJS будут
require()-ить, поставка только ESM теперь является правильным выбором по умолчанию; сохраняйте сборку CJS только для сред выполнения до 20.19 или модулей с TLA.
Почему вообще существовали двойные сборки CJS/ESM
Двойные сборки существовали потому, что CommonJS не мог require() ES-модуль. Две системы загружаются по-разному: require() является синхронным и возвращает module.exports в момент завершения вызова, тогда как ESM считался безусловно асинхронным. Синхронный вызывающий не может ожидать асинхронной загрузки, поэтому require('some-esm-package') выбрасывал ERR_REQUIRE_ESM. Обратное направление всегда работало — ESM может import CommonJS — что породило однобокую ситуацию, с которой авторы библиотек жили годами: поставляй ESM для современных потребителей, поставляй CommonJS для всех, кто ещё вызывает require(), и соединяй оба через условный exports.
Это означало реальные накладные расходы на инструментарий. Бандлеры, такие как tsup и unbuild, генерируют оба формата; карта exports в package.json направляет import к записи .mjs, а require — к записи .cjs; TypeScript нуждается в расположенных рядом объявлениях .d.ts и .d.cts, чтобы оба режима разрешения проходили проверку типов. Руководство по двойным сборкам Энтони Фу 2021 года и пошаговое руководство Маянка 2023 года подробно документируют этот механизм — и оба по-прежнему точны в том, как это делать. Они просто отвечают на вопрос, который для текущих сред выполнения больше не нужно задавать.
Двойные сборки также несли структурный риск: опасность двойного пакета. Когда граф зависимостей загружает ваш пакет через import в одном месте и через require в другом, Node может загрузить две отдельные копии — сборку ESM и сборку CJS — как отдельные экземпляры модуля. Любой синглтон, кэш, реестр или проверка instanceof тогда видит два расходящихся состояния. Двойная сборка, решившая проблему совместимости, незаметно создала проблему дублирования состояния.
require(esm): точные версии, где умерла асимметрия
Discover how at OpenReplay.com.
Исправление пришло из переосмысления давно устоявшегося предположения. Как задокументировал Джойи Чун, участник ядра Node, ESM сам по себе не был разработан как безусловно асинхронный — скорее, он был разработан как условно асинхронный, только когда граф содержит top-level await, поэтому было бы естественным, чтобы require() по крайней мере поддерживал графы ESM, не содержащие top-level await. Это понимание сделало возможным синхронный require() (большинства) ES-модулей, и require(esm) был построен на нём.
Развёртывание происходило поэтапно в разных ветках релизов. Вот временная шкала по состоянию на июнь 2026 года:
| Ветка Node.js | Статус require(esm) | Фаза поддержки (июнь 2026) |
|---|---|---|
| 18.x | Бэкпорт так и не был получен | EOL — необходимо перейти на 20+ |
| 20.x | Разблокирован в v20.19.0 | EOL 30 апреля 2026 |
| 22.x | Включён по умолчанию в v22.12.0 (3 дек. 2024) | Maintenance LTS |
| 23.x | Разблокирован (не-LTS) | EOL |
| 24.x | Стабильная пометка бэкпортирована в v24.15.0 (15 апр. 2026) | Active LTS |
| 25.x | Помечен стабильным в v25.4.0 (19 янв. 2026) | EOL 1 июня 2026 |
| 26.x | Стабильный | Current |
Главное: в релизе v25.4.0 изменение «module: mark require(esm) as stable» (PR #60959) убрало экспериментальную пометку, и тот же коммит был бэкпортирован в LTS-ветку в v24.15.0. Функция была разблокирована по умолчанию задолго до стабилизации: Node 22.12.0 стал первым LTS-релизом с ней, включённой по умолчанию, и она была бэкпортирована в Node 20 в v20.19.0. Node 18 так и не получил бэкпорт.
Согласно расписанию релизов Node.js, поддерживаемыми ветками в июне 2026 года являются 22 (Maintenance LTS), 24 (Active LTS, активная поддержка до 20 октября 2026 года, затем поддержка безопасности до 30 апреля 2028 года) и 26 (Current). Все три находятся выше порога разблокировки. Поскольку Node 18 так и не получил бэкпорт, а Node 20 достиг конца жизни 30 апреля 2026 года, минимальная версия, на которую должен ориентироваться любой поддерживаемый проект, уже включает require(esm).
Что require(esm) меняет для авторов библиотек
Потребитель CommonJS на текущей версии Node теперь может напрямую require() пакет, поставляемый только в формате ESM. Первоначальное обоснование для поставки сборки CJS — что вызывающие require() иначе будут заблокированы — больше не действует ни в одной поддерживаемой среде выполнения. Как описывает документация Node.js, если загружаемый ES-модуль соответствует требованиям, require() может загрузить его и вернуть объект пространства имён модуля; в этом случае это похоже на динамический import(), но выполняется синхронно и напрямую возвращает объект пространства имён.
Это также устраняет опасность двойного пакета. Поскольку вызывающий CommonJS теперь загружает настоящий ES-модуль вместо параллельной копии CJS, существует один экземпляр модуля, один синглтон, один кэш — проблема расходящихся состояний, оправдывавшая тщательные двойные сборки, просто не возникает, когда есть только одна сборка.
Одна деталь совместимости важна при отказе от обёртки CJS. require(esm) возвращает объект пространства имён, а не голое значение, поэтому экспорт по умолчанию оказывается в .default, а не является самим возвращаемым значением, аналогично результатам, возвращаемым import(). Если вам нужно единственное возвращаемое значение в стиле CommonJS, ES-модуль может экспортировать нужное значение, используя строковое имя "module.exports", чтобы настроить то, что require(esm) возвращает напрямую.
Вы можете обнаружить поддержку во время выполнения, когда вам нужен запасной путь, проверив, является ли process.features.require_module значением true.
// Обнаружение функции во время выполнения — true на Node 20.19+, 22.12+ и всех 24/26.
if (process.features.require_module) {
const lib = require("some-esm-only-package");
// экспорт по умолчанию находится в .default
const fn = lib.default ?? lib;
}
Единственное ограничение: top-level await — новый контракт совместимости
require(esm) имеет ровно одно жёсткое ограничение: он не может загрузить ES-модуль, граф которого использует top-level await. Поскольку require() должен оставаться синхронным, файл ESM, который приостанавливает своё собственное выполнение на top-level await, не может быть загружен таким образом. Если модуль, к которому обращается require(), содержит top-level await, или граф модуля, который он импортирует, содержит top-level await, будет выброшен ERR_REQUIRE_ASYNC_MODULE, и пользователям следует загружать асинхронный модуль с помощью import(). Выброшенное сообщение явное: «require() cannot be used on an ESM graph with top-level await. Use import() instead.»
Ключевое слово — граф. Ограничение касается не файла, который вы require-ите — оно касается всего, что этот файл транзитивно импортирует.
Реальный задокументированный инцидент демонстрирует масштаб последствий. В апреле 2026 года lru-cache@11.3.0 ввёл top-level await в свою ESM-сборку, что сломало любой CJS-модуль, транзитивно загружавший ESM-сборку lru-cache, в первую очередь jsdom через @asamuzakjp/css-color (который является чистым ESM без точки входа CJS). Цепочка выглядела так: jsdom (CJS) → чистый ESM-пакет цветов → теперь асинхронная ESM-запись lru-cache. Карта exports корректно направляла require к CJS, а import к ESM; но когда CJS-пакет требовал чистый ESM-пакет, Node разрешал граф ESM, и внутри этого графа точка входа ESM lru-cache — теперь содержащая TLA — делала весь граф невозможным для синхронного require(). Мейнтейнер откатил top-level await в последующем патче, так что поломка устранена — но это доказывает, что данный режим отказа проявляется в production. Тот же каскад ERR_REQUIRE_ASYNC_MODULE затронул Prettier и firebase-tools, когда Node 22.12.0 включил эту функцию.
require(esm) переформулирует всю проблему: он устраняет причину совместимости для двойных сборок, но делает отсутствие TLA контрактом. Для автора ESM-only первый top-level await, который вы добавляете в любом месте вашего require-доступного графа, является критическим изменением для каждого потребителя CommonJS. Как утверждает Эверт Пот, если это первый await, вы можете непреднамеренно сломать пользователей Node.js, которые использовали require() для подключения вашего модуля — это означает, что первый top-level await в вашем проекте или любой из ваших зависимостей может теперь означать новую мажорную версию, если вы следуете semver. Рассматривайте его как semver-major.
Top-level await действительно редко встречается в коде библиотек. Когда Чун впервые тестировала реализацию, ни один из ~30 протестированных высокоиспользуемых пакетов ESM-only не содержал top-level await — именно поэтому синхронный require(esm) охватывает подавляющее большинство реальных пакетов.
Нужна ли вам ещё сборка CJS в 2026 году?
Для большинства новых пакетов — нет. По умолчанию используйте только ESM и прибегайте к двойной сборке только тогда, когда конкретное ограничение вынуждает вас к этому. Ответьте на три вопроса:
- Какова ваша минимальная целевая версия Node? Если это Node 22.12+ (а с учётом того, что Node 20 теперь EOL, так и должно быть), каждый потребитель может
require()ваш ESM. Поставляйте только ESM. Если вам действительно необходимо поддерживать среды выполнения до 20.19, всё ещё существующие в реальности, вам всё ещё нужна сборка CJS для них. - Использует ли ваш require-доступный граф top-level await? Если да — в вашем коде или синхронно загружаемой зависимости — потребители CJS столкнутся с
ERR_REQUIRE_ASYNC_MODULE. Либо удалите TLA (зачастую достаточно заменить top-levelimport()на ленивый), либо сохраните точку входа CJS и задокументируйте, что пользователиrequire()не поддерживаются. - Контролируете ли вы своих потребителей? Авторы приложений на зафиксированной текущей версии Node могут свободно переходить на ESM-only. Авторы библиотек с неизвестными нижестоящими потребителями всё же должны публиковать чистую карту
exportsи рассматривать TLA как событие версионирования.
Если ни одно из этих условий не вынуждает вас использовать второй формат, двойная сборка — это мёртвый груз: дополнительный инструментарий, более медленный CI, более крупный публикуемый артефакт и повторно введённая опасность двойного пакета без какой-либо пользы.
Переход на ESM-only: чеклист
Переход на ESM-only — это в основном упрощение package.json плюс дисциплинированный синтаксис модулей. Шаги:
- Установите
"type": "module", чтобы файлы.jsпарсились как ESM. - Сведите карту
exportsк единственной точке входа ESM. Двойная карта превращается в одну строку:
{
"type": "module",
"exports": "./dist/index.js",
"engines": { "node": ">=22.12.0" }
}
Рекомендуемое значение engines в ретроспективе — "^20.19.0 || >=22.12.0"; поскольку Node 20 находится в EOL, одного >=22.12.0 вполне достаточно.
- Используйте явные расширения
.jsв относительных импортах — ESM требует их:import { x } from "./util.js", а не"./util". - Установите
"moduleResolution": "NodeNext"вtsconfig.json, чтобы TypeScript корректно генерировал и разрешал ESM, включая обязательные расширения. - Замените глобальные переменные CommonJS. ESM не имеет
__dirname,__filenameилиrequire. Воссоздайте их изimport.meta:
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
- Проверьте наличие top-level await во всём вашем коде и зависимостях перед публикацией. Если вы планируете использовать TLA позже, запланируйте мажорный бамп версии сейчас, а не поставляйте его как патч.
Что это означает для авторов библиотек
Стена совместимости, оправдывавшая двойные сборки CJS/ESM, исчезла во всех версиях Node.js, достойных поддержки: require(esm) стабилен начиная с v25.4.0 и присутствует в ветках 22, 24 и 26. Оставшееся ограничение узкое и именуемое — держите top-level await вне пути, который пройдёт вызывающий require(), и рассматривайте первый такой await как критическое изменение. Для нового пакета, ориентированного на текущий Node, поставляйте только ESM, сведите карту exports и проверьте граф на наличие TLA перед публикацией.
Часто задаваемые вопросы
Можно ли require-ить пакет ESM-only на Node.js 22?
Да. Node 22 включил require(esm) по умолчанию начиная с v22.12.0, выпущенного 3 декабря 2024 года, поэтому файл CommonJS, работающий на любой версии 22.12 или более поздней, может напрямую require() пакет ESM-only, при условии что граф этого пакета не содержит top-level await. Функция была позднее помечена как стабильная в Node 25.4.0 и бэкпортирована в LTS-ветку 24.x в v24.15.0, но она функционировала на Node 22 начиная с релиза v22.12.0.
В чём разница между ERR_REQUIRE_ESM и ERR_REQUIRE_ASYNC_MODULE?
ERR_REQUIRE_ESM была старой ошибкой, выбрасываемой всякий раз, когда CommonJS пытался require() любой ES-модуль, и она больше не возникает на поддерживаемых версиях Node, поскольку require(esm) обрабатывает синхронную загрузку ESM. ERR_REQUIRE_ASYNC_MODULE — это более узкая современная ошибка, выбрасываемая только тогда, когда требуемый граф ESM содержит top-level await, поскольку require() не может ожидать асинхронного выполнения. Её сообщение предписывает использовать вместо этого import(). Первая ошибка означала, что ESM не поддерживается; вторая означает, что одна конкретная функция ESM не поддерживается.
Возвращает ли require(esm) экспорт по умолчанию напрямую?
Нет. require(esm) возвращает полный объект пространства имён модуля, а не голое значение, поэтому экспорт по умолчанию оказывается в свойстве .default, а не является самим возвращаемым значением, что соответствует поведению динамического import(). Это отличается от традиционного модуля CommonJS, где require() возвращает module.exports напрямую. Если вам нужно единственное возвращаемое значение, ES-модуль может экспортировать его, используя строковое имя 'module.exports', что настраивает то, что возвращает require(esm). Всегда проверяйте .default при переводе потребителей с обёртки CJS.
Как проверить во время выполнения, доступен ли require(esm)?
Проверьте, является ли process.features.require_module значением true. Это булево значение устанавливается средой выполнения Node.js и возвращает true в каждой версии, поддерживающей require() для ES-модулей, включая Node 20.19 и более поздние, 22.12 и более поздние, а также все версии веток 24 и 26. Используйте его для ветвления между синхронным require() и асинхронным запасным вариантом import(), когда вам необходимо поддерживать смесь старых и новых сред выполнения в одной кодовой базе.
Безопасна ли поставка только ESM, если мои зависимости используют top-level await?
Не для потребителей CommonJS. Ограничение require(esm) применяется ко всему require-доступному графу, а не только к вашим собственным файлам, поэтому top-level await в любом месте синхронно загружаемой зависимости выбросит ERR_REQUIRE_ASYNC_MODULE для всех, кто использует require(). Задокументированный инцидент 2026 года показал, как lru-cache добавил top-level await в свою ESM-сборку и транзитивно сломал jsdom, прежде чем мейнтейнер откатил изменение. Проверьте весь граф зависимостей перед переходом на ESM-only или сохраните точку входа CJS и пометьте пользователей require() как неподдерживаемых.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k