На этой странице

Все приведённые конфигурации package.json (кроме специально помеченных «не работает») работают в Node.js 12.22.x (последняя v12, самая старая поддерживаемая линейка) и 17.2.0 (последняя актуальная на тот момент)1, а также, шутки ради, с webpack 5.53.0 и 5.63.0 соответственно. Они доступны здесь: JakobJingleheimer/nodejs-module-config-examples.

Для любопытных: разделы Как мы сюда попали и В кроличью нору дают предысторию и более глубокие объяснения.

Есть 2 основных варианта, покрывающих почти все сценарии:

  • Писать исходный код и публиковать в CJS (вы используете require()); CJS потребляем и из CJS, и из ESM (во всех версиях node). Перейдите к CJS-исходники и дистрибуция.
  • Писать исходный код и публиковать в ESM (вы используете import и не используете top-level await); ESM потребляем и из ESM, и из CJS (в node 22.x и 23.x; см. require() ES-модуля). Перейдите к ESM-исходники и дистрибуция.

В целом лучше публиковать только 1 формат — либо CJS, либо ESM. Не оба. Публикация нескольких форматов может привести к опасности двойного пакета (dual-package hazard), а также к другим недостаткам.

Есть и другие варианты, в основном по историческим причинам.

Вы как автор пакета пишетеПотребители вашего пакета пишут свой код вВаши варианты
CJS-исходники через require()ESM: потребители делают import вашего пакетаCJS-исходники и только ESM-дистрибуция
CJS-исходники через require()CJS и ESM: потребители делают require() или import вашего пакетаCJS-исходники и дистрибуция и в CJS, и в ESM
ESM-исходники через importCJS: потребители делают require() вашего пакета (и вы используете top-level await)ESM-исходники и только CJS-дистрибуция
ESM-исходники через importCJS и ESM: потребители делают require() или import вашего пакетаESM-исходники и дистрибуция и в CJS, и в ESM

Самая минимальная конфигурация может состоять только из "name". Но чем меньше магии, тем лучше: по сути, просто объявите экспорты пакета через поле (или набор полей) "exports".

Рабочий пример: cjs-with-cjs-distro

{
  "name": "cjs-source-and-distribution"
  // "main": "./index.js"
}

Обратите внимание, что packageJson.exports["."] = filepath — это сокращение для packageJson.exports["."].default = filepath

Просто, испытано и надёжно.

Обратите внимание, что начиная с Node.js v23.0.0 можно require статического ESM (кода, не использующего top-level await). Подробнее см. Загрузка ES-модулей через require().

Это почти в точности то же самое, что конфигурация CJS-CJS выше, с 1 небольшим отличием: поле "type".

Рабочий пример: esm-with-esm-distro

{
  "name": "esm-source-and-distribution",
  "type": "module"
  // "main": "./index.js"
}

Обратите внимание, что ESM теперь является «обратно» совместимым с CJS: CJS-модуль теперь может require() ES-модуль без флага начиная с 23.0.0 и 22.12.0.

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

Рабочий пример: cjs-with-esm-distro

{
  "name": "cjs-source-with-esm-distribution",
  "main": "./dist/index.mjs"
}

Расширение файла .mjs — козырь: оно переопределяет любую другую конфигурацию, и файл будет трактоваться как ESM. Использование этого расширения необходимо, потому что packageJson.exports.import НЕ означает, что файл — ESM (вопреки распространённому, если не всеобщему, заблуждению), а лишь означает, что это файл, используемый при импорте пакета (ESM может импортировать CJS. См. Подводные камни ниже).

Чтобы напрямую обслуживать обе аудитории (чтобы ваша дистрибуция работала «нативно» в любой из них), у вас несколько вариантов:

Классика, но требует некоторой изощрённости и сноровки. Это означает добавление свойств к существующему module.exports (вместо переприсваивания module.exports целиком).

Рабочий пример: cjs-with-dual-distro (properties)

{
  "name": "cjs-source-with-esm-via-properties-distribution",
  "main": "./dist/cjs/index.js"
}

