open atlas
↑ К треку
Node.js с нуля до senior NODE · 08 · 01

Бандлинг и публикация: ESM, CJS и exports

Публикация — это проектирование package.json. Карта exports — современная точка входа: выбирает ESM/CJS/types по условию и инкапсулирует внутренности. Dual-publish рискует dual-package hazard; ESM-only часто проще.

NODE Middle ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Команда библиотеки выпустила 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Что задаётПропустишь — и…
typeESM (module) vs CJS для .jsпо умолчанию CJS; ESM-синтаксис падает
exportsточки входа + условия + инкапсуляциявесь dist/ — публичный API
mainлегаси-вход; фолбэк, когда exports игнорятстарые тулзы тебя не разрешат
typesTS-декларации (и условие тоже)TS-потребители получают any
binмапит CLI-команду на файлнет установленной команды
filesallowlist того, что упаковатьотгрузишь тесты, 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. 1 Собрать через tsup/esbuild → эмитить dist/ JS плюс .d.ts-декларации
  2. 2 Добавить условие types, чтобы TS-потребители разрешали настоящие декларации, а не any
  3. 3 Написать карту exports (вход + subpath), чтобы импортировались только нужные пути
  4. 4 Задать files: ["dist"] (и bin + shebang + chmod +x для CLI), чтобы тарбол был минимальным и команда запускалась
  5. 5 Опубликовать из CI через npm publish --provenance для аттестации артефакта
Вспомните перед уходом
  1. 01
    Почему добавление карты exports в зрелый пакет — это ломающее (semver-major) изменение, и как выпустить его безопасно?
  2. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.