Поле exports в package.json: условные экспорты

Поле exports в package.json является механизмом управления публичным API пакета и определяет, какие модули доступны потребителю библиотеки и в каком виде они могут быть импортированы. В контексте Rollup это особенно важно, поскольку сборка библиотеки часто предполагает несколько форматов (ESM, CJS, UMD), а также разные точки входа для браузера, Node.js и вспомогательных утилит.

До появления exports основным способом указания входной точки был main:

{
  "main": "dist/index.cjs.js"
}

Позднее добавился module для ES-модулей:

{
  "main": "dist/index.cjs.js",
  "module": "dist/index.esm.js"
}

Однако оба поля имеют существенные ограничения:

  • невозможно ограничить доступ к внутренним модулям пакета;
  • отсутствует нативная поддержка условий окружения;
  • слабый контроль над разными типами импортов.

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

Базовая структура exports

Минимальная форма:

{
  "exports": "./dist/index.js"
}

Это эквивалентно единственной публичной точке входа. Любые попытки импортировать внутренние файлы, например:

import x from "my-lib/internal/utils.js";

будут заблокированы, если они не описаны в exports.

Условные экспорты (conditional exports)

Ключевая возможность exports — выбор файла в зависимости от условий окружения. Это критично при сборке библиотек через Rollup, где обычно генерируются разные форматы.

Пример:

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

Здесь:

  • import используется для ESM-импортов;
  • require используется для CommonJS.

Node.js и современные сборщики (Vite, Webpack, Rollup) автоматически выбирают нужную ветку.

Поддержка browser и node условий

Можно расширять условия:

{
  "exports": {
    "node": {
      "import": "./dist/node/index.esm.js",
      "require": "./dist/node/index.cjs.js"
    },
    "browser": {
      "import": "./dist/browser/index.esm.js",
      "require": "./dist/browser/index.cjs.js"
    }
  }
}

Такая структура позволяет:

  • отделять браузерную и серверную логику;
  • снижать размер бандла;
  • исключать Node.js API из браузерной сборки.

Условие default

default используется как fallback:

{
  "exports": {
    "import": "./dist/index.esm.js",
    "require": "./dist/index.cjs.js",
    "default": "./dist/index.esm.js"
  }
}

Если среда не распознаёт условия, будет использован default.

Паттерн “subpath exports”

Одна из ключевых возможностей — явное описание внутренних модулей:

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

Теперь библиотека контролирует доступ:

import { helper } from "my-lib/utils";

Но при этом невозможно обратиться к неописанным путям:

import x from "my-lib/src/internal.js"; // ошибка

Влияние exports на Rollup-сборку

Rollup не использует exports напрямую, но структура пакета, определённая этим полем, влияет на архитектуру сборки.

Обычно при настройке Rollup:

  • создаются отдельные входные точки;
  • каждый entry соответствует ключу в exports;
  • output формируется в разные форматы.

Пример конфигурации:

export default {
  input: {
    index: "src/index.js",
    utils: "src/utils.js"
  },
  output: [
    {
      dir: "dist/esm",
      format: "esm"
    },
    {
      dir: "dist/cjs",
      format: "cjs"
    }
  ]
};

И затем package.json синхронизируется:

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

Ограничение доступа и инкапсуляция

Одно из ключевых свойств exports — жёсткая инкапсуляция.

Без exports:

my-lib/
  src/
  dist/
  internal/

Потребитель мог импортировать:

import x from "my-lib/internal/x.js";

С exports это становится невозможным, если путь не объявлен.

Это позволяет:

  • фиксировать публичный API;
  • безопасно рефакторить внутреннюю структуру;
  • уменьшать риск случайных зависимостей от внутренних модулей.

Wildcards и шаблоны

Node.js поддерживает шаблоны:

{
  "exports": {
    "./*": "./dist/*.js"
  }
}

Это позволяет проксировать структуру каталогов.

Пример использования:

import something from "my-lib/features/some-feature";

соответствует:

./dist/features/some-feature.js

Однако использование wildcard требует осторожности, поскольку:

  • усложняет контроль API;
  • может случайно открыть внутренние файлы;
  • снижает явность интерфейса библиотеки.

Совместимость с TypeScript

TypeScript учитывает exports при резолве модулей (начиная с современных версий при moduleResolution: node16 или nodenext).

Типичная конфигурация:

{
  "compilerOptions": {
    "moduleResolution": "node16"
  }
}

Структура exports позволяет TypeScript корректно:

  • определять доступные пути импорта;
  • предотвращать обращение к скрытым модулям;
  • синхронизировать типы с реальной структурой пакета.

Практическая интеграция с Rollup multi-format build

При сборке библиотеки с Rollup обычно формируется следующая стратегия:

  • один исходный код;
  • несколько выходных форматов;
  • синхронизация с exports.

Типичная схема:

  • src/index.jsdist/esm/index.js
  • src/index.jsdist/cjs/index.js
  • src/utils.jsdist/esm/utils.js
  • src/utils.jsdist/cjs/utils.js

И затем:

{
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js"
    },
    "./utils": {
      "import": "./dist/esm/utils.js",
      "require": "./dist/cjs/utils.js"
    }
  }
}

Ошибки и типичные проблемы

  1. Несоответствие путей сборки и exports Если Rollup выводит файлы в dist/esm, а exports указывает на dist/es, импорт сломается.

  2. Отсутствие require/import веток В средах Node.js без ESM поддержка может быть нарушена.

  3. Смешивание внутренних и публичных модулей Если не все entry points отражены в exports, структура становится непредсказуемой.

  4. Использование module вместе с exports Поле module игнорируется при наличии exports в Node.js-резолве.

Архитектурное значение exports в библиотечных проектах

exports фактически становится контрактом между библиотекой и потребителем. Он фиксирует:

  • границы публичного API;
  • допустимые точки входа;
  • поведение в разных средах;
  • структуру выходной сборки.

При использовании Rollup это превращается в централизованную модель, где:

  • сборка определяет файлы;
  • exports определяет доступ;
  • потребитель работает только через контракт.

Такой подход делает библиотеку предсказуемой, устойчивой к рефакторингу и совместимой с современными инструментами экосистемы JavaScript.