Плюсы:

  • Меньший вес пакета
  • Легко и просто (вероятно, наименьшие усилия, если вы не против придерживаться небольшого требования к синтаксису)
  • Исключает опасность двойного пакета

Минусы:

  • Требует очень специфичного синтаксиса (в исходном коде и/или акробатики с бандлером).

Иногда CJS-модуль может переприсвоить module.exports чему-то другому (объекту или функции) вот так:

const someObject = {
  foo() {},
  bar() {},
  qux() {},
};

module.exports = someObject;

Node.js обнаруживает именованные экспорты в CJS через статический анализ, ищущий определённые паттерны, которых пример выше избегает. Чтобы сделать именованные экспорты обнаруживаемыми, сделайте так:

module.exports.foo = function foo() {};
module.exports.bar = function bar() {};
module.exports.qux = function qux() {};

Сложная настройка, и трудно поймать баланс.

Рабочий пример: cjs-with-dual-distro (wrapper)

{
  "name": "cjs-with-wrapper-dual-distro",
  "exports": {
    ".": {
      "import": "./dist/esm/wrapper.mjs",
      "require": "./dist/cjs/index.js",
      "default": "./dist/cjs/index.js"
    }
  }
}

Плюсы:

  • Меньший вес пакета

Минусы:

  • Вероятно, требует сложной акробатики с бандлером (мы не нашли готовой опции для автоматизации этого в Webpack).

Когда CJS-вывод из бандлера избегает обнаружения именованных экспортов в Node.js, ESM-обёртку можно использовать, чтобы явно реэкспортировать известные именованные экспорты для ESM-потребителей.

Когда CJS экспортирует объект (который становится псевдонимом ESM-default), можно сохранить в обёртке ссылки на все члены объекта локально, а затем реэкспортировать их, чтобы ESM-потребитель мог обращаться ко всем по имени.

import cjs from '../cjs/index.js';

const { a, b, c /* … */ } = cjs;

export { a, b, c /* … */ };

Однако это ломает «живые» привязки (live bindings): переприсваивание cjs.a не отразится в esmWrapper.a.

Накидать кучу всего и надеяться на лучшее. Это, вероятно, самый распространённый и самый лёгкий из вариантов «CJS → CJS и ESM», но за него приходится платить. Это редко хорошая идея.

Рабочий пример: cjs-with-dual-distro (double)

{
  "name": "cjs-with-full-dual-distro",
  "exports": {
    ".": {
      "import": "./dist/esm/index.mjs",
      "require": "./dist/cjs/index.js",
      "default": "./dist/cjs/index.js"
    }
  }
}

Плюсы:

  • Простая конфигурация бандлера

Минусы:

Как вариант, можно использовать ключи "default" и "node", которые менее контринтуитивны: Node.js всегда выберет опцию "node" (которая всегда работает), а инструменты вне Node.js выберут "default", когда настроены нацеливаться на что-то отличное от node. Это исключает опасность двойного пакета.

{
  "name": "cjs-with-alt-full-dual-distro",
  "exports": {
    ".": {
      "node": "./dist/cjs/index.js",
      "default": "./dist/esm/index.mjs"
    }
  }
}

Мы больше не в Канзасе, Тото.

Конфигурации (есть 2 варианта) почти те же, что у ESM-исходников и дистрибуции и в CJS, и в ESM, просто исключите packageJson.exports.import.

💡 Использование "type": "module"2 в паре с расширением файла .cjs (для commonjs-файлов) даёт лучшие результаты. Подробнее о том почему, см. В кроличью нору и Подводные камни ниже.

Рабочий пример: esm-with-cjs-distro

Когда исходный код написан не на JavaScript (например, на TypeScript), варианты могут быть ограничены из-за необходимости использовать расширения файлов, специфичные для этого языка (например, .ts), и эквивалента .mjs может не быть.

Аналогично CJS-исходникам и дистрибуции и в CJS, и в ESM, у вас те же варианты.

Хитро сделать, нужны хорошие «ингредиенты».

