10 янв. 2026 г.·8 мин чтения

Смешанные репозитории ESM и CommonJS: безопасная миграция пакетов

Узнайте, как переводить смешанные репозитории ESM и CommonJS по одному пакету за раз, не ломая тесты, скрипты и инструменты деплоя.

Смешанные репозитории ESM и CommonJS: безопасная миграция пакетов

Почему смешанные модули вызывают неприятные поломки

смешанные репозитории ESM и CommonJS ломаются особенно досадно, потому что одна и та же кодовая база может одновременно жить по двум разным правилам загрузки. Node определяет, как обрабатывать файл, по его расширению и по ближайшему package.json. Измените "type": "module" в одном пакете, и каждый файл .js внутри этого пакета начнёт подчиняться другому правилу. Импорты, которые работали час назад, внезапно могут выдать ERR_REQUIRE_ESM, Cannot use import statement outside a module или несоответствие default-экспорта.

Проблема распространяется дальше, чем ожидают многие команды. Вы можете планировать изменить один пакет, но соседний код часто зависит от его старого поведения. Хелпер, загруженный через require(), может теперь требовать import. Общий конфиг может работать в одном пакете и ломаться в следующем. Даже мелочи вроде того, существует ли __dirname и как загружаются JSON-файлы, могут сломать скрипты, о которых все уже забыли.

Тесты обычно падают раньше, чем падает само приложение. Это происходит потому, что раннеры тестов, моки, файлы настройки и инструменты покрытия трогают множество файлов необычным образом. Пакет может по-прежнему запускаться в локальной разработке, а вот Jest, Vitest или кастомный тестовый скрипт упадёт на первом же импорте. Многие команды считают, что это проблема тестов, но на самом деле тесты часто первыми показывают модульную границу, которая и так была ненадёжной.

Шаги сборки и деплоя могут какое-то время скрывать старые предположения. Сборка TypeScript может по-прежнему выдавать файлы, но скрипт релиза может вызывать node some-script.js и ожидать CommonJS. Docker-образ может работать в CI, потому что использует кэшированный результат, а в продакшене уже падать, когда Node читает исходники напрямую. Один и тот же репозиторий может выглядеть здоровым, пока крошечное изменение в одном пакете не дойдёт до забытого скрипта в линтинге, бандлинге, миграциях или запуске.

Простой пример показывает этот паттерн. Допустим, packages/utils переходит на ESM, а packages/api всё ещё использует CommonJS в тестах и релизных скриптах. Приложение может собраться, но стартовый код тестов всё равно вызывает require('../utils'), а шаг деплоя — по-прежнему загружает CommonJS-конфиг. В этих файлах ничего не менялось, но они всё равно ломаются. Именно поэтому такие миграции кажутся путаными: поломка редко остаётся внутри того пакета, который вы тронули.

Сначала составьте карту репозитория

В смешанном репозитории легко получить мелкие и раздражающие поломки, если действовать наугад. Один пакет нормально загружается в Node, другой падает в тестах, а шаг деплоя рушится, потому что всё ещё ждёт CommonJS. Избежать этого помогает простая карта того, что делает каждый пакет до того, как вы измените хотя бы один файл.

Начните с обычной таблицы. Для каждого пакета запишите его имя, текущий тип модулей и то, как его загружают другие пакеты. В смешанных репозиториях ESM и CommonJS одна такая страница часто экономит часы проб и ошибок.

Смотрите на код, а не только на названия папок. Ищите require, module.exports, import и export. Пакет может выглядеть современным снаружи, но внутри всё ещё содержать один старый helper-файл, который удерживает весь пакет на CommonJS. Проверьте и конфиги. Файлы настройки тестов, точки входа для CLI и скрипты сборки часто остаются старыми дольше, чем сам код приложения.

Проверьте метаданные пакета

Откройте каждый package.json и зафиксируйте поля, которые управляют загрузкой. type меняет то, как Node читает файлы .js. main по-прежнему важен в старых инструментах и некоторых скриптах. exports может помочь поддерживать оба формата, но он же способен заблокировать глубокие импорты, которые тесты или локальные скрипты всё ещё используют.

