ПроКодинг - Откроем для вас мир IT!

Вы когда-нибудь сталкивались с ситуацией, когда код работает локально, но падает на сборке или в 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 строится вокруг двух основных концепций:

  1. Загрузка: Функция require('path') выполняет синхронный запрос к файловой системе. Пока не будет загружен модуль, выполнение текущего скрипта останавливается.
  2. Экспорт: Объект 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 и ES Modules

Таблица сравнения: CommonJS vs ES Modules

Сравнение характеристик CommonJS и 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:

  1. Флаг в package.json: Если в корневом package.json есть поле "type": "module", то все файлы .js в этой директории считаются ESM. Если поля нет или оно равно "commonjs", файлы считаются CJS.
  2. Расширение файла: Файлы с расширением .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 проверяйте, не используются ли динамические изменения объекта экспорта.

Визуализация процесса оптимизации зависимостей через tree-shaking

Какой формат выбрать для нового проекта?

Выбор зависит от контекста использования:

  • 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, и улучшает производительность за счет оптимизаций рантайма.

Практические советы для разработки

Чтобы избежать головной боли с модулями, следуйте этим правилам:

  1. Используйте .mjs/.cjs для гибридных проектов: Если в вашем репозитории есть и старые скрипты на CJS, и новые на ESM, используйте явные расширения. Это исключает конфликты с флагом "type" в package.json.
  2. Держите TypeScript актуальным: Новые версии TypeScript лучше понимают семантику ESM и правильнее подсказывают типы для модулей.
  3. Тестируйте в целевом окружении: Код, который работает в редакторе с горячей перезагрузкой, может вести себя иначе в продакшене. Проверяйте, как Node.js резолвит ваши импорты.
  4. Избегайте циклических зависимостей: Они ведут себя непредсказуемо в 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.