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

Package exports и условия: контракт публичного API

Поле exports — это публичный API-контракт пакета: оно перекрывает main, блокирует любой не указанный путь и направляет import vs require на разные файлы через условия — что также порождает и помогает избежать dual-package hazard двух расходящихся экземпляров модуля.

NODE Senior ◷ 20 min
Уровень
ОсновыJuniorMiddleSenior

Ты обновляешь зависимость на один минорный релиз — и сборка взрывается: Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './lib/parse' is not defined by "exports". Файл lib/parse.js всё ещё в node_modules, байт в байт неизменный. Изменилось то, что мейнтейнер добавил поле exports — и резолвер теперь считает любой не указанный в нём путь как будто его не существует. То же поле, использованное иначе, даёт библиотеке загрузиться дважды с двумя расходящимися копиями собственного состояния. Поле exports — самые судьбоносные девять строк в современном package.json, и большинство людей никогда не читают его правил.

exports перекрывает main и запечатывает пакет

Классическое поле main называет одну точку входа и оставляет остальной пакет нараспашку: любой потребитель может require("pkg/src/internal/helper.js"), потому что разрешение просто идёт по файловой системе. Поле exports переворачивает это. В момент, когда пакет объявляет exports, разом вступают в силу три правила:

  • Оно перекрывает main. Node сверяется с exports первым; main — лишь запасной вариант для пакетов без exports.
  • Это белый список, а не подсказка. Любой подпуть, не указанный, становится недостижимым — ERR_PACKAGE_PATH_NOT_EXPORTED — даже если файл физически существует. Это инкапсуляция: автор пакета решает публичную поверхность, а внутренности можно свободно рефакторить, потому что их никто не мог легально импортировать.
  • package.json нужно экспортировать явно, если потребители (или инструменты) его читают: "./package.json": "./package.json".

Вместе эти три правила превращают пакет из открытого-по-умолчанию в запечатанный-по-контракту. Без третьего правила инструменты молча ломаются; без второго — нет настоящей API-границы, и любой глубокий require("pkg/dist/internal"), работавший годами, продолжает работать, лишая тебя возможности безопасно рефакторить внутренности.

{
  "name": "mylib",
  "exports": {
    ".": "./dist/index.js",
    "./package.json": "./package.json"
  }
}

С этим import "mylib" разрешается в dist/index.js, а import "mylib/dist/anything-else.js" бросает — дверь глубоких импортов закрыта. Это и есть senior-причина добавлять exports осознанно: это единственный механизм, который Node даёт для проведения настоящей API-границы вокруг пакета, превращая «каждый файл публичен» в «публично лишь то, что я публикую».

Подпути-экспорты и шаблоны

Ключ . — корень пакета; дополнительные ключи задают подпути-экспорты — именованные точки входа, доступные потребителю как pkg/<подпуть>:

{
  "exports": {
    ".": "./dist/index.js",
    "./parser": "./dist/parser.js",
    "./utils/format": "./dist/utils/format.js"
  }
}

import "mylib/parser" теперь отображается в dist/parser.js — заметь, что публичное имя (/parser) отвязано от физического пути (dist/parser.js), поэтому файлы можно перемещать внутри, не ломая потребителей, пока карта стабильна. Для множества подпутей шаблоны с * избавляют от ручного перечисления. * — нежадный подстановочный знак, захватываемый слева и подставляемый справа:

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

