Поле exports в package.json итоговой библиотеки

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

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


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

Минимальная конфигурация:

{
  "name": "my-lib",
  "version": "1.0.0",
  "exports": {
    ".": "./dist/index.js"
  }
}

Точка "." обозначает корневой импорт:

import { fn } from "my-lib";

Без "exports" Node.js разрешает доступ ко всем файлам пакета, например:

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

При включении "exports" подобные пути становятся недоступны, если они явно не описаны.


Связь esbuild и генерации точек входа

esbuild часто используется для формирования нескольких выходных форматов:

  • ESM (format: "esm")
  • CommonJS (format: "cjs")
  • IIFE для браузера
  • отдельные бандлы для worker-окружений

При этом итоговая структура пакета должна соответствовать декларации "exports". Типичный сценарий:

esbuild.build({
  entryPoints: ["src/index.ts"],
  outdir: "dist",
  format: "esm",
  splitting: true,
  outExtension: { ".js": ".mjs" }
});

И соответствующий package.json:

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

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

Node.js поддерживает выбор реализации в зависимости от среды. Это особенно важно при сборке библиотек через esbuild, где часто генерируются разные форматы.

Основные условия

  • "import" — ESM-окружение
  • "require" — CommonJS
  • "browser" — браузерные сборки
  • "default" — fallback

Пример:

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

Поведение резолвинга

Node.js выбирает первое подходящее условие по приоритету окружения. Это позволяет одной библиотеке обслуживать несколько runtime без дублирования API.


Многоуровневые экспортные пути

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

{
  "exports": {
    ".": "./dist/index.mjs",
    "./utils": "./dist/utils.mjs",
    "./math/*": "./dist/math/*.mjs"
  }
}

Использование:

import { clamp } from "my-lib/utils";
import { sum } from "my-lib/math/array";

Паттерн с wildcard

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

Это позволяет зеркалировать структуру src/features/* в dist/features/* без явного перечисления файлов.


Влияние на архитектуру библиотеки

Использование "exports" фактически заставляет библиотеку стать API-ориентированной, а не файлово-ориентированной.

До введения exports

  • структура проекта напрямую отражалась в импортах
  • возможен доступ к внутренним файлам
  • рефакторинг ломал внешние зависимости

После введения exports

  • публичный API строго фиксирован
  • внутренняя структура скрыта
  • безопасный рефакторинг без breaking changes вне API

Совместимость с esbuild output

esbuild не управляет "exports" напрямую, но влияет на его корректность через:

1. Формат выходных файлов

format: "esm" | "cjs"

Это требует соответствия:

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

2. Расширения файлов

Node.js различает:

  • .js
  • .mjs
  • .cjs

esbuild позволяет задавать:

outExtension: {
  ".js": ".mjs"
}

Неправильное согласование расширений и "exports" приводит к ошибкам резолвинга.


3. Code splitting

При splitting: true появляются дополнительные чанки:

dist/
  index.mjs
  chunk-ABC123.mjs

Важно учитывать, что "exports" должен указывать только на входные точки, а не на чанки.


Поддержка типов TypeScript

Современные библиотеки часто комбинируют "exports" с "types".

Базовый вариант

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

Отдельные типы для subpath

{
  "exports": {
    "./utils": {
      "import": "./dist/utils.mjs",
      "types": "./dist/utils.d.ts"
    }
  }
}

Это позволяет TypeScript корректно резолвить типы без дополнительных paths в tsconfig.json.


Взаимодействие с "main" и "module"

Исторически использовались поля:

  • "main" — CommonJS entry
  • "module" — ESM entry (де-факто стандарт bundler-экосистемы)

Однако при наличии "exports" они становятся вторичными или игнорируются Node.js.

Типичная современная конфигурация:

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

Здесь "exports" имеет приоритет в Node.js, а "module" остаётся для старых сборщиков.


Типичные ошибки при использовании с esbuild

1. Несоответствие форматов

Сборка ESM, но экспорт указан как CommonJS:

"import": "./dist/index.cjs"

Последствие: ошибки SyntaxError: Cannot use import statement outside a module.


2. Отсутствие default fallback

"exports": {
  ".": {
    "import": "./dist/index.mjs"
  }
}

В старых окружениях это приводит к невозможности загрузки пакета.


3. Экспорт внутренних файлов без явной необходимости

Чрезмерное расширение API:

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

Это ломает инкапсуляцию и усложняет поддержку.


4. Несоответствие структуры dist

esbuild может переименовать или переместить файлы, но "exports" остаётся статичным. Любое расхождение приводит к runtime-ошибкам резолвинга.


Практическая схема для библиотек на esbuild

Типовая структура:

src/
  index.ts
  utils.ts
dist/
  index.mjs
  index.cjs
  utils.mjs
  utils.cjs

package.json:

{
  "name": "my-lib",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    },
    "./utils": {
      "import": "./dist/utils.mjs",
      "require": "./dist/utils.cjs",
      "types": "./dist/utils.d.ts"
    }
  }
}

Влияние на tree-shaking и сборку

Хотя "exports" не участвует напрямую в tree-shaking, он косвенно влияет на него:

  • ограничение доступа к внутренним файлам улучшает анализ зависимостей
  • явные ESM-экспорты позволяют esbuild и другим bundler’ам точнее удалять неиспользуемый код
  • разделение entry points уменьшает размер итогового бандла потребителя

Многоформатная публикация и стабильность API

Использование "exports" в связке с esbuild превращает сборку в контракт:

  • структура dist становится реализацией
  • "exports" становится спецификацией API
  • esbuild — механизмом генерации артефактов под этот контракт

Любое изменение в сборке требует синхронного обновления "exports", иначе пакет теряет предсказуемость поведения в разных runtime.