Создаём npm пакет, совместимый с CJS, ESM, написанный на TypeScript
Для чего нужен пакет?
У нас в работе постоянно нужно переиспользовать код. Делать копипасту из одного проекта в другой одинаковых утилит, классов. Я считаю дело неблагодарным и стараюсь такие вещи выносить в пакет.
Пакет можно запаблишить как в официальный npm репозиторий, так и в свой репозиторий (например в Verdaccio, на случай если вы хотите иметь приватный пакет, но не хотите платить деньги npm'у и хотите держать все под контролем).
Зачем нужно собирать в разные модули?
Допустим, у вас есть два проекта. Один собирается в CJS, другой в ESM. И если подключить ESM пакет в CJS проект, то не все может работать, как надо. Скорее всего проект даже не сбилдится.
Взять, например, мой любимый rxjs пакет. Обратите внимание, сколько там tsconfig'ов в исходниках. А если разобраться, как они его паблишат, то это вообще целая история. Но благодаря такому подходу мы можем поставить rxjs пакет в любой проект, и он будет работать.
Начинаем погружение... Создадим простейший пакет
Давайте набросаем скромный пакет.
Создаем для него папку и заходим в неё
$ npm init -y && npm i -D typescript @types/node && ./node_modules/.bin/tsc --init
Отредактируем
tsconfig.json
- // "outDir": "./",
+ "outDir": "./dist",
Создадим папку
src
и в ней файлmy-class.ts
с содержимым
export class MyClass {
hello() {
let type: string
try {
// если удалось прочитать __dirname, значит мы в CommonJS. Если нет, то в ESModule
// в ESModule нет привычных nodejs разработчикам констант __dirname, __filename, ...
// https://nodejs.org/api/esm.html - раздел "Differences between ES modules and CommonJS"
const dir = __dirname;
type = 'CommonJS'
} catch (e) {
type = 'ESModule'
}
console.log('Hello!', type)
}
}
Отредактируем
package.json
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
+ "build": "tsc"
},
"keywords": [],
Соберем
$ npm run build
У нас появилась папка dist
, в которой лежит my-class.js
, скомпилированный как CommonJS модуль (как указано в tsconfig.json
)
Скопируем
package.json
вdist/package.json
Перейдем в
dist
и запаблишим пакет$ npm publish
(если вы паблишите в нестандартный npm репозиторий, укажите параметр--registry
со ссылкой на ваш репозиторий или создайте в корне.npmrc
с содержимым:registry=https://my.own-registry.com
)
Все это мы делаем, чтобы наш пакет был чистенький и не содержал лишние файлы, а только нужные (результат билда и необходимый package.json
).
Результатом является создание пакета в репозитории. Содержимое пакета будет следующим:
├── package.json
├── my-class.js
Теперь его можно поставить в свой проект и использовать.
Делаем ESM и CJS сборки пакета
Теперь сбилдим пакет в разные сборки.
Создадим файл
tsconfig.cjs.json
с содержимым
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist/_cjs",
"module": "CommonJS"
}
}
Создадим файл
tsconfig.esm.json
с содержимым
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist/_esm",
"module": "ESNext"
}
}
Создадим файл
tsconfig.types.json
с содержимым
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./dist/_types",
"declaration": true,
"emitDeclarationOnly": true
}
}
Добавим в package.json раздел
exports
, который отвечает за условный экспорт (Conditional exports) .
+ "exports": {
+ "./*": {
+ "types": "./_types/*.d.ts",
+ "require": "./_cjs/*.js",
+ "import": "./_esm/*.js",
+ "default": "./_esm/*.js"
+ }
+ }
Ключи объекта exports говорят, какие пути импорта мы хотим обработать. Например, когда делаем import "test-package/my-class"
, срабатывает условие "./*" (любой модуль в корневой директории).
Ключ | Правило | import |
. (точка) | Корень пакета | import "test-package" |
./* | Любой модуль в корневой директории | import "test-package/aaa" |
import "test-package/bbb" | ||
./my-module | Конкретный модуль в корневой директории | import "test-package/my-module" |
./my-module/* | Любой модуль в директории /my-module | import "test-package/my-module/aaa" |
import "test-package/my-module/bbb" |
Ключи значения: types (файлы d.ts), require (CommonJS файлы), import (ESM файл), default (какой файл должен подключиться, если ни одно условие не удовлетворило). Это не все, все перечислены тут. Их значения указывают на то, какой конкретно файл должен подключиться. Путь к файлу относительно package.json.
Например: CommonJS проект импортирует import "test-package/my-class"
. В exports
ищется правило ./*
или ./my-class
. В этом правиле ищется require (потому что проект собран в CommonJS, и он также требует подключения CommonJS файла) и подключается файл, который указан в ключе require. Если require нет, подключается файл, указанный в default.
Другой пример: ESM проект импортирует import "test-package/my-module/test"
. В exports
ищется правило ./my-module/*
или ./my-module/test
. В этом правиле ищется import (т.к. проект собран в ESM) и подключается файл. Если import нет, подключается default.
ПОДВОДНЫЙ КАМЕНЬ
Порядок ключей types, require, import, default важен! Честно, я первый раз столкнулся с тем, что порядок ключей в JSON имеет значение. Например, если default будет стоять самый первый, то import, require будут проигнорированы, а сразу возьмется путь из default, потому что он стоит самый первый.
default всегда должен идти самый последний.
types обязательно должен быть самым первым (так написано в оф. доке в разделе Community Conditions Definitions).
Ок, с этим разобрались. Поехали дальше.
Добавим в
package.json
блок typesVersions
+ "typesVersions": {
+ ">=4.2": {
+ "*": [
+ "_types/*"
+ ]
+ }
+ }
Подробное его описание на оф. сайте. Вкратце, это какие d.ts файлы TypeScript брать для работы. У нас указано, что если версия TypeScript >= 4.2, то бери типы из папки _types
Добавим types в
package.json
+ "types": "./index.d.ts",
Тут есть один странный нюанс. Это поле должно быть обязательно, иначе не будут находиться файлы декларации. И он необязательно должен указывать на существующий файл. Это очень странное поведение, и я его никак не могу объяснить. Если указать правильный путь, то ничего работать не будет.
Я посмотрел, как сделано в rxjs пакете, там сделано так же, types указан, но в пакете этого файла нет.
Поменяем скрипт билда в
package.json
- "build": "tsc"
+ "build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && tsc -p tsconfig.types.json && cp package.json dist/package.json"
В новой версии скрипта сборки происходит следующее:
Удаляется папка сборки
Компилится проект в CommonJS модуль и складывается в
dist/_cjs
Компилится проект в ESNext модуль и складывается в
dist/_esm
Создаются d.ts файлы и складываются в
dist/_types
Сбилдим наш проект
$ npm run build
Теперь содержимое папки dist после билда должно выглядеть так:
├── _cjs
│ └── my-class.js
├── _esm
│ └── my-class.js
├── package.json
└── _types
└── my-class.d.ts
В папке
dist/_cjs
создадим файлpackage.json
{"type": "commonjs"}
В папке
dist/_esm
создадим файлpackage.json
{"type": "module"}
Мы создали эти два файла, чтобы показать, что в папке dist/_cjs
находятся CommonJS модули, а в папке dist/_esm
ES модули.
Можно было бы пойти другим путём, переименовав все js файлы в dist/_cjs
в cjs файлы и в dist/_esm
js файлы в mjs. И поменять расширения в exports. Но так, мне кажется, гораздо проще.
Содержимое папки dist должно быть таким:
├── _cjs
│ ├── my-class.js
│ └── package.json
├── _esm
│ ├── my-class.js
│ └── package.json
├── package.json
└── _types
└── my-class.d.ts
Поменять версию в
package.json
на 1.0.1.
Всегда нужно менять версию перед новым паблишингом!
Паблишим пакет.
$ cd dist && npm publish
Теперь в проекте можно поставить наш пакет. Для теста можно создать файл с таким содержимым:
import {MyClass} from "@evg/test-package/my-class"
new MyClass().hello();
Если ваш проект в CommonJS, то в консоле
будет Hello! CommonJS
Если в ESModule, то Hello! ESModule.
Алиасы
Никакой крупный пакет не обходится без путей алиасов. Давайте добавим и к нашему пакету алиасы.
Отредактируем
tsconfig.json
- // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
+ "paths": {
"~/*": ["./src/*"]
},
Добавим файл
src/constants.ts
export const PI = Math.PI;
Отредактируем
src/my-class.ts
+ import {PI} from "~/constants"
export class MyClass {
hello() {
let type: string
try {
// если удалось прочитать __dirname, значит мы в CommonJS. Если нет, то в ESModule
// в ESModule нет привычных nodejs разработчикам констант __dirname, __filename, ...
// https://nodejs.org/api/esm.html - раздел "Differences between ES modules and CommonJS"
const dir = __dirname;
type = 'CommonJS'
} catch (e) {
type = 'ESModule'
}
- console.log('Hello!', type)
+ console.log('Hello!', type, 'PI: ', PI)
}
}
Мы добавили импорт с алиасом ~/constants. Если сейчас запаблишить пакет, то ничего работать не будет, потому что алиасы останутся, а в проекте нет такого алиаса "~", а даже если и есть, то он вряд ли будет указывать на наш пакет.
Поэтому после компиляции нужно преобразовать все алиасы в относительные пути.
Ставим tsc-alias
$ npm i -D tsc-alias
Правим
package.json
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "rm -rf dist && tsc -p tsconfig.cjs.json && tsc -p tsconfig.esm.json && tsc -p tsconfig.types.json && cp package.json dist/package.json"
+ "postbuild": "tsc-alias -v --dir dist/_cjs -p tsconfig.cjs.json && tsc-alias -v --dir dist/_esm -p tsconfig.esm.json && tsc-alias -v --dir dist/_types -p tsconfig.types.json"
},
В скрипте postbuild (который исполняется автоматически после build) мы меняем алиасы через пакет tsc-alias. Выполняем его для каждой сборки, передавая путь к tsconfig файлу сборки и путь к уже собранной сборке.
Паблишим и проверяем.
Вывод будет следующий:
Hello! ESModule PI: 3.141592653589793
В следующей статье, которая выйдет в ближайшее время, мы рассмотрим созданный нами npm пакет, который делает всё вышесказанное автоматически.