CommonJS против ES-модулей: что выбрать и где ломается
require и import ведут себя по-разному не только синтаксисом. Разбираем, почему нельзя просто заменить одно на другое, как их подружить в одном проекте и где чаще всего рвётся.

Вы добавили в package.json строку "type": "module" — и половина проекта перестала запускаться с ошибкой ReferenceError: require is not defined. Или наоборот: подключили свежий пакет с npm, а Node падает на Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported. Это не баги, а прямое следствие того, что в JavaScript сосуществуют две несовместимые системы модулей. Разберём, чем они отличаются на уровне поведения, а не только синтаксиса, и как принять решение, не переписывая проект дважды.
Две системы, а не два синтаксиса
CommonJS (CJS) — модульная система, с которой Node.js жил с самого начала. ES-модули (ESM) — стандарт языка, описанный в ECMAScript и реализованный в браузерах и в Node.js начиная с 12.x (стабильно — с 14.x). Материал ориентирован на Node.js 20.x LTS и 22.x.
Соблазн считать, что require и import — просто разное написание одного и того же, приводит к самым дорогим ошибкам. Различий три, и все они меняют поведение кода в рантайме.
Синхронная загрузка против статического графа
CommonJS загружает модуль синхронно в момент вызова require(). Вы можете вызвать require внутри условия, внутри функции, с вычисляемым путём — модуль подтянется в тот момент, когда до строки дойдёт исполнение.
ESM устроен иначе. Импорты статичны: движок сначала строит граф зависимостей, разбирая все import в начале файла, и только потом исполняет код. Поэтому import нельзя поставить внутрь if и нельзя дать ему вычисляемый путь — для этого есть отдельная динамическая форма import(), возвращающая промис.
Копия значения против живой привязки
CommonJS отдаёт вам снимок module.exports на момент выполнения require. ESM отдаёт живую ссылку на переменную модуля — если она изменится внутри, вы увидите новое значение.
Это тонкое, но реальное различие. В CJS импортированное примитивное значение — копия. В ESM экспортированные привязки живые: если модуль переопределит экспортируемую переменную позже, импортёр увидит обновление. На практике вы столкнётесь с этим при циклических зависимостях — о них ниже.
Асинхронность на верхнем уровне
В ESM работает top-level await — можно писать await прямо в теле модуля, вне функции. В CommonJS этого нет: там пришлось бы оборачивать всё в самовызывающуюся async-функцию.
Как отличаются в коде
CommonJS-модуль:
// logger.cjs — CommonJS, Node.js 20.x
function createLogger(prefix) {
return (message) => console.log(`[${prefix}] ${message}`);
}
// экспорт через объект module.exports
module.exports = { createLogger };
// app.cjs
const { createLogger } = require('./logger.cjs');
const log = createLogger('app');
log('сервис запущен');
Тот же смысл на ES-модулях:
// logger.mjs — ESM, Node.js 20.x
export function createLogger(prefix) {
return (message) => console.log(`[${prefix}] ${message}`);
}
// app.mjs
import { createLogger } from './logger.mjs';
const log = createLogger('app');
log('сервис запущен');
Обратите внимание: в ESM в относительном импорте расширение файла обязательно (./logger.mjs, а не ./logger). CommonJS расширение достраивал сам — это одна из частых причин ошибки ERR_MODULE_NOT_FOUND при переносе кода.
Как Node решает, что перед ним
Node определяет систему модулей по нескольким сигналам, в порядке приоритета:
- расширение
.mjs— всегда ESM,.cjs— всегда CommonJS, независимо от настроек; - для файлов
.jsрешает ближайшийpackage.json:"type": "module"— ESM,"type": "commonjs"или отсутствие поля — CommonJS; - точка входа, запущенная через
--input-typeили переданная в stdin, подчиняется этому флагу.
Отсюда практическое правило: не смешивайте в одной папке .js-файлы с разными ожиданиями. Если проект на ESM, а вам нужен один CommonJS-скрипт — дайте ему явное расширение .cjs.
Таблица различий
| Характеристика | CommonJS | ES-модули |
|---|---|---|
| Импорт | require() | import / import() |
| Экспорт | module.exports | export / export default |
| Загрузка | синхронная, в рантайме | статический граф до исполнения |
| Условный/динамический импорт | require где угодно | только через import() |
| Значение импорта | копия | живая привязка |
| Top-level await | нет | да |
| Расширение в пути | необязательно | обязательно для файлов |
__dirname, __filename | доступны | нет, нужен import.meta.url |
Как их подружить в одном проекте
Полностью изолировать друг от друга две системы не всегда возможно — половина npm-экосистемы всё ещё на CommonJS. Работает следующее направление совместимости:
- ESM импортирует CommonJS — работает.
import pkg from 'some-cjs-lib'подтянетmodule.exportsкак default-экспорт. Именованные импорты Node пытается вычислить статическим анализом, но это срабатывает не всегда — еслиimport { thing } from 'cjs-lib'падает, берите default и деструктурируйте:import pkg from 'cjs-lib'; const { thing } = pkg;. - CommonJS импортирует ESM синхронно — не работает.
require()ES-модуля выдаётERR_REQUIRE_ESM. Единственный путь — динамическийawait import('./esm-module.mjs')внутри async-функции.
Второй случай — главная боль при обновлении зависимостей. Многие популярные пакеты перешли на ESM-only, и старый CommonJS-проект перестаёт их подключать через require. Варианты: перевести свой модуль на ESM, зафиксировать старую версию пакета или использовать динамический импорт как мостик.
Замена __dirname в ESM
В ES-модулях нет __dirname и __filename. Их восстанавливают через import.meta.url:
// paths.mjs — Node.js 20.x
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const currentFile = fileURLToPath(import.meta.url);
const currentDir = dirname(currentFile);
const configPath = join(currentDir, 'config.json');
console.log(configPath);
Начиная с Node.js 21.2 доступна и более короткая форма import.meta.dirname и import.meta.filename. Если целитесь в 20.x LTS — используйте вариант выше через fileURLToPath, он работает везде.
Когда что выбирать
Критерий простой, без «зависит от задачи»:
- Новый проект — ESM. Это стандарт языка, он работает и в браузере, и в Node, и его понимают все современные сборщики. Ставьте
"type": "module"и не оглядывайтесь. - Библиотека для широкой аудитории — публикуйте dual-package: и CJS, и ESM-сборку через поле
exportsвpackage.json. Иначе часть пользователей на старых проектах не сможет вас подключить. - Существующий большой CommonJS-проект — не переписывайте разом. Мигрируйте по модулям, начиная с точек входа, и держите совместимость через
.cjs/.mjs. - Скрипт на один запуск — что удобнее сейчас. Разница в поведении на маленьком скрипте вас не догонит.
Что ломается на практике
Типичные ошибки при переходе и совмещении:
- Циклические зависимости. В CJS при цикле вы можете получить частично заполненный
module.exports— то, что успело выполниться до момента require. В ESM благодаря живым привязкам цикл часто проходит корректнее, но если обратиться к привязке до её инициализации — получитеReferenceError. Цикл — сигнал пересмотреть границы модулей, а не бороться с системой загрузки. - Именованные импорты из CJS-пакета не резолвятся. Node анализирует CommonJS статически и не всегда находит именованные экспорты. Решение — импорт default и деструктуризация.
- Забытое расширение файла.
import './utils'в ESM дастERR_MODULE_NOT_FOUND. Пишите./utils.js. - JSON-импорт. Импорт JSON в ESM требует import-атрибута:
import data from './x.json' with { type: 'json' }(форма стабилизировалась в Node.js 22.x; в 20.x был флаг и синтаксисassert). Проверяйте по документации своей версии. - Инструменты, ожидающие CJS. Некоторые конфиги (например, для отдельных линтеров или сборщиков) исторически ждут CommonJS. Дайте им расширение
.cjs, если проект переведён на"type": "module".
Что делать дальше
Если ведёте новый код — переключайте проект на "type": "module" и настройте exports в package.json, чтобы явно объявить точки входа. Если поддерживаете старый CommonJS — прежде чем начинать миграцию, прогоните npm ls и посмотрите, сколько зависимостей уже стали ESM-only: это подскажет, насколько срочен переход. За точным поведением import-атрибутов, import.meta и полем exports идите в раздел ESM официальной документации Node.js для вашей мажорной версии — детали менялись между 20.x, 21.x и 22.x.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Modules: ECMAScript modules — Node.js Documentation, Modules: CommonJS modules — Node.js Documentation
Частые вопросы
У меня Node.js 18, всё это работает?
Можно ли подключить ES-модуль через require?
Что делать, если именованный импорт из старого пакета падает?
Обязательно ли писать расширение файла в import?
Стоит ли переписывать большой CommonJS-проект на ESM целиком?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.