Короткий список должен включать:

  • имя пакета и текущий тип модулей
  • входные файлы, которые используют приложение, тесты и CLI-скрипты
  • поля package.json, такие как type, main и exports
  • раннер тестов и бандлер, используемые в этом пакете
  • команды сборки или деплоя, которые работают с пакетом напрямую

Не думайте, что весь репозиторий устроен одинаково. Один пакет может работать на Vitest, другой — на Jest, а третий может вообще обходиться без бандлера и запускаться прямо в Node. Инструменты деплоя не менее важны. Docker-сборка, релизный скрипт или небольшой шаг postinstall могут упасть раньше, чем кто-то заметит ошибку модуля в приложении.

Составляйте карту каждого пакета по краям: как код в него попадает, как другие части кода его импортируют, и как его запускают тесты и деплой. Это даёт чёткую картину того, что можно переносить первым, а где нужен дополнительный контроль.

Выберите первый пакет для переноса

Первый пакет важнее, чем кажется. Если начать с пакета, который находится в середине репозитория, у него одновременно сдвинутся все пути импорта вокруг. Так маленькая миграция с CommonJS на ESM превращается в неделю шумных падений тестов.

Начинайте с края. Листовой пакет обычно самый безопасный выбор, потому что его импортируют немногие другие пакеты, а его поведение легче проверить. Утилитные пакеты, небольшие обёртки SDK и хелперы для форматирования данных часто подходят хорошо. Если у пакета узкая задача и небольшой публичный API, поломку можно заметить быстро.

Пропускайте пакеты, которые управляют самим репозиторием. Настройка тестов, скрипты сборки, автоматизация релизов, CLI-точки входа и помощники деплоя трогают слишком много движущихся частей. Если один из них сломается, вы потеряете страховочную сетку как раз в тот момент, когда ещё только разбираетесь, где смешанные репозитории ESM и CommonJS особенно хрупки.

Хорошая первая цель имеет понятные входы и выходы. Вы хотите пакет, по которому можно ответить на простые вопросы: что в него входит, что из него выходит и кто от него зависит? Если пакет читает конфиги, меняет глобальные объекты или загружает плагины во время выполнения, оставьте его на потом. Такие случаи скрывают проблемы формата до самого неудобного момента.

Запишите критерии успеха до того, как измените файл. Пусть они будут скучными и конкретными:

  • пакет собирается так же, как и раньше
  • его тесты проходят без специальных флагов
  • один реальный потребитель может его импортировать и запуститься
  • CI и шаги публикации по-прежнему работают для этого пакета

Этот короткий список помогает держать миграцию честной. «У меня на машине собирается» — недостаточно.

Небольшой пример: если в репозитории есть @acme/date-utils, @acme/api и @acme/cli, первым переносите @acme/date-utils. У него меньше точек соприкосновения. @acme/cli может зависеть от загрузчиков во время выполнения, обработки окружения и shell-скриптов, так что один модульный сдвиг способен сломать его в пяти разных местах.

Выбирайте пакет с наименьшей зоной влияния. Вам нужен один чистый успех, а не драматический урок.

Используйте миграцию по одному пакету

Относитесь к каждому пакету как к отдельной маленькой миграции, а не как к переключателю для всего репозитория. В смешанных репозиториях ESM и CommonJS это помогает держать ошибки узкими и понятными. Это также даёт команде одно правило для релизов: пакет не двигается дальше, пока он сам не работает и не работает для своих потребителей.

Сначала заморозьте изменения стиля модулей в выбранном пакете. Если один разработчик добавляет require(), а другой параллельно переводит файлы на import, вместо сигналов вы получите шум. Оставьте API стабильным и меняйте по одному слою за раз.

