Поле "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 часто используется для формирования нескольких выходных форматов:
format: "esm")format: "cjs")При этом итоговая структура пакета должна соответствовать декларации
"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"
}
}
}
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";
{
"exports": {
"./features/*": "./dist/features/*.js"
}
}
Это позволяет зеркалировать структуру src/features/* в
dist/features/* без явного перечисления файлов.
Использование "exports" фактически заставляет библиотеку
стать API-ориентированной, а не файлово-ориентированной.
esbuild не управляет "exports" напрямую, но влияет на
его корректность через:
format: "esm" | "cjs"
Это требует соответствия:
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
Node.js различает:
.js.mjs.cjsesbuild позволяет задавать:
outExtension: {
".js": ".mjs"
}
Неправильное согласование расширений и "exports"
приводит к ошибкам резолвинга.
При splitting: true появляются дополнительные чанки:
dist/
index.mjs
chunk-ABC123.mjs
Важно учитывать, что "exports" должен указывать только
на входные точки, а не на чанки.
Современные библиотеки часто комбинируют "exports" с
"types".
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
}
}
{
"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" остаётся для старых сборщиков.
Сборка ESM, но экспорт указан как CommonJS:
"import": "./dist/index.cjs"
Последствие: ошибки
SyntaxError: Cannot use import statement outside a module.
"exports": {
".": {
"import": "./dist/index.mjs"
}
}
В старых окружениях это приводит к невозможности загрузки пакета.
Чрезмерное расширение API:
"exports": {
"./internal/*": "./dist/internal/*"
}
Это ломает инкапсуляцию и усложняет поддержку.
esbuild может переименовать или переместить файлы, но
"exports" остаётся статичным. Любое расхождение приводит к
runtime-ошибкам резолвинга.
Типовая структура:
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"
}
}
}
Хотя "exports" не участвует напрямую в tree-shaking, он
косвенно влияет на него:
Использование "exports" в связке с esbuild превращает
сборку в контракт:
dist становится реализацией"exports" становится спецификацией APIЛюбое изменение в сборке требует синхронного обновления
"exports", иначе пакет теряет предсказуемость поведения в
разных runtime.