Этот вариант почти идентичен экспортам-свойствам из «CJS-исходники с CJS и ESM дистрибуцией» выше. Единственное отличие — в package.json: "type": "module".

Только некоторые инструменты сборки поддерживают генерацию такого вывода. Rollup производит совместимый вывод «из коробки» при нацеливании на commonjs. Webpack начиная с v5.66.0+ — с новым типом вывода commonjs-static (до этого ни одна из опций commonjs не производила совместимый вывод). В настоящее время это невозможно с esbuild (который производит нестатический exports).

Рабочий пример ниже был создан до недавнего релиза Webpack, поэтому использует Rollup (доберусь и до добавления варианта с Webpack).

Эти примеры предполагают, что javascript-файлы внутри используют расширение .js; "type" в package.json управляет тем, как они интерпретируются:

"type":"commonjs" + .js → cjs
"type":"module" + .js → mjs

Если ваши файлы явно все используют расширения .cjs и/или .mjs (ни один не использует .js), "type" избыточен.

Рабочий пример: esm-with-cjs-distro

{
  "name": "esm-with-cjs-distribution",
  "type": "module",
  "main": "./dist/index.cjs"
}

💡 Использование "type": "module"2 в паре с расширением файла .cjs (для commonjs-файлов) даёт лучшие результаты. Подробнее о том почему, см. В кроличью нору и Подводные камни ниже.

Здесь много всего происходит, и обычно это не лучший вариант.

Это тоже почти идентично CJS-исходникам и двойной дистрибуции с ESM-обёрткой, но с тонкими отличиями: "type": "module" и некоторые расширения .cjs в package.json.

Рабочий пример: esm-with-dual-distro (wrapper)

{
  "name": "esm-with-cjs-and-esm-wrapper-distribution",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/esm/wrapper.js",
      "require": "./dist/cjs/index.cjs",
      "default": "./dist/cjs/index.cjs"
    }
  }
}

💡 Использование "type": "module"2 в паре с расширением файла .cjs (для commonjs-файлов) даёт лучшие результаты. Подробнее о том почему, см. В кроличью нору и Подводные камни ниже.

Накидать кучу всего (с сюрпризом) и надеяться на лучшее. Это, вероятно, самый распространённый и самый лёгкий из вариантов «ESM → CJS и ESM», но за него приходится платить. Это редко хорошая идея.

С точки зрения конфигурации пакета есть несколько вариантов, различающихся в основном личными предпочтениями.

У этого варианта наименьшее бремя для разработки/опыта разработчика.

Это также означает, что какой бы инструмент сборки ни использовался, он должен произвести файл дистрибуции с расширением .cjs. Это может потребовать сцепления нескольких инструментов сборки или добавления последующего шага для перемещения/переименования файла, чтобы он имел расширение .cjs (например, mv ./dist/index.js ./dist/index.cjs). Это можно обойти, добавив последующий шаг для перемещения/переименования выведенных файлов (например, Rollup или простой shell-скрипт).

Поддержка расширения .cjs была добавлена в 12.0.0, и его использование заставит ESM правильно распознать файл как commonjs (import { foo } from './foo.cjs' работает). Однако require() не разрешает .cjs автоматически, как это делает для .js, так что расширение файла нельзя опустить, как это принято в commonjs: require('./foo') не сработает, а require('./foo.cjs') работает. Использование его в exports вашего пакета не имеет недостатков: packageJson.exports (и packageJson.main) в любом случае требуют расширения файла, а потребители ссылаются на ваш пакет по полю "name" вашего package.json (так что они в блаженном неведении).

Рабочий пример: esm-with-dual-distro

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

Как вариант, можно использовать ключи "default" и "node", которые менее контринтуитивны: Node.js всегда выберет опцию "node" (которая всегда работает), а инструменты вне Node.js выберут "default", когда настроены нацеливаться на что-то отличное от node. Это исключает опасность двойного пакета.

{
  "type": "module",
  "exports": {
    ".": {
      "node": "./dist/index.cjs",
      "default": "./dist/esm/index.js"
    }
  }
}