Рабочая последовательность выглядит так:

  1. Выберите один входной файл, обычно точку входа пакета или файл, который потребители загружают первым.
  2. Переведите этот файл и обновите импорты, на которые он ссылается. Если одна из зависимостей всё ещё использует CommonJS, используйте тот паттерн совместимости, которого ждёт Node, вместо того чтобы форсировать полный переписанный вариант.
  3. Обновите package.json под новый формат. В большинстве случаев это означает проверить type, main и exports, чтобы Node и ваши инструменты резолвили пакет одинаково.
  4. Запустите unit-тесты пакета, а затем локальные скрипты, которыми люди действительно пользуются: команды разработки, seed-скрипты или CLI-задачи.
  5. Соберите пакет и проверьте его хотя бы на одном реальном потребителе, прежде чем что-то выпускать.

Этот последний шаг экономит много нервов. Пакет может пройти свои собственные тесты и всё равно сломать другой пакет, который импортирует его по старому пути, ожидает default-экспорт или читает собранный файл напрямую. Проверка со стороны потребителя ловит такие ошибки раньше.

Небольшой пример: если packages/utils переходит первым, смените его публичный входной файл, исправьте внутренние импорты, задайте метаданные пакета, а потом проверьте packages/api на новом результате. Если api падает, остановитесь. Исправьте границу или откатите только utils. Не продавливайте миграцию дальше на новые пакеты.

В первый день этот процесс кажется медленнее. Обычно он становится быстрее ко второму или третьему пакету, потому что одни и те же проверки тестов, шаблоны импортов и правила релиза повторяются уже без сюрпризов.

Чётко разделяйте форматы

Миграция без хаоса в репозитории
Получите аккуратный план миграции, который подходит вашей команде, инструментам и процессу релизов.

В смешанных репозиториях ESM и CommonJS большинство неприятных ошибок начинается на границе, а не внутри самого пакета. Пакет может быть ESM или CommonJS и при этом вести себя хорошо. Проблемы начинаются, когда файлы, экспорты и загрузчики подают смешанные сигналы.

Используйте расширения файлов по одному простому правилу: человек должен понимать формат модуля ещё до того, как откроет файл.

  • Используйте .js только тогда, когда у пакета есть понятный package.json type, и все файлы внутри этого пакета следуют ему.
  • Используйте .cjs для файлов, которые обязательно должны оставаться на require(), например для старых конфигов, небольших адаптеров или скриптов, которые ваши инструменты всё ещё загружают как CommonJS.
  • Используйте .mjs, если файл должен быть ESM независимо от значения по умолчанию в пакете.

Это правило убирает догадки. Оно же упрощает ревью, потому что граница видна прямо в имени файла.

Делайте обёртки маленькими. Если ESM-пакету нужно вызвать CommonJS-хелпер, добавьте один небольшой файл-адаптер и назовите его прямо. То же самое делайте в обратную сторону. Не размазывайте преобразование формата по многим файлам. Один очевидный мост легко тестировать. Пять скрытых мостов превращают простую ошибку импорта в долгий и утомительный день.

Форма экспорта важна не меньше, чем расширение файла. Выберите одну форму и сохраняйте её стабильной для потребителей, пока переносите пакеты по одному. Если сейчас вызывают require("pkg") и получают функцию, не меняйте это незаметно на named export на следующий день. Если нужен ESM-обёртчик, пусть он на публичной границе возвращает то же самое.

Default- и named-экспорты часто вызывают путаницу в форматах модулей Node.js. Многие команды смешивают оба варианта «на всякий случай», а потом забывают, какой стиль импорта нужен каждому потребителю. Обычно это рождает неудобный код и хрупкие тесты. Используйте default-экспорт только тогда, когда у пакета есть одна понятная главная сущность. Используйте named-экспорты, когда пакет отдаёт несколько публичных функций. Если вы уже выпустили один стиль, сохраняйте его до тех пор, пока не запланируете настоящее breaking change.

Чёткие границы кажутся немного строгими. Зато они экономят время каждый раз, когда вы запускаете тесты, собираете билд или разбираете ошибку загрузчика в продакшене.

