Бандлинг и публикация: ESM, CJS и exports
Публикация — это проектирование package.json. Карта exports — современная точка входа: выбирает ESM/CJS/types по условию и инкапсулирует внутренности. Dual-publish рискует dual-package hazard; ESM-only часто проще.
Команда библиотеки выпустила 2.0, чтобы починить утечку памяти. Через час CI крупного потребителя стал красным: его бандлер всё это время делал deep-import their-lib/dist/internal/cache.js, чтобы пропатчить класс, а рефакторинг переместил этот файл. Авторы библиотеки никогда не публиковали карту "exports", так что каждый файл на диске случайно стал публичным API — и «приватное» переименование превратилось в ломающее изменение для тысяч установок. Фикс был одним полем в package.json. Урок: то, что ты публикуешь, — это не твоё дерево исходников, а поверхность, которую объявляет твой package.json, и если ты её не объявил, то весь dist/ и есть контракт.
Два формата модулей и как Node выбирает
Node запускает твой код в одном из двух форматов, и выбор делается до того, как разрешится любой импорт. Пакет — ESM, если в его package.json есть "type": "module" (или файл оканчивается на .mjs); иначе это CommonJS (или .cjs). Это единственное поле меняет всё ниже по течению: ESM использует статические import/export и top-level await; CJS — require/module.exports. Ты не можешь require() ESM-only пакет из CJS без await import(), а ESM-потребители могут импортировать CJS-пакет, но чисто получают только его default-экспорт.
Трудная задача — публикация для обоих видов потребителя, dual package. Соблазнительный ответ — собрать dist/esm/ и dist/cjs/ сборки одного и того же кода. Это работает, пока не перестаёт: у потребителя, чей граф зависимостей тянет твою CJS-копию по одному пути и ESM-копию по другому, теперь загружены сразу два разных экземпляра модуля. Две копии означают две идентичности класса, так что instanceof ломается через границу, а любое состояние уровня модуля (кэш, реестр, синглтон) тихо дублируется. Это dual-package hazard, и его действительно трудно полностью избежать. Для большинства новых библиотек прагматичный ответ — выпускать ESM-only (Node поддерживает ESM в проде уже годы) — или взять инструмент сборки, который эмитит оба формата, и держать всю общую идентичность/состояние в едином внутреннем CJS-ядре, которое обе обёртки реэкспортируют.
# ESM-only — самый простой контракт: один формат, без hazard
# package.json
{
"type": "module",
"exports": "./dist/index.js"
}Карта exports: точки входа и инкапсуляция
Если два пакета разделяют границу и один может дотянуться произвольно глубоко во внутренности другого, ты теряешь возможность рефакторить без сломки потребителей — и фикс здесь не тесты, а карта exports.
Поле "exports" — современная точка входа, и оно вытесняет "main". Оно делает две работы сразу. Первая — условное разрешение: ты мапишь один и тот же спецификатор на разные файлы в зависимости от того, как он загружается — "import" для ESM, "require" для CJS, "types" для TypeScript и "default" как запасной вариант. Порядок важен; Node читает условия сверху вниз и берёт первое совпадение, так что "types" и самые специфичные условия идут первыми, "default" — последним. Вторая — subpath-экспорты: "./feature" открывает глубокую точку входа, не раскрывая раскладку папок.
То, что сеньоров волнует больше всего, — инкапсуляция. Как только ты определил "exports", импортируемы только перечисленные subpath. Потребитель больше не может дотянуться до your-pkg/dist/internal/secret.js — такой импорт бросает ERR_PACKAGE_PATH_NOT_EXPORTED. Это поле спасло бы команду из хука: с картой exports внутренний файл никогда не был частью контракта, так что его перемещение бесплатно. Держи "main" как фолбэк для древнего тулинга, игнорирующего exports, и всегда давай условие "types", чтобы TypeScript-потребители получали настоящие типы вместо any.
{
"name": "their-lib",
"type": "module",
"main": "./dist/index.cjs", // фолбэк для старых инструментов
"exports": {
".": {
"types": "./dist/index.d.ts", // первым: это читает TS
"import": "./dist/index.js", // ESM-потребители
"require": "./dist/index.cjs", // CJS-потребители
"default": "./dist/index.js" // фолбэк на крайний случай
},
"./feature": "./dist/feature.js" // явный subpath; больше ничего не достать
}
}| поле package.json | Что задаёт | Пропустишь — и… |
|---|---|---|
type | ESM (module) vs CJS для .js | по умолчанию CJS; ESM-синтаксис падает |
exports | точки входа + условия + инкапсуляция | весь dist/ — публичный API |
main | легаси-вход; фолбэк, когда exports игнорят | старые тулзы тебя не разрешат |
types | TS-декларации (и условие тоже) | TS-потребители получают any |
bin | мапит CLI-команду на файл | нет установленной команды |
files | allowlist того, что упаковать | отгрузишь тесты, src, секреты |
sideEffects | говорит бандлерам, что модули чисты | слабее tree-shaking |
engines | объявляет диапазон Node | тихая поломка на старом Node |
Все эти поля образуют слоистый контракт: type и exports задают поверхность модуля, files и sideEffects влияют на тарбол и на то, как бандлеры его трактуют, а bin/engines покрывают CLI и совместимость с рантаймом. Пропусти любое из них — и ты отправляешь контракт с невидимой дырой, которая всплывёт в чужом CI через три месяца.
▸Почему это работает
Почему добавление "exports" ломает существующих потребителей? Потому что инкапсуляция ретроактивна и тотальна. До exports достижим был каждый файл пакета; в день, когда ты добавляешь поле, каждый deep-import, не перечисленный явно, начинает бросать ERR_PACKAGE_PATH_NOT_EXPORTED. Это намеренно — в этом весь смысл — но значит, что добавление exports в зрелый пакет — это semver-major изменение. Перечисли каждый subpath, на который твои пользователи законно опираются ("./package.json" часто ждут инструменты), выпусти как major и задокументируй deep-import пути, которые выводишь из обихода.
Tree-shaking, CLI bin и гигиена публикации
Статическая структура ESM — это то, что позволяет бандлеру tree-shaking: проанализировать импорты на этапе сборки и выбросить код, до которого никто не доходит. CJS сопротивляется: require — динамический вызов функции, так что бандлер обычно не может доказать, что ветка мертва. Ты помогаешь бандлеру через "sideEffects": false — обещание, что импорт твоих модулей не делает ничего, кроме объявления экспортов (никакой мутации глобалов, никакой регистрации полифилла). С этим флагом неиспользованный экспорт можно вырезать целиком; без него бандлер обязан держать модули на случай, если важен сам импорт.
CLI — это всего лишь маппинг "bin" от имени команды к файлу. Три вещи должны сойтись, иначе команда тихо не запустится: целевой файл нуждается в shebang #!/usr/bin/env node первой строкой, ему нужно право на выполнение (chmod +x), а при установке npm сам создаёт симлинк в node_modules/.bin. Забудешь shebang — оболочка попытается запустить JS как shell-скрипт; забудешь бит прав — получишь «permission denied».
{
"bin": { "their-cli": "./dist/cli.js" },
"files": ["dist"],
"engines": { "node": ">=18.17" },
"sideEffects": false
}Наконец, отгружай сборку, а не исходник. Бандлер вроде tsup/esbuild/rollup эмитит dist/ плюс .d.ts-декларации; используй allowlist "files" (или .npmignore), чтобы тарбол содержал только dist — не твой src/, тесты или .env. Запусти npm pack --dry-run, чтобы увидеть точный список файлов до публикации. Поднимай версии по semver (удалённый экспорт или новая карта exports — это major). И публикуй с npm publish --provenance из CI, чтобы npm записал подписанную аттестацию, связывающую артефакт с коммитом и workflow, его собравшим, — защита цепочки поставок от подменённых или тайпсквоттинговых релизов.
Ты публикуешь новую универсальную библиотеку в 2026-м и должен выбрать формат дистрибуции. Каков сеньорский дефолт?
Что карта exports даёт сверх выбора ESM- или CJS-файлов?
Потребитель сообщает, что instanceof MyError возвращает false даже для ошибки, которую твоя dual-published библиотека явно бросила. Наиболее вероятная причина?
Расставь шаги безопасной публикации библиотеки + CLI — от сборки до аттестованного релиза:
- 1 Собрать через tsup/esbuild → эмитить dist/ JS плюс .d.ts-декларации
- 2 Добавить условие types, чтобы TS-потребители разрешали настоящие декларации, а не any
- 3 Написать карту exports (вход + subpath), чтобы импортировались только нужные пути
- 4 Задать files: ["dist"] (и bin + shebang + chmod +x для CLI), чтобы тарбол был минимальным и команда запускалась
- 5 Опубликовать из CI через npm publish --provenance для аттестации артефакта
- 01Почему добавление карты exports в зрелый пакет — это ломающее (semver-major) изменение, и как выпустить его безопасно?
- 02Что именно такое dual-package hazard и как проще всего его избежать?
Публикация пакета — это на самом деле проектирование его package.json: именно этот файл, а не дерево исходников, задаёт публичную поверхность. Node выбирает формат модуля по "type" — "module" для ESM, иначе CommonJS — и эти два не смешиваются свободно, так что обслуживание обоих потребителей (dual package) рискует dual-package hazard (проблема двойного пакета: два загруженных экземпляра дают две идентичности класса, instanceof ломается, состояние дублируется); для новых библиотек выпуск ESM-only обходит это полностью. Карта "exports" — современная точка входа, вытесняющая "main": она разрешает по условию ("types" первым, затем "import"/"require", "default" последним) и, что критично, инкапсулирует — импортируемы только перечисленные subpath, так что неперечисленные внутренности можно свободно рефакторить (поэтому добавление exports в существующий пакет — semver-major поломка). ESM плюс "sideEffects": false открывают настоящий tree-shaking (встряхивание дерева зависимостей — удаление мёртвого кода); CLI нужны "bin" плюс shebang #!/usr/bin/env node и право на выполнение; а гигиена отгрузки — это эмит сборки с .d.ts, allowlist "files", диапазон "engines", semver-инкременты и npm publish --provenance из CI ради подписанной аттестации цепочки поставок. Теперь, когда увидишь баг потребителя через deep-import или сломавшийся instanceof на границе пакетов — ты будешь знать, каких полей не хватало в package.json и почему.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.