Привет! Меня зовут Никита, и я тружусь в фронтенд-команде платформы в Ozon. Платформа поставляет инструменты для создания и поддержки JS-проектов. В компании в настоящее время более 500 таких проектов. Мы прилагаем максимум усилий, чтобы разработчикам всех проектов было одинаково приятно работать с нашими инструментами.
Также мы предоставляем инструменты для создания JS-библиотек. И в этой статье я расскажу о том, как мы советуем создавать npm-пакеты. Отмечу, что это не касается UIKit-пакетов, — для них требуется довольно специфичный инструментарий, который заслуживает отдельной статьи.
Недавно у нас проходила актуализация инструментов, которая включала обновление версий Node, TypeScript и прочего. И мы обнаружили, что сейчас правильно упаковать библиотеку ой как нелегко, особенно с началом активной фазы по отказу от CommonJS. В идеале очень хочется иметь инструмент, который бы просто работал. В open-source есть парочка вариантов (unbuild, pkgroll, dnt), но выбрать подходящий мы пока не смогли. А написать свой — довольно трудоёмкая задача. В будущем мы обязательно обзаведёмся таким инструментом, а пока просто погрузились в тему и подготовили для наших разработчиков рекомендованные сетапы, которыми сейчас поделимся и с вами.
Требования
Сегодня npm-пакет может быть использован и в приложениях для браузера, и в скриптах, и в серверной среде. А также он должен предоставлять типизированное API, чтобы им было удобно пользоваться.
Также дополнительные трудности привносит наличие двух систем модулей CommonJS (CJS) и ECMAScript modules (ESM), совместимость которых оставляет желать лучшего. Проблема их совместимости актуальна как никогда, в силу начала активной фазы миграции c CJS на ESM, когда популярные пакеты начинают отказываться от CJS (пример).
Но обо всем по порядку.
Использование в браузере через сборщик
В этом сценарии при подключении содержимого пакета в конечном проекте к нему применяются дополнительные преобразования, позволяющие использовать код в браузере.
Актуально использование пакета в браузере именно через сборщик, а не в чистом виде, поскольку для оптимального запуска кода в браузере требуются дополнительные транспиляции, минификации, полифилы. Эти штуки очень сложно универсально подобрать в библиотеке, ведь они зависят от нужд конкретного проекта и должны настраиваться именно там.
Важным свойством пакета для этого сценария является возможность произвести tree-shaking библиотеки во время сборки.
Использование в серверной среде
В этом сценарии пакет подключается в серверной среде исполнения без дополнительных преобразований.
В качестве серверной среды в основном подразумевается Node. Существуют альтернативы (Deno, Bun и другие), однако они обладают поддержкой Node-пакетов. Поэтому можно ориентироваться только на Node.
В плане обновления версий с Node дела обстоят лучше, чем с браузерами, поэтому авторы библиотек в большинстве случаев рассчитывают только на актуальные LTS-версии Node. На данный момент поддерживаемыми LTS-версиями являются 18, 20 и 22.
Ключевая проблема сейчас заключается в наличии двух систем модулей: CommonJS (CJS) и ESM.
Традиционная для Node система модулей CJS продолжает оставаться актуальной, но происходит активная миграция на нативную – ESM. В Node эти системы обладают совместимостью, но не 100%-ной и с множеством особенностей.
Явно отмечу, что тут я говорю о формате поставляемых внутри пакета файлов. Для исходного кода как правило используют ESM-файлы, которые в процессе формирования пакета преобразуются в файлы с нужным форматом.
Какой перед ней модуль Node определяет по правилам, которые кратко можно сформулировать так:
Условие |
Формат |
|
файл c расширением |
CJS |
|
файл c расширением |
ESM |
|
для файлов c расширением |
||
поле "type" отсутствует |
пока CJS, а в будущем ожидается ESM |
|
в поле "type" значение "commonjs" |
СJS |
|
в поле "type" значение "module" |
ESM |
Для загрузки модуля есть три варианта:
Загрузчик |
Описание |
Особенности |
|
Доступно только в ESM-файлах |
|
Может загружать ESM-файлы | ||
Может загружать СJS-файлы, но могут быть проблемы с named imports, если они не поддаются статическому анализу, или используется "default" экспорт. | ||
|
Доступно и в ESM- и в CJS-файлах |
|
Может загружать ESM-файлы | ||
Может загружать СJS-файлы, но могут быть проблемы с named imports, если они не поддаются статическому анализу, или используется "default" экспорт. | ||
|
Всегда доступно в CJS-файлах |
|
Доступно в ESM-файлах, но с помощью "createRequire" из "node:module" | ||
Сейчас может загружать только CJS-файлы, но в будущем сможет загружать ESM-файлы, если в них нет top-level await |
Также в этой системе существуют conditional exports, которые при подключении пакета по имени могут выдавать разные файлы в зависимости от способа загрузки:
// package.json
{
"type": "module",
"exports": {
"import": "./index-module.js",
"require": "./index-require.cjs"
}
}
Наверняка вы встречали немало библиотек, которые активно используют conditional exports и дублируют код, предоставляя файлы как в формате CJS, так и в ESM. Однако слепо следовать этому подходу не рекомендуется, поскольку это может привести к багам, которые очень сложно отловить.
Эта проблема называется dual package hazard. Она заключается в том, что если в рантайме библиотека загружена в одном месте через require
, а в другом — через import
, то это будут разные модули со своими экземплярами объектов, которые хранят разные состояния. Это вполне логично, ведь были загружены разные файлы. Следить за этим довольно сложно. Вы можете договориться всегда использовать, например, import
библиотеки, но в конечном проекте может появиться зависимость, которая будет использовать require
библиотеки, и с этим ничего не поделать. По-хорошему, каждый участок кода должен быть только в одном экземпляре: или в ESM, или в CJS.
Разобраться со всем этим довольно непросто, тем не менее от современного пакета ожидается, что он способен работать в этом хаосе.
К слову, отчасти может помочь TypeScript. Он предлагает значения "Node16" и "NodeNext" для опции "module", которые включают дополнительные проверки для выявления ошибок совместимости ESM и CJS при проверке типов.
Предоставление типизированного API
Использование в проекте
Это базовый сценарий, когда потребитель вашей библиотеки может получить типы для TypeScript, чтобы они обеспечили удобство разработки.
Как найти правильные типы для пакета, определяет опция "moduleResolution", которую потребитель библиотеки устанавливает в своём проекте.
В последней версии TypeScript актуальными значениями для "moduleResolution" являются "Bundler", "Node16", "NodeNext". Главное отличие этих значений от привычного "Node" — это поддержка полей "exports" и "imports" в соответствии с современной реализацией в Node. К слову, само значение "Node" в последней версии TypeScript переименовано в "Node10".
Разработчики TypeScript рекомендуют мигрировать с "Node10" на одно из новых значений. Однако с этим не всё так просто. При наличии поля "exports" TypeScript будет использовать его для обнаружения деклараций и игнорировать традиционные поля "main"/"types" и "typesVersions". Во многих библиотеках уже используется поле "exports", однако в нём не всегда указывают типы. Это приводит к тому, что TypeScript не подтягивает типы при использовании новых значений "moduleResolution". Исправить это могут только авторы библиотек, поэтому значение "Node10" будет актуально ещё продолжительное время.
В итоге современный пакет должен хорошо работать как с новыми значениями "moduleResolution", так и со старыми, то есть указывать типы и в поле "exports", и в традиционных полях "main"/"types" и "typesVersions".
Использование в TypeScript-декларациях
В этом сценарии потребителем вашей библиотеки является другая библиотека, публичный API которой транзитивно полагается на типы вашей библиотеки.
Представим, что есть ваша библиотека "some-lib":
// src/lib/value.ts
export type Value<T> = {
arg: T
}
// src/index.ts
import { Value } from "./lib/value.js"
export function getValue<T>(arg: T): Value<T> {
return { arg }
}
Есть библиотека потребителя "some-consumer-lib":
// src/index.ts
import { getValue } from "some-lib"
export function getMyValue() {
return getValue('my-special-arg')
}
И есть конечный потребитель:
// index.ts
import { getMyValue } from "some-сonsumer-lib"
let myVar = getMyValue()
Рассмотрим варианты, когда могут возникнуть проблемы.
Допустим, ваша библиотека "some-lib" по какой-то причине использует поле "exports" (при этом также есть поле "types", чтобы поддержать работу со всеми значениями опции "moduleResolution"):
// package.json
{
"name": "some-lib",
...
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
...
}
Также допустим, библиотека "some-consumer-lib" при формировании деклараций использует "moduleResolution: Node10" — и в результате предоставляет такую декларацию типов:
// dist/index.d.ts
export declare function getMyValue(): import("pkg-esm-pure/dist/value").Value<string>
В этом случае, если конечный потребитель использует "moduleResolution: Node10", то у него всё работает. Однако если он воспользуется "moduleResolution: Bundler", то тип переменной "myVar" станет "any". При отключённой опции "skipLibCheck" также появится ошибка при проверке типов:
node_modules/some-consumer-lib/dist/index.d.ts
error TS2307: Cannot find module 'pkg-esm-pure/dist/lib/value' or its corresponding type declarations.
Корень проблемы в том, что, хоть файл и существует в папке "dist", TypeScript не разрешает его использовать. Дело в ещё одной особенности новых значений "moduleResolution". Вместе с поддержкой поля "exports" TypeScript запрещает использование файлов, не указанных в "exports" (так же, как это работает в Node).
Описание ошибки выглядит так, что разработчики "some-consumer-lib" неправильно подготовили декларации, и проблема на их стороне. И в этом есть доля правды. Разработчики "some-consumer-lib" при формировании деклараций могли воспользоваться "moduleResolution: Node16". В этом случае они бы сразу увидели ошибку и не опубликовали пакет с проблемной декларацией:
The inferred type of 'getMyValue' cannot be named without a reference to '../node_modules/pkg-esm-pure/dist/value.js'. This is likely not portable. A type annotation is necessary.
Тем не менее полностью решить проблему могут только разработчики "some-lib" (если они желают поддерживать значение "Node10").
Одно из решений — сделать поведение пакета в этом аспекте идентичным для всех значений опции "moduleResolution". Для этого нужно или не использовать поле "exports", или в поле "exports" разрешить использование всех деклараций:
// package.json
{
"name": "some-lib",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./dist/*": {
"types": "./dist/*.d.ts"
}
}
}
Обратите внимание, что выставлять так декларации в поле "exports" следует тогда, когда вы или хотите полностью поддерживать совместимость пакета с "moduleResolution: Node10" или не хотите заморачиваться с контролем публичного API для типов.
Однако контроль над публичным API для типов очень даже полезен. Он подразумевает, что у потребителей вашей библиотеки есть возможность использовать публичные типы и нет возможности завязаться на приватные типы, которые вы можете изменить при любом обновлении библиотеки. При этом важно не забыть сделать тип публичным. К слову, чтобы отслеживать изменения в публичном API библиотеки, есть инструмент API Extractor.
Рекомендуемые сетапы
ESM является частью стандарта ECMAScript, и в идеале любая среда его исполнения должна поддерживать работу с ESM. Да, на текущий момент ESM в Node пока не обладает абсолютно всеми возможностями CJS, но тем не менее переход экосистемы на ESM неизбежен. Крупные библиотеки уже начинают отказываться от поставки CJS-файлов. Так, например, Vite в следующей мажорной версии будет поставлять только ESM-модули, и всем его потребителям необходимо к этому адаптироваться.
Поэтому приоритетный сетап ESM Pure предоставляет только ESM-модули. Так у вас всегда будет возможность синхронно загрузить модуль. Ведь с помощью import
можно загрузить CJS-модуль, а с помощью require
загрузить ESM-модуль – нет. Но есть ограничение, что в свою очередь CJS-потребители вашей библиотеки смогут использовать только асинхронный import()
вашей библиотеки, а с require
будет ошибка.
Тем не менее CJS всё ещё пользуется популярностью. И если у вашей библиотеки есть CJS-потребители, которым необходим именно синхронная загрузка, то вам придётся выпускать CJS-файлы. Для этого предлагаем использовать сетап CJS Compat, который предоставляет возможность синхронно использовать пакет в контексте обоих систем модулей. Обратите внимание, что сетап актуален, только если код будет выполняться в Node. Если ваша библиотека нацелена только на браузер, то просто используйте сетап ESM Pure.
Базовые примеры библиотек можно посмотреть здесь.
Ниже представлена резюмирующая таблица, помогающая выбрать сетап.
Таргет библиотеки |
Сетап |
Браузер Код библиотеки попадает в рантайм браузера после обработки сборщиком конечного проекта. |
ESM Pure |
Node современный Код библиотеки попадает в рантайм Node. Потребители библиотеки могут использовать только статический и динамический |
ESM Pure |
Node совместимый Код библиотеки попадает в рантайм Node. Потребители библиотеки могут использовать и |
CJS Compat |
Изоморфная среда современная Библиотека должна поддерживать описанные выше таргеты «Браузер» и «Node современный». |
ESM Pure |
Изоморфная среда совместимая Библиотека должна поддерживать описанные выше таргеты «Браузер» и «Node совместимый». |
CJS Compat (c дополнительной опцией ESM) |
ESM Pure
Важно! Используется "type: module" в "package.json".
Плюсы
Внутри библиотеки можно использовать синхронный
import
и ESM-, и CJS-зависимостей.Для реализации достаточно просто TypeScript.
Минусы
CJS-потребители не могут делать синхронный
require
вашей библиотеки, а только асинхронныйimport()
.
Реализация
Исходный код лучше положить в папку "src", чтобы в "dist" было соответствие по структуре.
Допустим, в папке "src" есть такие исходные файлы:
|-lib
|- math
|- index.ts
|- multiply.ts
|- sum.ts
|- value.ts
|- get-value.ts
|- index.ts
// src/lib/math/sum.ts
export const sum = (a: number, b: number) => a + b
// src/lib/math/multiply.ts
export const multiply = (a: number, b: number) => a * b
// src/lib/math/index.ts
export * from './sum.js'
export * from './multiply.js'
// src/lib/value.ts
export type Value = { arg: T }
// src/get-value.ts
import { Value } from "./lib/value.js"
export function getValue<T>(arg: T): Value<T> {
return { arg }
}
// src/index.ts
export * from "./lib/math/index.js";
export const a = 1;
Для проверки типов настраиваем "tsconfig.json":
// tsconfig.json
{
"compilerOptions": {
// Этот конфиг — только для проверки типов, поэтому выключаем выпуск файлов.
"noEmit": true,
// Значения "Node16"/"NodeNext" обеспечивают современный резолвинг,
// который поддерживает поля "exports" и "imports" из "package.json",
// а также включает необходимые проверки совместимости ESM и CJS.
// Для более обширной поддержки сред исполнения лучше выбрать "Node16".
"moduleResolution": "Node16",
"module": "Node16",
// Значение этого поля выбираем в зависимости от того,
// насколько обширная должна быть поддержка сред исполнения.
"target": "ES2020",
// Можно добавить глобальные типы, например для "node".
// Если никакие глобальные типы не нужны, то лучше выставить [].
"types": ["node"],
// Опции для лучшей обработки некоторых пограничных случаев.
"verbatimModuleSyntax": true,
"isolatedModules": true,
"esModuleInterop": true,
// Остальные опции.
"strict": true,
"skipLibCheck": true,
"...": "..."
},
// По умолчанию в "include" попадают все файлы,
// поэтому добавляем "exclude", чтобы убрать лишнее.
"exclude": [
"**/dist",
"**/coverage",
"**/__fixtures__"
],
}
Для выпуска файлов будем использовать отдельный конфиг "tsconfig.build.json":
// tsconfig.build.json
{
// За основу берём конфиг для проверки типов.
"extends": "./tsconfig.json",
// Ограничиваем его работу только исходным кодом библиотеки.
"include": ["src/**/*"],
// Переопределяем исключения.
"exclude": ["**/__tests__/"],
"compilerOptions": {
// Включаем выпуск файлов.
"noEmit": false,
// Добавляем формирование деклараций типов.
"declaration": true,
// Указываем, куда положить результат.
"outDir": "dist",
// Указываем "src", чтобы в "dist" была структура как в "src".
"rootDir": "src",
// Лучше выключить использование JSON через "import", чтобы избежать багов.
// Подробнее — в разделе "Cоответствие CJS → ESM для Node API".
"resolveJsonModule": false
}
}
Подробнее о выборе опций для библиотеки можно прочитать тут.
Настраиваем "package.json":
// package.json
{
"name": "pkg-esm-pure",
"version": "1.0.0",
// Должно быть значение "module",
// чтобы стандартные ".js"/".ts" файлы определялись как ESM-модули.
"type": "module",
// Основная точка входа (import {} from "pkg-esm-pure").
// Воспринимается старыми средами и сборщиками,
// которые не воспринимают поле "exports",
// а также TypeScript при "moduleResolution": "Node"/"Node10".
// Поле "types" можно не указывать,
// потому что файл деклараций — ./dist/index.d.ts — находится рядом,
// и TypeScript его автоматически обнаружит.
"main": "./dist/index.js",
// Современные среды и сборщики отдают предпочтение полю "exports" при его наличии.
// Также поступает TypeScript при "moduleResolution": "Node16"/"NodeNext"/"Bundler".
"exports": {
// Указываем основную точка входа.
// Не требуется указывать типы явно (".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }),
// так как файл деклараций — ./dist/index.d.ts — находится рядом,
// и TypeScript его автоматически обнаружит.
".": "./dist/index.js",
// В "exports" также можно указать дополнительные точки входа,
// которые будут использованы так: import {} from "pkg-esm-pure/another".
// Аналогично типы можно не указывать.
"./get-value": "./dist/get-values.js",
// Опционально. Если хотим, чтобы при использовании "moduleResolution": "Node16"/"NodeNext"/"Bundler"
// все типы были доступны так же, как при использовании "moduleResolution": "Node10"/"Node10".
"./dist/*": { "types": "./dist/*.d.ts" }
},
// Если есть дополнительные точки входа в "exports", то их также нужно указать в "typesVersions".
// Это необходимо, чтобы TypeScript-потребитель с значением "Node"/"Node10" опции "moduleResolution" смог их обнаружить.
"typesVersions": {
"*": {
"get-value": ["dist/get-value.d.ts"]
}
},
// Тут перечисляем все файлы, которые хотим добавить в пакет.
"files": ["dist"],
"scripts": {
// Команда для сборки пакета в "dist".
// Выполняем транспиляцию через TypeScript со специальным конфигом.
"build": "rm -rf dist && tsc -p tsconfig.build.json",
// Команда, которая будет обновлять "dist" при изменениях в исходных файлах.
// Полезно при разработке.
"watch": "npm run build -- --watch"
},
}
Подробнее с логикой поиска файлов деклараций в TypeScript можно ознакомиться тут.
Попробовать сетап в действии можно здесь.
CJS Compat
Важно! Используется "type: сommonjs" в "package.json".
Плюсы
Потребители пакета могут использовать и
import
, иrequire
вашей библиотеки в Node.
Минусы
Внутри библиотеки может синхронно использовать только CJS-зависимости, а для ESM-модулей придётся применить асинхронный
import()
.Нужно иметь в виду кейсы, когда ESM-потребители вашей библиотеки могут столкнуться с проблемами при импорте CJS-модулей.
Чтобы добавить поддержку tree-shaking для сборщиков, необходимо настроить создание отдельных ESM-модулей с помощью дополнительного инструмента.
Реализация
По большей части сетап идентичен ESM Pure, за исключением нескольких важных моментов.
Необходимо использовать "type: commonjs" в "package.json":
// package.json
{
"name": "pkg-сjs-compat",
"version": "1.0.0",
// Должно быть значение "commonjs", чтобы стандартные ".js"/".ts" файлы определялись как CJS-модули.
// Можно не указывать, но лучше указать явно.
"type": "commonjs",
"...": "..."
}
В "tsconfig.json" лучше отключить "verbatimModuleSyntax", чтобы исходной код можно было писать в ESM формате:
// tsconfig.json
{
"...": "...",
"compilerOptions": {
"...": "...",
// Можно отключить, чтобы TypeScript разрешил использовать "import" в ".ts" файлах,
// которые определяются как CJS-модули, и преобразовывал "import" в "require".
"verbatimModuleSyntax": "false",
"...": "...",
}
}
Обратите внимание, что в этом сетапе крайне не рекомендуется использовать default-экспорты в точках входа. Они приведут к проблеме "double default". А также избегайте использования конструкции export * from
из других библиотек — в них могут использоваться динамические свойства. Такие свойства для потребителей библиотеки по типизации будут выглядеть как обычные "named exports", в то время как в рантайме будет ошибка.
Опционально: добавляем ESM-файлы для работы tree-shaking
Если ваш пакет используется в изоморфной среде, вам может быть важно, чтобы потребитель библиотеки мог воспользоваться tree-shaking при сборке для браузера.
Очень хотелось бы делать это через TypeScript, однако при выборе формата выпуска файлов он смотрит на значение поля "type" в "package.json", и при значении "commonjs" формат может быть только CJS. Поэтому необходимо использовать дополнительный инструмент, например rollup, чтобы настроить добавочный выпуск файлов в ESM-формате.
// rollup.config.mjs
import { defineConfig } from "rollup";
import typescript from "@rollup/plugin-typescript";
export default defineConfig({
// Перечисляем все точки входа.
input: [
"./src/index.ts",
"./src/another.ts"
],
output: {
// Кладём в специальную подпапку.
dir: "dist/browser-esm",
entryFileNames: "[name].mjs",
format: "esm",
},
// Все зависимости помечаем как external, мы не хотим их бандлить.
external: [() => true],
plugins: [
// Переопределяем настройки из "tsconfig.json",
// так как rollup будет собирать "browser-esm" версию.
typescript({
moduleResolution: "Bundler",
// Вместо значения "Node16", установленного в "tsconfig.json",
// модуль будет выбран в зависимости от значения "target".
module: null,
})
]
})
В "package.json" устанавливаем:
// package.json
{
"...": "...",
"exports": {
// Добавляем отдельную версию для сборщика для браузера.
// Так для Node будут использованы только CJS-файлы,
// чтобы избежать dual package hazard.
// Важно: поле "default" должно быть последним.
".": {
"browser": "./dist/browser-esm/index.mjs",
"default": "./dist/index.js"
},
"./get-value": {
"browser": "./dist/browser-esm/get-value.mjs",
"default": "./dist/get-value.js"
},
},
"...": "...",
"scripts": {
// Появился этап rollup-сборки.
"build": "rm -rf dist && tsc -p tsconfig.build.json && rollup -c",
// Так можно запустить два watch-процесса одновременно с общим сигналом для выхода.
"watch": "rm -rf dist && (trap 'kill 0' SIGINT; tsc --project tsconfig.build.json --watch & rollup --config --watch)"
}
}
Обратите внимание, что в некоторых случаях может понадобиться дополнительная настройка в виде поля "sideEffects" или расстановки #PURE
-комментариев. С тем, как это работает, можно ознакомиться тут.
Попробовать сетап в действии можно здесь.
Cоответствие CJS → ESM для Node API
Так как приоритетным сетапом является ESM Pure, расскажу немного об особенностях использования ESM в рантайме Node. Взаимодействие с Node API в форматах CJS и ESM различается, некоторые вещи в ESM нужно делать по-другому. Ниже рассмотрим наиболее важные из этих отличий.
__dirname и __filename
Если ваша библиотека нацелена на Node >= 20.11, вы можете просто использовать конструкции import.meta.dirname
и import.meta.filename
.
Для остальных версий Node:
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(fileURLToPath(import.meta.url))
require и require.resolve
В ESM-файле для загрузки модулей вместо require
следует использовать import
или import()
, но в случае необходимости есть возможность воспользоваться именно require
:
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
При этом вызов require.resolve('pkg-name')
будет использовать "require"-условие в поле "exports" пакета "pkg-name" при формировании пути до файла. Если вам требуется, чтобы учитывалось "import"-условие, можно воспользоваться import.meta.resolve('pkg-name')
.
Импорт JSON-модулей
В ESM-модуле возможен импорт JSON-модуля, но функционал является экспериментальным и требует использования import attributes, иначе получите ошибку в рантаймe.
К сожалению, этот момент TypeScript пока не учитывает, поэтому, если вы не используете сборщик, во избежание проблем рекомендуем отключать опцию "resolveJsonModule" и использовать JSON-файлы, например так:
import fs from 'node:fs'
const packageJson = JSON.parse(
fs.readFileSync(
new URL('../package.json', import.meta.url)
, 'utf-8')
)
Использование CJS-хелперов
Если вы используете ESM, это не означает, что библиотека должна быть только в ESM-формате. Чтобы воспользоваться Node API в CJS-стиле, часть кода можно вынести в отдельный CJS-модуль, а в ESM-модуле просто его загрузить.
Например, проблему с пока ещё плохой поддержкой JSON в ESM можно решить, если создать .cjs
/ .cts
файл, который будет реэкспортировать JSON-файл.
Заключение
Каждый охотник желает знать, где сидит фазан. А каждый JS-разработчик желает знать, как опубликовать библиотеку так, чтобы и в Node всё сразу запускалось, и для браузера собиралось, и TypeScript типы выводил.
Ответ можно найти в представленных нами сетапах. Их можно использовать как в чистом виде, так и в качестве источников вдохновения. В них показано какие подобрать форматы поставляемых файлов (ESM или CJS), какие поля добавить в "package.json", какие настройки внести в "tsconfig.json".
В приоритете советуем применять сетап ESM Pure. Он использует минималистичный набор инструментов и поставляет модули только в ESM-формате, но при этом может без проблем подключать CJS-зависимости. Новые JS-библиотеки в Ozon как правило берут его за основу, потому что в конечном итоге останется только ESM.
Однако если у вашего пакета остаются потребители на CommonJS, то можно воспользоваться сетапом CJS Compat. В этом случае библиотека подойдёт всем потребителям, но внутри библиотеки могут возникнуть трудности с подключением ESM-зависимостей.
Надеюсь, наши рекомендации будут вам полезны. В будущем на основе наших сетапов подготовим инструмент, избавляющий от ручных действий при создании пакета. Обязательно поделимся результатами с вами — следите за новостями.
Полезные материалы по теме:
https://github.com/madeofsun/modern-pkg/tree/master
https://nodejs.org/docs/latest-v20.x/api/packages.html
https://nodejs.org/docs/latest-v20.x/api/esm.html
https://nodejs.org/docs/latest-v20.x/api/modules.html
https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c
https://www.typescriptlang.org/docs/handbook/modules/appendices/esm-cjs-interop.html
https://www.typescriptlang.org/docs/handbook/modules/reference.html
https://www.typescriptlang.org/docs/handbook/modules/theory.html
Комментарии (5)
ko22012
18.11.2024 14:35Имхо, но статья через чур перегружена. Есть шпаргалка, где какой формат лучше использовать?
madeofsun_se Автор
18.11.2024 14:35Согласен, что в статье много все. Если совсем кратко про формат поставляемых файлов, то такая шпаргалка получится:
-
Вопрос: есть потребители библиотеки, которым принципиален CJS формат?
нет: используйте сетап "ESM Pure"
да: используйте сетап "CJS Compat"
-
isumix
18.11.2024 14:35Спасибо, было интересно!
Пилю свою либу, сделал пока методом проб и ошибок и скорей всего не правильно, но вроде работет :) Надо будет пройтись по вашим шаблонам и еще проверить импорт в другой либе.
Мне все это видится так, как наверное и вам. Любая либа всегда должна поставляться как минимум в форме Pure ESM ESNext. Потому что клиенту может понадобиться скомпилировать ее уже под свою конкретную платформу и важно иметь максимально оригинальный и стандартный код. Мы конечно тоже можем компилировать под все возможные варианты платформ и типов модулей, но это уже дополнительный плюшки.
PaulIsh
С ноды 20.не_помню_минор (в 22 тоже) есть экспериментальная опция загрузки esm через require. А так согласен, что гемор полный. Особенно когда тебе на выходе еще и бинарник получить надо. Всякие pkg начинают глючить.
madeofsun_se Автор
Ага, действительно, в 20ке появилось с версии 20.17