Сохраните работоспособность тестов и локальных скриптов

Безопасная миграция начинается с одной скучной привычки: запускайте тесты пакета до любых изменений, а потом снова после каждого маленького шага. Это даёт чистую базу и помогает понять, какое именно изменение сломало загрузку модулей. Если менять пять файлов сразу, ошибка обычно указывает куда угодно, только не на настоящую причину.

Файлы настройки тестов часто ломаются первыми. Во многих репозиториях код настройки всё ещё загружается через require, хотя сам пакет уже переведён на import. Из-за такого несоответствия всё может упасть ещё до начала настоящих тестов. Проверьте setup-файлы, кастомные раннеры, хуки покрытия и маленькие bootstrap-файлы, про которые все забывают, потому что они редко меняются.

То же самое касается хелперов вокруг тестов. Если helper по-прежнему экспортирует данные через module.exports, а тесты теперь импортируют именованные значения, ошибка может выглядеть как сломанный mock или отсутствующая функция. Исправьте helper, mock и тест за один проход. Так же поступайте со snapshot-тестами, если вывод изменился из-за изменения формы модуля.

Короткий чеклист помогает:

  • Запустите тестовый набор пакета до изменения и сохраните результат.
  • Переводите по одной границе, а затем снова запускайте те же тесты.
  • Проверьте setup-файлы, mocks, fixtures и общие helpers на несовпадения формата.
  • Обновляйте snapshots только после того, как убедитесь, что поведение во время выполнения не изменилось.
  • Запускайте пакет из пакета-потребителя, а не только из его собственной папки.

Последний пункт ловит много ошибок. Пакет может пройти собственные тесты и всё равно сломаться, когда другой пакет импортирует его через workspace, шаг сборки или CLI-скрипт. В monorepo добавьте хотя бы один маленький интеграционный тест со стороны потребителя, даже если он только импортирует пакет и вызывает одну функцию.

Локальные скрипты тоже требуют такого же внимания. Seed-скрипты, миграционные скрипты, генерация кода и одноразовые админские команды часто используют путь загрузки, отличный от самого приложения. Запустите их вручную. Если в команде есть скрипт, которым люди пользуются каждый день, сохраните его работоспособность на протяжении всей миграции. Миграция кажется маленькой, пока один сломанный npm run не блокирует всех остальных.

Проверьте скрипты и инструменты деплоя

Картируйте рискованные пакеты
Заранее выясните, какие пакеты, скрипты и потребители нуждаются в обёртках до начала изменений.

Пакет может выглядеть нормально на вашем ноутбуке и всё равно упасть в тот момент, когда его запускает CI или продакшен. Большинство проблем с ESM проявляется на краях: в CLI-командах, раннерах тестов, шагах сборки и релизных скриптах, которые всё ещё рассчитывают на CommonJS.

Начните с каждой команды, которая запускает код напрямую. Проверьте записи bin, npm scripts, команды запуска воркеров, cron-задачи, seed-скрипты и одноразовые админские задачи. Если скрипт вызывает node file.js, этот файл теперь должен совпадать с форматом пакета, а путь должен резолвиться так же, как раньше.

Быстрый аудит обычно находит большую часть проблем:

  • сравните локальные команды запуска с командами, которые используются в CI и продакшене
  • проверьте, загружают ли ts-node, Babel, Jest, Vitest или ESLint файлы конфигурации через require()
  • убедитесь, что скрипты package manager не зависят от импортов без расширения, которые ломаются в ESM
  • проверьте, что Docker-образы, CI-job’ы и релизные раннеры используют одну и ту же версию Node
  • вручную запустите точную команду старта из продакшена перед слиянием

Разница в версиях вызывает много боли. Node 18, 20 и 22 ведут себя не совсем одинаково в части загрузчиков, раннеров тестов и резолва пакетов. В командах, которые используют Docker и GitLab CI, как в настройках, которые Олег Сотников часто строит для клиентов, репозиторий может проходить локально и падать в CI просто потому, что на одном раннере другой образ Node.