Здесь import "mylib/features/auth" разрешается в dist/features/auth.js. Шаблоны экспонируют целую директорию одним правилом, поэтому применяй их, когда действительно хочешь сделать это поддерево публичным — и помни о компромиссе инкапсуляции: широкий шаблон ./* вновь раскрывает пакет почти так же широко, как полное отсутствие exports.

Условные экспорты: один спецификатор, много целей

Значением подпути может быть объект условий вместо строки: Node проходит его ключи по порядку и берёт первый, чьё условие совпало с текущей средой. Так один пакет отдаёт ESM на import и CommonJS на require:

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

Порядок значим — выигрывает первое совпадение, а не самое специфичное — поэтому общепринятый порядок: types первым (инструменты читают его до запуска кода), затем рантайм-условия, затем default последним как заглушка-перехватчик. Частые условия:

УсловиеСовпадает, когда…Типичная цель
importЗагружено через import / динамический import()ESM-сборка (.mjs)
requireЗагружено через require()CJS-сборка (.cjs)
nodeРаботает на Node (любой загрузчик)Node-специфичный код
typesTypeScript разрешает пакет.d.ts-декларации
defaultВсегда (финальный запасной)Безопасный универсальный вход
Почему это работает

Почему types идёт первым, если это даже не рантайм-условие? Потому что TypeScript разрешает пакет во время проверки, до запуска любого кода, и останавливается на первом совпавшем ключе ровно как Node. Если import или require стоит выше types, TypeScript может совпасть с JavaScript-целью и никогда не найти декларации — поэтому ставь types наверх каждого блока условий. Зеркальная ловушка — поставить default куда угодно, кроме последней позиции: поскольку совпадение по принципу «первое выигрывает», default выше require затмит его, и каждый потребитель получит цель default независимо от того, как он загрузил пакет.

Поле imports: приватные внутренние спецификаторы

Где exports определяет наружный API, родственное поле imports определяет внутренние псевдонимы — приватные спецификаторы, всегда с префиксом #, видимые только внутри самого пакета:

{
  "imports": {
    "#db": {
      "node": "./src/db/postgres.js",
      "default": "./src/db/memory.js"
    },
    "#internal/*": "./src/internal/*.js"
  }
}

Внутри пакета ты пишешь import db from "#db", и Node разрешает его через карту — с полной поддержкой условий, так что #db может указывать на реальную БД на Node и на in-memory-заглушку в другом месте. В отличие от относительного пути, спецификатор # стабилен независимо от глубины вложенности импортирующего файла, и в отличие от голого спецификатора он никогда не убегает в node_modules. Это чистый способ внутреннего алиасинга без сборщика и фокусов с tsconfig paths.

Dual-package hazard

Условные экспорты позволяют легко поставлять и ESM-, и CJS-сборку. Ловушка: если обе сборки несут состояние, приложение может загрузить обе — ESM-код достигает цели import, CJS-код достигает цели require — и теперь сосуществуют два отдельных экземпляра модуля твоего пакета, у каждого своя копия каждой переменной, реестра и instanceof-идентичности. Синглтоны молча двоятся; объект, созданный одной половиной, проваливает проверку instanceof в другой. Это dual-package hazard (ловушка двух экземпляров одного пакета).

// Приложение случайно загружает обе половины:
import { registry } from "mylib";        // → dist/index.mjs экземпляр A
const { registry: r2 } = require("mylib"); // → dist/index.cjs экземпляр B
// registry !== r2  — два состояния, hazard

Надёжные способы избежать, по убыванию предпочтительности: (1) поставлять только ESM и дать CJS-потребителям доступ через динамический import(), чтобы был ровно один экземпляр; либо (2) держать всю состояние-несущую логику в едином CJS- (или едином ESM-) ядре, а другое условие сделать тонкой обёрткой, реэкспортирующей тот же экземпляр, а не вторую копию. Антипаттерн — две независимые полные сборки, каждая со своим состоянием — именно эта конфигурация кусается.

Викторина

После того как зависимость добавила поле exports, require('pkg/lib/parse') бросает ERR_PACKAGE_PATH_NOT_EXPORTED, хотя lib/parse.js всё ещё существует. Почему?

Викторина

В блоке условий ты ставишь 'default' выше 'require'. Что произойдёт с CommonJS-потребителями?

Выбери лучший вариант

Ты публикуешь библиотеку с состоянием (она держит реестр плагинов) и хочешь, чтобы и import-, и require-потребители работали без dual-package hazard. Что ты поставляешь?

Вспомните перед уходом
  1. 01
    Объясни, что поле exports меняет в разрешении в момент, когда пакет его добавляет, и почему ранее работавший глубокий импорт может вдруг упасть.
  2. 02
    Что такое dual-package hazard, как условные экспорты его вызывают и как его избежать?
Итог

Поле exports — самая судьбоносная запись в современном package.json, потому что превращает пакет из открытой директории в запечатанный публичный API. В момент его появления оно перекрывает main и действует как белый список: любой не указанный явно подпуть бросает ERR_PACKAGE_PATH_NOT_EXPORTED, хотя файл всё ещё существует — это инкапсуляция, позволяющая авторам безопасно рефакторить внутренности и объясняющая, почему добавление exports может сломать давний глубокий импорт. Подпути-экспорты отвязывают публичное имя от физического пути, а шаблоны * экспонируют целое поддерево одним правилом (ценой повторного раскрытия инкапсуляции, если шаблон слишком широк). Значением подпути может быть объект условий, который Node проходит сверху вниз, выигрывает первое совпадение, чтобы отдать разные файлы на import, require, node, types и default — поэтому порядок важен: types первым, чтобы TypeScript нашёл декларации, default последним как перехватчик. Родственное поле imports определяет приватные спецификаторы #, внутренние, учитывающие условия и стабильные независимо от глубины файла. Наконец, условные экспорты порождают dual-package hazard: две сборки с состоянием, загруженные вместе, становятся двумя расходящимися экземплярами с раздельным состоянием и сломанной instanceof-идентичностью. Избегай его, поставляя один экземпляр — только ESM с динамическим import() для CJS-потребителей, либо единое ядро с состоянием, которое другое условие лишь реэкспортирует. Теперь, когда в следующем минорном обновлении встретишь ERR_PACKAGE_PATH_NOT_EXPORTED, — знаешь почему: файл на месте, но мейнтейнер добавил exports и не указал этот путь; открой карту exports, найди нужную точку входа и двигайся дальше.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.