💡 Использование "type": "module"2 в паре с расширением файла .cjs (для commonjs-файлов) даёт лучшие результаты. Подробнее о том почему, см. В кроличью нору и Подводные камни ниже.

Конфигурация для этого та же, что у CJS-исходников и дистрибуции и в CJS, и в ESM.

Исходный код не на JavaScript: конфигурация самого не-JavaScript-языка должна распознавать/указывать, что входные файлы — ESM.

🛑 Так делать не стоит: версии Node.js до 12.x достигли конца жизни (End of Life) и теперь уязвимы к серьёзным эксплойтам безопасности.

Если вы исследователь безопасности, которому нужно изучить Node.js до v12.22.x, свяжитесь с нами — поможем с конфигурацией.

Определение синтаксиса (syntax detection) не заменяет правильную конфигурацию пакета; определение синтаксиса не безошибочно и имеет значительную стоимость по производительности.

При использовании "exports" в package.json обычно хорошая идея — включить "./package.json": "./package.json", чтобы его можно было импортировать (module.findPackageJSON этим ограничением не затронут, но import может быть удобнее).

"exports" может быть предпочтительнее "main", потому что предотвращает внешний доступ к внутреннему коду (так что можно быть относительно уверенным, что пользователи не зависят от того, от чего не должны). Если вам это не нужно, "main" проще и может быть лучшим вариантом для вас.

Поле "engines" даёт понятную и человеку, и машине индикацию того, с какими версиями Node.js совместим пакет. В зависимости от используемого менеджера пакетов может выбрасываться исключение, приводящее к сбою установки, когда потребитель использует несовместимую версию Node.js (что может быть очень полезно потребителям). Включение этого поля избавит от многих головных болей потребителей со старой версией Node.js, которые не могут использовать пакет.

Применительно именно к Node.js, есть 4 проблемы, которые нужно решить:

  • Определение формата файлов исходного кода (автор запускает свой собственный код)

  • Определение формата файлов дистрибуции (код, который получат потребители)

  • Публикация кода дистрибуции для случая, когда его require() (потребитель ожидает CJS)

  • Публикация кода дистрибуции для случая, когда его import (потребитель, вероятно, хочет ESM)

⚠️ Первые 2 независимы от последних 2.

Способ загрузки НЕ определяет формат, в котором интерпретируется файл:

  • exports.require в package.json ≠ CJS. require() НЕ и не может слепо интерпретировать файл как CJS; например, require('foo.json') правильно интерпретирует файл как JSON, а не как CJS. Модуль, содержащий вызов require(), разумеется, должен быть CJS, но то, что он загружает, не обязательно тоже CJS.
  • exports.import в package.json ≠ ESM. import аналогично НЕ и не может слепо интерпретировать файл как ESM; import может загружать CJS, JSON и WASM, а также ESM. Модуль, содержащий инструкцию import, разумеется, должен быть ESM, но то, что он загружает, не обязательно тоже ESM.

Так что когда вы видите опции конфигурации, ссылающиеся на require или import (или названные так), сопротивляйтесь желанию предположить, что они предназначены для определения CJS против ES-модулей.

⚠️ Добавление поля (или набора полей) "exports" в конфигурацию пакета фактически блокирует глубокую адресацию (deep pathing) внутрь пакета для всего, что явно не перечислено в подпутях exports. Это значит, что это может быть ломающим изменением.

⚠️ Тщательно взвесьте, стоит ли распространять и CJS, и ESM: это создаёт потенциал для опасности двойного пакета (особенно при неверной конфигурации и если потребитель попытается схитрить). Это может привести к крайне запутанному багу в потребляющих проектах, особенно когда ваш пакет сконфигурирован неидеально. Потребителя может даже застать врасплох промежуточный пакет, использующий «другой» формат вашего пакета (например, потребитель использует ESM-дистрибуцию, а какой-то другой пакет, который потребитель тоже использует, сам использует CJS-дистрибуцию). Если ваш пакет каким-либо образом хранит состояние, потребление и CJS-, и ESM-дистрибуции приведёт к параллельным состояниям (что почти наверняка непреднамеренно).