Тестовые инструменты требуют отдельного внимания. Jest часто нуждается в изменениях конфигурации или другом пути трансформации. Vitest обычно лучше работает с ESM, но setup-файлы и mocks всё равно нужно проверять. ts-node может сломаться, если скрипт ожидает CommonJS-хуки. ESLint тоже может падать, если формат конфига не совпадает с форматом пакета.

Сделайте финальную проверку в среде, похожей на деплой. Соберите образ, установите зависимости так же, как это делает ваш pipeline, и запустите ту же команду старта, которую использует платформа. Если продакшен стартует через node dist/index.js, не останавливайтесь на npm test. Запустите именно эту команду и посмотрите на ошибки импорта, загрузчика и путей.

Эта маленькая привычка предотвращает худший тип ошибки миграции: код, который проходит тесты, но не может запуститься.

Простой пример из многопакетного репозитория

Представьте репозиторий с тремя пакетами: utils, web и jobs. Оба прикладных пакета всё ещё используют CommonJS. Самый удачный первый шаг — это utils, а не основное приложение. Перенос общего пакета первым даёт вам одну границу, которую можно контролировать, вместо того чтобы одновременно менять запуск приложения, тест-раннер и процесс деплоя.

packages/
  utils
  web
  jobs

В смешанных репозиториях ESM и CommonJS такой порядок держит зону влияния маленькой. Переведите исходники utils на ESM, но какое-то время сохраните CommonJS-вход. На практике это обычно означает, что import указывает на dist/index.js, а require — на dist/index.cjs через поле exports в пакете. web и jobs могут по-прежнему вызывать require("@repo/utils"), пока сам пакет движется вперёд.

Переносить сначала web выглядит быстрее на бумаге. Обычно это не так. Основное приложение содержит больше кода запуска, больше скриптов и больше мест, где одно изменение модуля превращается в десять не связанных между собой ошибок.

После того как utils стабилен, обновите только один пакет-потребитель. jobs часто лучше подходит, чем web, потому что у него меньше движущихся частей. Измените там импорты и внимательно смотрите на форму импорта. Именно отсюда начинается множество ошибок миграции с CommonJS на ESM.

// старый стиль CommonJS
const slugify = require("@repo/utils");

// возможная замена на ESM
import slugify from "@repo/utils";

Это изменение работает только если пакет по-прежнему отдаёт default-экспорт. Если старый модуль возвращал объект, новому коду может понадобиться import { slugify } from "@repo/utils" вместо этого. Небольшое несоответствие — и уже неприятная ошибка.

Короткая проверка после обновления jobs экономит много догадок:

  • запустите тесты пакета
  • запустите сборку репозитория
  • запустите точный стартовый скрипт, который используется в CI
  • проверьте один preview или staging-job для деплоя

Потом остановитесь. Не переносите web в тот же день только потому, что первый пакет локально прошёл. Подождите, пока CI останется зелёным полный цикл. Это включает тесты, упаковку и инструменты деплоя. При миграции по одному пакету терпение — часть метода. Один зелёный цикл показывает, что новые форматы модулей Node.js работают в реальных условиях, а не только на вашем ноутбуке.

Ошибки, из-за которых тяжело читать сообщения об ошибках

Обновите стек Node.js
Наведите порядок в структуре пакетов, CI-путях и процессе релиза с помощью опытного технического консультанта.

Большинство неприятных ошибок модулей начинается с одного небольшого несоответствия между тем, как Node загружает файл, и тем, как ваши инструменты ожидают его загрузить. В смешанных репозиториях ESM и CommonJS такое несоответствие часто какое-то время прячется, а потом всплывает в тестах, CI или deploy-скрипте в самый неподходящий момент.

Частая ошибка — слишком рано переключить "type": "module" на уровне корня репозитория. Одно это изменение может заставить Node одновременно считать множество файлов .js файлами ESM, включая старые скрипты, helpers для тестов и build-файлы, которые вы ещё не планировали трогать. Безопаснее менять по одному пакету и оставлять корень репозитория скучным, пока границы не стабилизируются.

