Вы когда-нибудь сталкивались с ситуацией, когда код работает локально, но падает на сборке или в Node.js? Часто виновник - путаница между CommonJS и системой модулей ES6 (ESM), которая стала стандартом для браузеров и новых вертасей Node.js.. Понимание различий между этими двумя подходами к управлению зависимостями критически важно для любого разработчика, работающего с JavaScript или TypeScript.
Ключевые выводы
- CommonJS (CJS) использует синхронную загрузку модулей через функции
require()и объектmodule.exports. Это стандарт для Node.js до версии 12. - ES Modules (ESM) используют ключевые слова
importиexport, поддерживают асинхронную загрузку и статический анализ графа зависимостей. - Совместимость обеспечивается флагом
"type": "module"вpackage.jsonили расширением файла.mjsдля ESM и.cjsдля CJS. - TypeScript позволяет использовать оба формата одновременно, но требует правильной настройки
tsconfig.jsonдля корректной компиляции.
Что такое CommonJS и почему он появился
CommonJS - это спецификация, разработанная сообществом Node.js для решения проблемы управления кодом в серверном окружении. До его появления каждый файл был изолированной скриптовой программой, что делало переиспользование кода хаотичным.
Архитектура CJS строится вокруг двух основных концепций:
- Загрузка: Функция
require('path')выполняет синхронный запрос к файловой системе. Пока не будет загружен модуль, выполнение текущего скрипта останавливается. - Экспорт: Объект
module.exports(или псевдонимexports) определяет, что именно доступен внешнему миру из данного файла.
Простота была главным преимуществом CJS. Вы могли написать const fs = require('fs'); и сразу начать работать с файлами. Однако эта простота имела цену: отсутствие поддержки асинхронных операций на уровне загрузки модулей и невозможность оптимизации кода на этапе сборки (tree-shaking), так как структура зависимостей определялась только во время выполнения.
ES6 Modules: новый стандарт для веба и Node.js
ES Modules (часто называемые ESM) были введены в спецификации ECMAScript 2015 (ES6). В отличие от CJS, ESM ориентированы на декларативное описание связей между файлами.
Основные отличия ESM от CJS:
- Синтаксис: Использование
importиexportвместо функций. - Статический анализ: Граф зависимостей известен до выполнения кода. Это позволяет сборщикам (Webpack, Vite) удалять неиспользуемый код (tree-shaking).
- Асинхронность: Загрузка модулей может происходить асинхронно, что особенно важно в браузерах.
- Single Instance: Каждый модуль выполняется один раз, и его состояние сохраняется при повторных импортах.
В Node.js поддержка ESM стала полноценной начиная с версии 12, но по умолчанию файлы с расширением .js считались CommonJS, если в package.json не было указано иное.
Таблица сравнения: CommonJS vs ES Modules
| Характеристика | CommonJS (CJS) | ES Modules (ESM) |
|---|---|---|
| Синтаксис импорта | const mod = require('./file') |
import { x } from './file' |
| Синтаксис экспорта | module.exports = obj |
export const x = 1 |
| Тип загрузки | Синхронная | Асинхронная (в браузере), синхронная/асинхронная (Node.js) |
| Поддержка Tree-shaking | Нет (только на уровне сборки) | Да (на уровне языка) |
| Работа в браузере нативно | Нет | Да |
| Стандарт | Спецификация сообщества | ECMAScript Standard |
Как настроить совместимость в проекте
Переход от CJS к ESM не всегда возможен одним махом. Многие библиотеки все еще используют CommonJS, поэтому важно понимать, как Node.js и TypeScript определяют тип модуля.
Определение типа модуля в Node.js
Node.js использует два механизма для определения, какой движок исполнения применять к файлу .js:
- Флаг в package.json: Если в корневом
package.jsonесть поле"type": "module", то все файлы.jsв этой директории считаются ESM. Если поля нет или оно равно"commonjs", файлы считаются CJS. - Расширение файла: Файлы с расширением
.mjsвсегда трактуются как ESM, а файлы с расширением.cjs- как CommonJS, независимо от настроекpackage.json.
Это создает гибкость: вы можете иметь смешанный проект, где часть кода написана на современном ESM, а устаревшие зависимости остаются на CJS.
Настройка TypeScript для работы с обоими форматами
TypeScript добавляет слой абстракции над JavaScript, но должен знать, какой целевой формат модулей генерировать. Ключевые параметры в tsconfig.json:
"module": "esnext"или"es2020": Компилятор сохраняет синтаксисimport/exportбез изменений. Подходит для проектов, использующих сборщики (Vite, Webpack) или современный Node.js."module": "commonjs": Компилятор преобразуетimport/exportвrequire/module.exports. Это нужно, если вы пишете пакет для Node.js, который должен поддерживать старые версии."moduleResolution": "node"или"bundler": Определяет, как искать модули.bundlerрекомендуется для фронтенд-проектов с Vite/Webpack, так как он допускает импорт без расширения файлов.
Частые ошибки при переходе и их решение
Даже зная теорию, разработчики часто попадают в ловушки. Вот три самых распространенные проблемы:
1. Ошибка ERR_REQUIRE_ESM
Если вы пытаетесь загрузить ESM-модуль через require() в Node.js (до версии 22, где появилась экспериментальная поддержка), возникает ошибка ERR_REQUIRE_ESM. Это значит, что файл имеет флаг "type": "module" или расширение .mjs, но вызывается синхронно.
Решение: Используйте import() (динамический импорт) или убедитесь, что весь цепочка зависимостей поддерживает ESM.
2. Отсутствие расширения при импорте
В строгом режиме ESM (Node.js) необходимо указывать полное расширение файла при импорте. Например, import { app } from './app.js', а не './app'. В CommonJS это не обязательно.
Решение: Привыкайте писать расширения явно. Если используете TypeScript с настройкой moduleResolution: "bundler", можно обойтись без них, но для чистого Node.js ESM они обязательны.
3. Разница в объекте exports
В CommonJS exports и module.exports - это разные вещи. Переопределение module.exports заменяет весь объект, тогда как изменение свойств exports добавляет к нему методы. В ESM экспорт является неизменяемым (immutable) после объявления.
Решение: При конвертации кода из CJS в ESM проверяйте, не используются ли динамические изменения объекта экспорта.
Какой формат выбрать для нового проекта?
Выбор зависит от контекста использования:
- Frontend-приложения (React, Vue, Svelte): Всегда используйте ESM. Сборщики (Vite, Webpack) лучше работают со статической структурой импортов, что ускоряет сборку и уменьшает размер бандла.
- Библиотеки для Node.js: Рекомендуется выпускать пакеты в формате ESM, так как это будущее экосистемы. Однако для максимальной обратной совместимости многие популярные библиотеки (например, Lodash, Express) пока предоставляют обе версии или используют dual-package hazard workaround.
- Серверные приложения на Node.js: Если вы используете Node.js 14+, смело переходите на ESM. Это дает доступ к новым функциям, таким как top-level await, и улучшает производительность за счет оптимизаций рантайма.
Практические советы для разработки
Чтобы избежать головной боли с модулями, следуйте этим правилам:
- Используйте .mjs/.cjs для гибридных проектов: Если в вашем репозитории есть и старые скрипты на CJS, и новые на ESM, используйте явные расширения. Это исключает конфликты с флагом
"type"вpackage.json. - Держите TypeScript актуальным: Новые версии TypeScript лучше понимают семантику ESM и правильнее подсказывают типы для модулей.
- Тестируйте в целевом окружении: Код, который работает в редакторе с горячей перезагрузкой, может вести себя иначе в продакшене. Проверяйте, как Node.js резолвит ваши импорты.
- Избегайте циклических зависимостей: Они ведут себя непредсказуемо в ESM, так как порядок инициализации модулей фиксирован статически.
Часто задаваемые вопросы
Можно ли использовать CommonJS и ES Modules в одном проекте?
Да, можно. Node.js позволяет смешивать форматы. Файлы с расширением .cjs всегда будут CommonJS, а .mjs - ES Modules. Для файлов .js тип определяется полем "type" в ближайшем package.json. Главное правило: не пытайтесь импортировать ESM через require() в старых версиях Node.js.
Почему в Node.js нужно указывать расширение .js при импорте?
В ES Modules механизм разрешения путей более строгий, чем в CommonJS. Браузеры и современный Node.js требуют точного указания пути к файлу, чтобы избежать неоднозначности (например, если существуют файлы index.js и utils.js). Это делает код более предсказуемым и облегчает работу сборщиков.
Какой параметр module выбрать в tsconfig.json?
Для фронтенд-проектов с Vite или Webpack используйте "module": "esnext" и "moduleResolution": "bundler". Для библиотек, которые должны работать в Node.js без сборки, используйте "module": "commonjs", если нужна обратная совместимость, или "node16"/"nodenext" для строгого соответствия правилам Node.js.
Что такое Dual Package Hazard?
Это проблема, возникающая, когда библиотека публикует две версии одного и того же кода (CJS и ESM). Если в вашем приложении одна зависимость подгружает CJS-версию библиотеки, а другая - ESM-версию, могут возникнуть ошибки типов или дублирование состояния (например, в React hooks). Решение - использовать единый формат или правильно настроить aliases в сборщике.
Поддерживает ли TypeScript нативно ES Modules?
Да, TypeScript полностью поддерживает синтаксис ES Modules. Он понимает import/export, default exports и namespace imports. Однако поведение компилятора зависит от настроек tsconfig.json. Начиная с версии 4.7, TypeScript лучше интегрируется с механизмами разрешения модулей Node.js, минимизируя расхождения между TS и JS.