ESM в Node.js: совместимость с CommonJS

Содержание
- Введение
- Основы
- Импорт CommonJS из ESM
- Импорт ESM из CommonJS
- Получение пути текущего модуля в ESM
- Почему такие различия
- Стоит ли переходить на ESM
- Практическое руководство по поддержанию совместимости
- План миграции — пошагово
- Рекомендации для авторов библиотек
- Типичные ошибки и способы их устранения
- Краткий глоссарий
- Итог
Введение
На протяжении долгого времени в ECMAScript не было штатного способа описывать и загружать модули. Поскольку Node.js нуждался в модулях раньше, чем их стандартизовали, де-факто стандартом внутри экосистемы стал CommonJS (CJS) — система с синхронным require и module.exports.
ES2015 (ES6) ввёл собственную систему модулей — ES Modules (ESM). Она использует import и export. Node.js развивал поддержку ESM постепенно; с версии v16 поддержка ESM включена по умолчанию. Тем не менее две разные системы модулей в одном проекте создают сложности, поэтому важно понимать механики их взаимодействия.
Основы
Если вы начинаете новый проект и планируете использовать ESM, дела относительно просты: разбивайте код на файлы и используйте import / export. Но есть важные настройки, которые нужно сделать заранее.
По умолчанию Node трактует файлы с расширением .js как CJS. Есть два способа явно указать, что проект должен использовать ESM:
- Использовать расширение файлов .mjs — тогда Node всегда будет рассматривать их как ESM.
- В корневом package.json задать поле “type”: “module” — тогда все файлы с расширением .js будут обрабатываться как ESM по умолчанию.
Пример package.json для проекта, где .js — это ESM:
{
"name": "example-package",
"type": "module",
"dependencies": {
"...": "..."
}
}Если поле type не установлено, по умолчанию проект рассматривается как CommonJS для совместимости с существующим кодом. Файлы с расширениями .cjs и .mjs всегда будут интерпретироваться как CJS и ESM соответственно.
Импорт CommonJS из ESM
Импортировать CJS-модуль в ESM-файле можно обычным import:
// cjs-module.cjs
module.exports.helloWorld = () => console.log("Hello World");
// esm-module.mjs
import component from "./cjs-module.cjs";
component.helloWorld();При импорте CJS в ESM результатом будет объект, равный module.exports из CJS-модуля. Это означает, что именованные “экспорты” CJS на самом деле — свойства объекта. Поэтому можно обращаться либо к свойствам объекта по имени, либо распаковывать их:
import { helloWorld } from "./cjs-module.cjs";
helloWorld();Node пытается определить набор экспортов CJS с помощью статического анализа — он сканирует исходник CJS, чтобы понять, какие свойства выставляются на module.exports. Но анализ не всегда идеален: в редких случаях динамически формируемые экспорты могут быть не обнаружены. Тогда используйте доступ через default-импорт (в ESM это объект module.exports):
import * as cjs from "./cjs-module.cjs";
cjs.default.someProperty();Если статический анализ не сработал, cjs.default будет содержать экспорт CJS как объект.
Важно: CJS не поддерживает концепцию “named exports” — у модуля один экспорт (module.exports), который может быть объектом с множеством свойств.
Импорт ESM из CommonJS
Обратный сценарий — использование ESM-модулей внутри CJS-кода — сложнее. В CJS нельзя писать import сверху файла, но можно использовать динамический import(), который возвращает промис. Это позволяет асинхронно загружать ESM-модули из CJS:
// esm-module.mjs
export const helloWorld = () => console.log("Hello World");
// esm-module-2.mjs
export default () => console.log("Hello World");
// cjs-module.cjs
const loadHelloWorld = async () => {
const { helloWorld } = await import("./esm-module.mjs");
return helloWorld;
};
(async () => {
const helloWorld = await loadHelloWorld();
helloWorld();
const loadHelloWorld2 = async () => {
const helloWorld2 = await import("./esm-module-2.mjs");
// Для default-экспорта возвращается объект вида { default: <функция> }
return helloWorld2.default;
};
const helloWorld2 = await loadHelloWorld2();
helloWorld2();
})();Из-за асинхронной природы ESM и отсутствия top-level await в CJS приходится оборачивать динамические импорты в async-функции. (Замечание: современные версии Node поддерживают top-level await в ESM; в CJS его нет.)
Получение пути текущего модуля в ESM
В CJS часто используются глобальные переменные dirname и filename. В ESM их нет. Вместо этого можно воспользоваться import.meta.url:
console.log(import.meta.url);
// Пример вывода: file:///home/demo/module.mjsЕсли нужен путь в файловой системе, превратите URL в путь с помощью встроенного модуля URL:
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
console.log(__dirname, __filename);Почему такие различия
CJS — синхронная система: require() загружает модуль с диска и выполняет его сразу. ESM — асинхронный и многоступенчатый: парсинг импортов, асинхронная загрузка зависимостей, затем выполнение в правильном порядке. Такая модель ESM сделана с прицелом на браузеры, где асинхронность и ленивые загрузки критичны.
Асинхронность ESM объясняет ограничения при использовании ESM в CJS-коде: CJS не поддерживает top-level await, поэтому статический import вверху файла невозможен, а динамический import() нужен именно из-за архитектуры ESM.
Стоит ли переходить на ESM
Короткий ответ: да. ESM — стандартная и совместимая с браузерами система модулей. Она даёт преимущества: динамические импорты, возможность импортировать по URL, и лучшую совместимость с модульными стандартами ECMAScript. Однако полная миграция в больших кодовых базах может быть болезненной — CJS останется надолго в экосистеме.
Если вы создаёте новую библиотеку, по возможности распространяйте ESM-совместимый код и делайте ESM своим системным выбором.
Практическое руководство по поддержанию совместимости
Ниже — стратегии, которые подходят авторам библиотек и приложениям, желающим оставаться совместимыми с обоими мирами.
Подход: два входа — ESM и CJS
Создайте тонкую обёртку ESM, которая реэкспортирует содержимое из существующего CJS-модуля. Структура может выглядеть так:
project/
cjs-index.js # ваше текущее CJS окончание
esm/esm-index.js # обёртка ESM
package.jsonПример esm/esm-index.js:
import demoComponent from "../cjs-component-demo.js";
import exampleComponent from "../cjs-component-example.js";
export { demoComponent, exampleComponent };В package.json укажите поле exports, чтобы Node отдавал соответствующий вход в зависимости от механизма импорта:
{
"exports": {
"require": "./cjs-index.js",
"import": "./esm/esm-index.js"
}
}Когда пользователь делает require(‘your-package’), Node отдаст cjs-index.js. При import ‘your-package’ будет использоваться esm/esm-index.js. Обратите внимание: когда используется exports, доступ к отдельным внутренним файлам библиотеки через пути типа “your-package/path/to/file.js” может быть запрещён — это ожидаемый побочный эффект.
Подход: скомпилированные бандлы
Некоторые проекты поставляют код в нескольких форматах на стороне сборки: ESM (для современных сред), CJS (для обратной совместимости), и иногда UMD или IIFE для браузеров без сборщика. Это обычно делается через инструмент сборки (Rollup, Babel, esbuild) и указывается в package.json через поля main, module и exports.
Подход: single source, dual publish
Содержимое пишется в ESM, затем при публикации автоматически генерируются версии для CJS (транспиляция/бандлинг) и ESM. CI/CD публикует оба набора артефактов.
План миграции — пошагово
Ниже приведён проверяемый план (SOP) для команды, планирующей миграцию библиотеки или приложения с CJS на ESM.
- Оценка покрытия зависимостей
- Составьте список внутренних и внешних зависимостей. Отметьте, какие зависимости уже поддерживают ESM, какие — только CJS.
- Выработайте стратегию публикации
- Решите, будете ли вы поддерживать оба формата либо переключитесь только на ESM (если потребители это позволяют).
- Локальная экспериЕНта
- Попробуйте переименовать узкий набор файлов в .mjs или установить “type”: “module” в отдельной тестовой ветке.
- Исправление кода
- Замените require/module.exports на import/export. Используйте файлы как модули ES.
- Учтите: замена не всегда тривиальна — иногда логика зависит от синхронной загрузки.
- Тесты
- Обновите тестовую среду (например, Jest требует дополнительных настроек для ESM); прогоните все тесты.
- Библиотеки и тулчейн
- Убедитесь, что bundler, linter и CI поддерживают ESM. Настройте трансформацию для обратной совместимости, если нужно.
- Публикация
- Если вы выпускаете dual format, настройте этап сборки в CI для генерации CJS-артефакта.
- Документация
- Обновите README с примерами импорта для обоих типов потребителей.
- Мониторинг
- Отслеживайте ошибки после релиза, особенно ошибки, связанные с import/require и путями модулей.
Критерии приёмки
- Все unit/integration тесты проходят в среде ESM и CJS (если вы выпускаете оба).
- Документация обновлена.
- Пользователи не испытывают регрессий при установке и импортировании модуля.
Рекомендации для авторов библиотек
- Если библиотека новая — делайте ESM форматом по умолчанию.
- Для существующих библиотек рассмотрите публикацию dual format или создание тонкой ESM-обёртки.
- Используйте поле “exports” в package.json для контроля, какие входные точки доступны пользователям.
- Тщательно тестируйте edge cases: динамическая генерация экспорта в CJS, использование require() внутри функций, reliance на dirname и filename.
Типичные ошибки и способы их устранения
Важно: перечисленные проблемы — самые часто встречающиеся при совместном использовании ESM и CJS.
- Ошибка: import из CJS возвращает объект с ключом default
- Причина: ESM default экспорт при импортировании через import() из CJS приходит в виде { default: <значение> }.
- Решение: обращайтесь к .default или используйте деструктуризацию в месте, где ожидали named-экспорт.
- Ошибка: статический импорт CJS-модуля не показывает именованные экспорты
- Причина: статический анализ не смог определить свойства module.exports.
- Решение: используйте import * as cjs или обращайтесь через default-объект.
- Ошибка: path/URL проблемная конвертация
- Причина: в ESM нет dirname/filename.
- Решение: используйте fileURLToPath(import.meta.url) и path.dirname.
- Ошибка: экспорт map ограничивает доступ к файлам внутри пакета
- Причина: использование поля exports в package.json.
- Решение: либо явно перечислите разрешённые внутренние пути в exports, либо не используйте exports (но это снижает контроль над API пакета).
- Ошибка: тесты ломаются после смены module system
- Причина: тестовый раннер может требовать специальных настроек для ESM (jest, mocha, ava и пр.).
- Решение: обновите конфигурацию тестов, используйте поддерживающие ESM версии раннеров, либо запускайте тесты в среде, где проект удобен.
Decision flowchart — как выбрать стратегию миграции
flowchart TD
A[Начало: проект на CJS или новый проект?] --> B{Это новая библиотека?}
B -- Да --> C[Использовать ESM по умолчанию]
B -- Нет --> D{Пользователи требуют CJS?}
D -- Да --> E[Выпуск dual format: ESM + CJS]
D -- Нет --> C
E --> F[Настроить сборку: сгенерировать CJS из ESM]
C --> G[Установить 'type': 'module' или .mjs]
F --> H[Настроить exports и тесты]
G --> H
H --> I[Тесты и документация]
I --> J[Релиз и мониторинг]
Рольные чек-листы при релизе библиотеки
Для Maintainer:
- Проверить поддержку в CI и билд-пайплайне
- Обновить package.json: name, exports, type
- Обновить документацию по установке и использованию
- Пропустить smoke-тесты для CJS и ESM
Для Reviewer PR:
- Убедиться, что нет прямых require() в ESM модулях
- Проверить корректность путей import.meta.url при необходимости
- Проверить, что bundle size не вырос существенно (при dual publish)
Для User (потребителя библиотеки):
- Проверить, доступен ли импорт в вашей среде (require или import)
- В случае ошибок посмотреть, не возвращается ли объект с default
Ментальные модели и эвристики
- Подумайте о модулях как о «контрактах»: exports — это публичный API. Экспортируйте минимально необходимое.
- CJS = синхронный, ESM = асинхронный. Если ваш код зависит от синхронной инициализации, будьте осторожны при переходе.
- Используйте exports в package.json, чтобы чётко контролировать публичные точки входа.
Когда миграция не рекомендуется
- Если проект очень большой и критичен к времени отклика, и миграция потребует больших изменений в runtime-логике.
- Если вы зависите от инструментов или библиотек, которые не поддерживают ESM и не планируют поддерживать.
Советы по совместимости для Node.js окружения
- По возможности делайте тонкие ESM-обёртки рядом с существующим CJS-кодом.
- Тестируйте оба варианта импорта при каждом изменении API.
- Документируйте, какие публичные пути доступны для require и import.
Краткий глоссарий
- ESM — ES Modules, стандартный механизм модулей (import/export).
- CJS — CommonJS, старая система модулей Node.js (require/module.exports).
- import.meta.url — URL текущего модуля в ESM.
- exports (package.json) — маппинг входных точек пакета в Node.js.
Итог
ES Modules — правильное направление для JavaScript и Node.js: они стандартизованы, асинхронны и совместимы с браузерной моделью модулей. Node.js поддерживает ESM по умолчанию с v16, но из-за объёма существующего CJS-кода важно уметь работать с обоими подходами.
Если вы создаёте новый проект — выбирайте ESM. Если поддерживаете существующую библиотеку — рассмотрите dual publish или тонкую обёртку ESM вокруг CJS, настройте поле exports в package.json и тщательно протестируйте оба сценария. Следуя плану миграции и чек-листам, вы сведёте риски к минимуму и поможете экосистеме постепенно перейти на единый стандарт.
Важно: документируйте свои решения и информируйте пользователей о том, какие точки входа поддерживаются через require и import.
Похожие материалы
Как изменить день рождения в Facebook
Как сохранить анонимность в Facebook
Как отметить себя в безопасности в Facebook
Изменить email и номер телефона в Facebook
Удаление группы в Facebook: инструкция