ESM также требует полные пути к файлам в импортax. Если вы меняете require('./util') на import './util', Node может упасть, потому что ESM ожидает ./util.js или другое точное имя файла. Бандлеры иногда скрывают эту проблему в локальной разработке, а потом обычный Node падает в CI. Именно поэтому такие ошибки кажутся случайными, хотя ими не являются.

Ещё одна ловушка — __dirname и __filename. В ESM они работают не так, как в CommonJS. Если пакет читает шаблоны, конфиги или тестовые fixtures с диска, этот код может сломаться, даже если синтаксис импорта выглядит правильным. Вам нужен ESM-подход с import.meta.url и helper-ами для file URL, иначе логика путей начнёт указывать не туда.

Изменение экспортов вызывает одни из самых запутанных поломок. Если вы переключите модуль с module.exports = thing на named export в ESM, старые импорты могут всё ещё компилироваться, но возвращать undefined или объект пространства имён. Ошибка маленькая, а stack trace обычно нет.

То же самое происходит, когда команды обновляют исходники и забывают про окружающие инструменты. Смотрите на эти места:

  • раннеры тестов и файлы настройки тестов
  • конфиги сборки, такие как Babel, tsup, esbuild или webpack
  • CLI-скрипты в package.json
  • хуки деплоя и команды запуска
  • конфиги lint и coverage

Пакет может выглядеть полностью перенесённым и всё равно падать, потому что Jest загружает конфиг как CommonJS, или потому что релизный скрипт всё ещё вызывает файл с неправильным расширением. Сначала проверьте код, потом инструменты вокруг него. Второй проход ловит много боли.

Быстрые проверки и следующие шаги

Смешанные репозитории ESM и CommonJS становятся безопаснее, когда вы относитесь к переносу каждого пакета как к маленькому релизу, а не как к общей уборке репозитория. Измените один пакет, запустите его тесты, а затем проверьте тесты и скрипты каждого пакета, который его импортирует. Если CI становится красным, вы точно знаете, какое изменение это вызвало.

Держите короткую заметку об откате для каждого пакета, который трогаете. Достаточно двух-трёх строк: что изменилось, какие файлы задают формат модулей и как быстро откатиться. Когда deploy-скрипт или тестовый раннер падает в два часа ночи, такая заметка экономит больше времени, чем длинный документ о миграции.

Ещё помогает простая таблица учёта. Отмечайте, какие пакеты уже полностью перенесены, какие всё ещё нуждаются в обёртках, и какие пока должны поддерживать оба формата, потому что на них завязаны старые скрипты или инструменты CommonJS. В долгой миграции по одному пакету временные мосты — это нормально. Проблема начинается, когда вы забываете, где они находятся, и потом долго и нудно всё убираете.

Используйте короткую повторяемую проверку после каждого изменения пакета:

  • Запустите unit-тесты изменённого пакета.
  • Запустите интеграционные тесты для прямых зависимых пакетов.
  • Выполните локальные скрипты, которыми разработчики пользуются каждый день.
  • Запустите те же команды сборки и деплоя, что использует ваш pipeline.
  • Запишите шаги отката и все временные обёртки, которые ещё остались.

Сделайте второй проход после того, как репозиторий стабилизируется. Уберите мостовые файлы, удалите дублирующиеся точки входа и упростите правила сборки, когда старым инструментам они больше не нужны. Если пропустить этот проход, репозиторий месяцами будет нести стоимость двух форматов.

Для большого репозитория с множеством пакетов, окружений и путей деплоя внешний аудит может сэкономить реальные деньги. Опытный Fractional CTO, такой как Олег Сотников, может посмотреть на план миграции, найти слабые места в тестах и инструментах и снизить риск запуска ещё до того, как мелкие ошибки модулей превратятся в проблемы релиза.

Лучшая точка остановки скучная: зелёный CI, рабочие скрипты, чистые деплои и понятный список того, что ещё нужно перевести.