Когда приложение использует пакет, предоставляющий и CommonJS-, и ES-модульные исходники, есть риск определённых багов, если оба экземпляра пакета загрузятся. Этот потенциал возникает из того, что pkgInstance, созданный через const pkgInstance = require('pkg'), — не тот же, что pkgInstance, созданный через import pkgInstance from 'pkg' (или альтернативный главный путь вроде 'pkg/module'). Это и есть «опасность двойного пакета», когда два экземпляра одного пакета могут быть загружены в одном и том же runtime-окружении. Хотя маловероятно, что приложение или пакет намеренно загрузят оба экземпляра напрямую, часто бывает, что приложение загружает одну копию, а зависимость приложения загружает другую. Эта опасность возможна, потому что Node.js поддерживает смешивание CommonJS и ES-модулей, и может привести к неожиданному и запутанному поведению.

Если главный экспорт пакета — конструктор, сравнение instanceof для экземпляров, созданных двумя копиями, вернёт false, а если экспорт — объект, свойства, добавленные к одному (вроде pkgInstance.foo = 3), отсутствуют на другом. Это отличается от того, как инструкции import и require работают в целиком-CommonJS или целиком-ES-модульных окружениях соответственно, и потому удивляет пользователей. Это также отличается от поведения, к которому пользователи привыкли при использовании транспиляции инструментами вроде Babel или esm.

CommonJS (CJS) был создан задолго до ECMAScript-модулей (ESM), когда JavaScript был ещё подростком — CJS и jQuery появились с разницей всего в 3 года. CJS не является официальным стандартом (TC39) и поддерживается ограниченным числом платформ (в первую очередь Node.js). ESM как стандарт «шёл» несколько лет; сейчас он поддерживается всеми основными платформами (браузеры, Deno, Node.js и т. д.), а значит, будет работать практически везде. Когда стало ясно, что ESM фактически сменит CJS (который всё ещё очень популярен и распространён), многие пытались внедрить его пораньше, часто до того, как тот или иной аспект спецификации ESM был финализирован. Из-за этого решения со временем менялись по мере появления более качественной информации (нередко на основе уроков/опыта тех самых нетерпеливых энтузиастов), переходя от «догадки» к соответствию спецификации.

Дополнительное усложнение — бандлеры, которые исторически управляли большей частью этой территории. Однако многое из того, чем раньше должны были управлять бандлеры, теперь является нативной функциональностью; тем не менее бандлеры всё ещё нужны (и, вероятно, всегда будут нужны) для некоторых вещей. К сожалению, функциональность, которую бандлерам больше не нужно предоставлять, глубоко въелась в реализации старых бандлеров, так что порой они бывают чересчур услужливы, а в некоторых случаях — антипаттерном (бандлить библиотеку часто не рекомендуют сами авторы бандлеров). Как и почему — тема для отдельной статьи.

Поле "type" в package.json меняет смысл расширения .js на commonjs или ES-module соответственно. В двойных/смешанных пакетах (содержащих и CJS, и ESM) очень часто используют это поле неправильно.

{
  "type": "module",
  "main": "./dist/CJS/index.js",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js",
      "default": "./dist/cjs/index.js"
    },
    "./package.json": "./package.json"
  }
}

Это не работает, потому что "type": "module" заставляет packageJson.main, packageJson.exports["."].require и packageJson.exports["."].default интерпретироваться как ESM (хотя на самом деле они CJS).

Исключение "type": "module" порождает обратную проблему:

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

Это не работает, потому что packageJson.exports["."].import будет интерпретироваться как CJS (хотя на самом деле это ESM).

Footnotes

  1. В Node.js v13.0–13.6 был баг, из-за которого packageJson.exports["."] должен был быть массивом с подробными опциями конфигурации в качестве первого элемента (объектом) и «default» в качестве второго элемента (строкой). См. nodejs/modules#446. ↩

  2. Поле "type" в package.json меняет смысл расширения .js, аналогично атрибуту type у HTML-элемента script. ↩ ↩2 ↩3 ↩4