Поле exports в package.json и связь со стратегией вывода

Современная экосистема JavaScript перешла от простого указания main в package.json к сложной системе управления точками входа через поле exports. Это изменение напрямую повлияло на то, как формируются бандлы в Rollup, как проектируется структура пакетов и как выстраивается стратегия вывода (output strategy) при сборке библиотек.


Семантика exports и ограничения доступа к модулям

Поле exports определяет публичный API пакета и управляет тем, какие модули доступны при импорте извне.

{
  "name": "my-lib",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  }
}

Ключевой момент заключается в том, что все пути, не указанные в exports, становятся недоступными для внешнего импорта:

import something from "my-lib/internal/helper.js"; // может быть запрещено

Такой подход вводит строгую инкапсуляцию, превращая пакет в контролируемую систему публичных контрактов.


Subpath exports и структурирование API

exports позволяет описывать не только корневой модуль, но и субпути:

{
  "exports": {
    ".": "./dist/index.js",
    "./utils": "./dist/utils.js",
    "./feature": {
      "import": "./dist/feature/index.js",
      "require": "./dist/feature/index.cjs"
    }
  }
}

Это формирует явную карту публичной поверхности пакета. В контексте Rollup это напрямую влияет на архитектуру сборки: каждый экспорт становится потенциальной точкой входа или отдельным чанком.


Conditional exports и мультиреализационные пакеты

exports поддерживает условия (conditional exports), которые определяют, какой файл будет использоваться в зависимости от окружения:

  • import — ESM
  • require — CommonJS
  • node — Node.js runtime
  • browser — браузерная среда
  • default — fallback
{
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "default": "./dist/index.mjs"
    }
  }
}

Это создаёт необходимость синхронизации стратегии вывода Rollup с целевыми форматами.


Rollup и модель генерации выходных форматов

Rollup не просто собирает модули в файл, он формирует конкретные артефакты в зависимости от output.format:

  • esm — ES Modules
  • cjs — CommonJS
  • iife — самовызывающаяся функция
  • umd — универсальный формат
  • system — SystemJS
export default {
  input: "src/index.js",
  output: [
    { file: "dist/index.mjs", format: "esm" },
    { file: "dist/index.cjs", format: "cjs" }
  ]
};

Стратегия вывода в этом случае должна быть согласована с exports, иначе пакет может стать неконсистентным: Node будет ожидать один файл, а фактически использоваться будет другой.


Связь exports и стратегии dual-package

Наиболее распространённый паттерн — dual package (ESM + CJS). В этом случае exports становится центральной точкой синхронизации:

{
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

Rollup обязан генерировать два согласованных артефакта:

  • ESM-версию для import
  • CommonJS-версию для require

Критически важно, чтобы:

  • экспортируемые API совпадали
  • порядок побочных эффектов не различался
  • tree-shaking поведение не ломалось между форматами

Влияние exports на external-логику Rollup

Rollup использует external для исключения зависимостей из бандла. Однако exports влияет на то, как эти зависимости интерпретируются:

  • если зависимость имеет exports, глубокие импорты становятся запрещёнными
  • Rollup не должен резолвить внутренние пути пакетов напрямую
  • необходимо учитывать только публичные entry points
export default {
  external: ["lodash"]
};

При использовании exports у lodash (гипотетически) доступ к внутренним путям был бы ограничен, и Rollup должен учитывать только публичный API.


Node resolution и роль @rollup/plugin-node-resolve

Rollup сам по себе не реализует полный алгоритм Node resolution. Эту задачу выполняет @rollup/plugin-node-resolve.

Современные версии плагина поддерживают exports-map:

  • учитываются conditional exports
  • запрещаются недекларированные subpath imports
  • корректно выбирается import или require условие

Это критично для предотвращения расхождений между dev-сборкой и runtime Node.js.


Стратегия вывода и соответствие exports карте

Стратегия вывода в Rollup должна зеркально соответствовать структуре exports. Несоответствие приводит к нескольким типичным проблемам:

  1. Broken entry points

    • пакет экспортирует ./feature, но Rollup не генерирует соответствующий файл
  2. Mismatch форматов

    • exports.import указывает на ESM, но файл собран как CJS
  3. Tree-shaking деградация

    • неправильный формат ломает статический анализ зависимостей

Мульти-энтри и subpath mapping

При наличии множества subpath exports возникает архитектурный вопрос: генерировать ли один бандл или несколько независимых артефактов.

Два основных подхода в Rollup:

1. Монолитный бандл

input: "src/index.js"

Плюсы:

  • простота
  • единый граф зависимостей

Минусы:

  • невозможность точно отразить exports
  • потенциальный лишний код

2. Множественные входные точки

input: {
  index: "src/index.js",
  utils: "src/utils.js",
  feature: "src/feature/index.js"
}

Плюсы:

  • точное соответствие exports
  • независимая оптимизация чанков

Минусы:

  • сложность сборки
  • необходимость контроля дублирования зависимостей

preserveModules как инструмент синхронизации с exports

Опция preserveModules позволяет сохранять структуру исходных модулей:

output: {
  dir: "dist",
  format: "esm",
  preserveModules: true
}

Это приближает структуру output к exports-карте, особенно при использовании subpath exports. Каждый файл может соответствовать отдельному публичному маршруту.


Side effects и влияние на публичный API

Поле sideEffects в package.json взаимодействует с exports косвенно, но критически важно для Rollup:

{
  "sideEffects": false
}

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

  • модуль экспортируется, но считается “побочным”
  • tree-shaking удаляет код, доступный через subpath export

Архитектурная роль exports в стратегии сборки

exports фактически становится декларацией контракта пакета, а Rollup — механизмом его реализации.

Стратегия вывода должна учитывать:

  • соответствие структуры dist/ карте exports
  • согласование ESM/CJS условий
  • предотвращение глубоких импортов
  • стабильность публичных API
  • предсказуемость resolution в Node и bundlers

Согласование exports, типов и деклараций

При TypeScript-сборке появляется дополнительный слой:

{
  "types": "./dist/index.d.ts"
}

или через exports:

{
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    }
  }
}

Rollup-стратегия должна обеспечивать, чтобы .d.ts файлы зеркально соответствовали структуре JS-экспорта.


Итоговая взаимосвязь уровней

Связь между exports и Rollup-выводом формируется на трёх уровнях:

  • семантический уровень пакета: что считается публичным API
  • уровень сборки Rollup: как эти API физически собираются в файлы
  • уровень резолвинга Node.js: как потребитель получает доступ к этим файлам

Любое расхождение между этими уровнями приводит к неконсистентности пакета, особенно в условиях dual-package и subpath exports, где структура экспорта становится не просто конфигурацией, а основой архитектуры распространения библиотеки.