Для чего нужен пакет?

У нас в работе постоянно нужно переиспользовать код. Делать копипасту из одного проекта в другой одинаковых утилит, классов. Я считаю дело неблагодарным и стараюсь такие вещи выносить в пакет.

Пакет можно запаблишить как в официальный 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
        }
}
+ "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).

Ок, с этим разобрались. Поехали дальше.

+ "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"

В новой версии скрипта сборки происходит следующее:

  1. Удаляется папка сборки

  2. Компилится проект в CommonJS модуль и складывается в dist/_cjs

  3. Компилится проект в ESNext модуль и складывается в dist/_esm

  4. Создаются 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 пакет, который делает всё вышесказанное автоматически.