Публикация пакета npm с ESM и TypeScript

от автора

За последние 2 года поддержка ESM в TypeScript, Node.js и браузерах сильно улучшилась. В этой статье я объясню мою современную настройку, которая является относительно простой по сравнению с тем, что нам приходилось делать раньше:

  • она предназначена для пакетов, которые могут пренебречь обратной совместимостью. Настройка хорошо работает для меня некоторое время — начиная с TS 4.7 (24.05.2022)
    • помогает то, что Node.js теперь поддерживает require(esm) — запрос библиотек ESM из модулей CommonJS
  • я использую только tsc, но упоминаю поддержку других инструментов в разделе «Компиляция TS с помощью других инструментов»

Обратная связь приветствуется: что вы делаете по-другому? Что может быть улучшено?

Пример пакета: в @rauschma/helpers используется настройка, описываемая в этой статье.

1. Структура проекта

Наш пакет npm будет иметь следующую структуру:

my-package/   README.md   LICENSE   package.json   tsconfig.json   docs/     api/   src/   test/   dist/     src/     test/

Комментарии:

  • добавление файлов README.md и LICENSE, обычно, является хорошей идеей
  • package.json описывает пакет (мы поговорим о нем позже)
  • tsconfig.json настраивает TS (мы поговорим о нем позже)
  • docs/api/ предназначен для документации API, генерируемой с помощью TypeDoc (мы поговорим об этом позже)
  • src/ — для исходного кода TS
  • test/ — для интеграционных тестов — тестов, охватывающих несколько модулей (о юнит-тестах мы поговорим позже)
  • dist/ — для результата компиляции TS

1.1. .gitignore

Для управления версиями я использую Git. Вот как выглядит мой .gitignore (находящийся в корневой директории проекта):

node_modules dist .DS_Store

  • node_modules — директория с зависимостями проекта, как правило, не включается в удаленный репозиторий
  • dist — результат компиляции TS не включается в удаленный репозиторий, но загружается в реестр npm
  • .DS_Store — актуально только для пользователей macOS, хотя это можно настроить глобально

1.2. Юнит-тесты

Обычно, я располагаю юнит-тесты для определенного модуля рядом с ним:

src/   util.ts   util_test.ts

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

1.2.1. Трюк дял тестов — циклическая ссылка

Если пакет npm содержит exports, он может ссылаться сам на себя по названию пакета:

// util_test.js import { helperFunc } from 'my-package/util.js';

Документация Node.js содержит больше информации о циклических ссылках и отмечает: «Циклические ссылки доступны, только если package.json содержит exports. Импортировать можно только то, что разрешает exports«.

Преимущества циклических ссылок:

  • полезны для тестов (которые показывают, как работает код)
  • проверяют, что экспорты пакета настроены правильно

2. tsconfig.json

В этом разделе мы рассмотрим основные моменты tsconfig.json. Дополнительные материалы:

2.1. Директория для результата

{   "include": ["src/**/*", "test/**/*"],   "compilerOptions": {     // Определяем явно (не полагаемся на пути исходных файлов):     "rootDir": ".",     "outDir": "dist",     // ···   } }

  • исходник — src/util.ts
    • результат — dist/src/util.js
  • исходник — test/util_test.ts
    • результат — dist/test/util_test.js

2.2. Результат

Файл src/util.ts компилируется tsc в следующие файлы:

dist/src/   util.js   util.js.map   util.d.ts   util.d.ts.map

  • util.js — код JS, содержащийся в util.ts
  • util.js.map — карта исходников (source map) для кода JS. Благодаря этому файлу при запуске util.js мы получаем следующее:
    • в отладчике видим код TS
    • трассировка стека содержит локации исходного кода TS
  • util.d.ts — типы, определенные в util.ts
  • util.d.ts.map — карта исходников (определений, declaration map) для util.d.ts. Этот файл позволяет поддерживающим его редакторам TS переходить к исходному коду TS определения типа. Я нахожу это полезным для библиотек

Файл tsconfig.json
*.js.map "sourceMap": true
*.d.ts "declaration": true
*.d.ts.map "declarationMap": true

2.3. Компиляция TS с помощью других инструментов

Компилятор TS выполняет 3 задачи:

  1. Проверка типов.
  2. Генерация файлов JS.
  3. Генерация файлов определений (declaration files).

В настоящее время внешние инструменты могут выполнять последние две задачи намного быстрее, чем tsc. Следующие настройки помогают таким инструментам:

"compilerOptions": {   //----- Помогает с генерацией .js -----   // Заставляет использовать `type` для импортов типов и др.   "verbatimModuleSyntax": true, // применяет "isolatedModules"   // Запрещает неспецифичные для JS конструкции, такие как   // JSX, перечисления (enums), свойства параметров конструктора и пространства имен.   // Имеет важное значение для удаления типов   "erasableSyntaxOnly": true, // TS 5.8+    //----- Помогает с генерацией .d.ts -----   // - Запрещает выводить тип значения, возвращаемого экспортируемой функцией и др.   // - Может использоваться только совместно с `declaration` или `composite`   "isolatedDeclarations": true,    //----- tsc не генерирует файлы, только проверяет типы -----   "noEmit": true, }

3. package.json

Некоторые настройки в package.json также влияют на TS. Мы рассмотрим их далее. Дополнительные материалы:

3.1. Использование .js для ESM

По умолчанию файлы .js интерпретируются как модули CommonJS. Следующая настройка включает режим ESM:

"type": "module",

3.2. Файлы, загружаемые в реестр npm

Необходимо определить, какие файлы должны загружаться в npm. Хотя существует .npmignore, явное перечисление включаемых файлов является более безопасным. Это делается с помощью свойства files файла package.json:

"files": [   "package.json",   "README.md",   "LICENSE",    "src/**/*.ts",   "dist/**/*.js",   "dist/**/*.js.map",   "dist/**/*.d.ts",   "dist/**/*.d.ts.map",    "!src/**/*_test.ts",   "!dist/**/*_test.js",   "!dist/**/*_test.js.map",   "!dist/**/*_test.d.ts",   "!dist/**/*_test.d.ts.map" ],

В .gitignore мы игнорируем директорию dist, поскольку она содержит файлы, генерируемые автоматически. Однако, здесь мы ее явно добавляем, поскольку большая часть содержащихся в ней файлов должна быть включена в пакет npm.

Шаблоны, начинающиеся с восклицательного знака определяют исключаемые файлы. В нашем случае исключаются тесты:

  • одни тесты находятся рядом с модулями в src/
  • другие — в test/

3.3. Экспорты

Если мы хотим, чтобы пакет поддерживал старый код, существует несколько настроек package.json, которые следует принять во внимание:

  • «main» — раньше использовалось Node.js
  • «module» — раньше использовалось сборщиками
  • «types» — раньше использовалось TS
  • «typesVersions» — раньше использовалось TS

В современном коде нам требуется только одно свойство:

"exports": {   // Экспорты пакета },

Перед тем, как двигаться дальше, ответим на 2 вопроса:

  • Наш пакет будет импортироваться только с помощью голого импорта (bare import) или он также будет поддерживать импорт субпутей (subpath)?

import { someFunc } from 'my-package'; // голый импорт import { someFunc } from 'my-package/sub/path'; // импорт субпутей

  • Если пакет поддерживает импорт субпутей, должны ли учитываться расширения файлов?

Для ответа на эти вопросы следует учитывать следующее:

  • стиль импорта без учета расширений файлов имеет давнюю традицию. Ситуация не сильно изменилась с появлением ESM, хотя для локальных импортов требуется указание расширений
  • недостаток стиля без расширений (согласно документации Node.js): «Карты импортов (import maps) сейчас являются стандартом для разрешения пакетов в браузерах и других средах выполнения JS. Использование стиля без расширений может привести к раздутой карте импортов. Явные расширения файлов позволяют карте импорта использовать сопоставление директорий пакетов для сопоставления нескольких подпутей, где это возможно, вместо отдельной записи карты для каждого экспорта подпути пакета. Это также удовлетворяет требованию использовать полный путь спецификатора в относительных и абсолютных спецификаторах импорта».

Поразмыслив, я пришел к следующему:

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

3.3.1. Определение экспортов пакета

// Голый экспорт ".": "./dist/src/main.js",  // Субпути с расширениями "./util/errors.js": "./dist/src/util/errors.js", // один файл "./util/*": "./dist/src/util/*", // поддерево (subtree)  // Субпути без расширений "./util/errors": "./dist/src/util/errors.js", // один файл "./util/*": "./dist/src/util/*.js", // поддерево

Заметки:

  • если модулей немного, то несколько файлов являются более описательными, чем одно поддерево
  • по умолчанию файлы .d.ts должны находиться рядом с файлами .js. Это можно изменить с помощью условия импорта types

3.4. Импорты

Импорты пакетов Node.js также поддерживаются TS. Они позволяют нам определять синонимы путей (path aliases). Преимущество синонимом в том, что они начинаются с верхнего уровня пакета. Пример:

"imports": {   "#root/*": "./*" },

Этот импорт можно использовать следующим образом:

import pkg from '#root/package.json' with { type: 'json' }; console.log(pkg.version);

Для того, чтобы это работало, нужно разрешить импорт модулей JSON:

"compilerOptions": {   "resolveJsonModule": true, }

Импорты пакетов особенно полезны, когда итоговые файлы JS находятся гораздо глубже, чем исходные файлы TS. В этом случае мы не можем использовать относительные пути для доступа к файлам на верхнем уровне.

3.5. Скрипты

Скрипты позволяют определять синонимы для таких команд оболочки, как build и запускать их с помощью npm run build. Получить список этих синонимов можно с помощью npm run (без названия скрипта).

В своих проектах я использую следующие скрипты:

"scripts": {   "\n========== Сборка ==========": "",   "build": "npm run clean && tsc",   "watch": "tsc --watch",   "clean": "shx rm -rf ./dist/*",   "\n========== Тестирование ==========": "",   "test": "mocha --enable-source-maps --ui qunit",   "testall": "mocha --enable-source-maps --ui qunit \"./dist/**/*_test.js\"",   "\n========== Публикация ==========": "",   "publishd": "npm publish --dry-run",   "prepublishOnly": "npm run build" },

Комментарии:

  • build — директория dist очищается перед каждой сборкой. Зачем? При переименовании файлов TS старые файлы не удаляются. Это особенно проблематично для файлов с тестами
  • test, testall:
    • --enable-source-maps включает поддержку карт исходников в Node.js, что добавляет аккуратные номера строк в трассировку стека
    • Mocha поддерживает несколько стилей тестирования. Мне нравится --ui qunit (пример)
    • publishd — мы публикуем пакет с помощью npm publish. npm run publishd вызывает --dry-run — версию команды, которая не вносит изменений, но предоставляет полезную обратную связь, например, показывает, какие файлы будут частью пакета
    • prepublishOnly — этот скрипт вызывается перед загрузкой файлов в реестр npm. Выполняя сборку перед публикацией, мы убеждаемся, что не будут загружены старые файлы

Для чего нужны именованные разделители? Они облегчают чтение вывода npm run.

Если пакет содержит скрипты bin, тогда может быть полезен следующий скрипт (вызываемый из build после tsc):

"chmod": "shx chmod u+x ./dist/src/markcheck.js",

3.5.1. Генерация документации

Для конвертации комментариев JSDoc в документацию API я использую TypeDoc:

"scripts": {   "\n========== TypeDoc ==========": "",   "api": "shx rm -rf docs/api/ && typedoc --out docs/api/ --readme none --entryPoints src --entryPointStrategy expand --exclude '**/*_test.ts'", },

В числе прочего, я разворачиваю GitHub Pages из docs/:

  • файл в репозитории — my-package/docs/api/index.html
  • файл онлайн (пользователь robin) — https://robin.github.io/my-package/api/index.html

Можете взглянуть на документацию API @rauschma/helpers.

3.6. Зависимости для разработки

Несмотря на то, что у моего пакета нет обычных зависимостей, ему требуются следующие зависимости для разработки:

"devDependencies": {   "@types/mocha": "^10.0.6",   "@types/node": "^20.12.12",   "mocha": "^10.4.0",   "shx": "^0.3.4",   "typedoc": "^0.27.6" },

Комментарии:

  • @types/node — в юнит-тестах я использую node:assert для таких утверждений, как assert.deepEqual(). Эта зависимость предоставляет типы для этого и других модулей Node.js
  • shx — предоставляет кроссплатформенную реализацию команд оболочки Unix. Я часто использую:
    • shx rm -rf
    • shx chmod u+x

Я также устанавливаю еще 2 инструмента командной строки локально внутри моих проектов, чтобы гарантировать их наличие. Прикольной фичей npm run является то, что она добавляет локально установленные команды в path. Это означает, что они могут использоваться в скриптах пакета так, будто установлены глобально.

  • mocha и @types/mocha — я по-прежнему использую Mocha для тестирования, но встроенный тест-раннер Node.js стал интересной альтернативой
  • typedoc — для генерации документации API я использую TypeDoc

4. Инструменты

4.1. Линтинг

Общий линтинг:

  • publint — «проверяет пакеты npm на совместимость с разными средами выполнения, такими как Vite, Webpack, Rollup, Node.js и др.»
  • npm-package-json-lint — «настраиваемый линтер для файлов package.json«
  • installed-check — «проверяет, что установленные модули удовлетворяют требованиям (например, диапазону версий Node.js в engines), определенным в package.json«
  • Knip — «находит и исправляет неиспользуемые файлы, зависимости и экспорты»

Линтинг модулей:

  • Madge — создает визуальный граф зависимостей модуля, обнаруживает циклические зависимости и др.

Линтинг типов TS:

  • arethetypeswrong — «этот проект анализирует содержимое пакета npm на предмет проблем с типами TS, особенно проблем разрешения модулей ESM»

4.2. Инструменты для CommonJS

Эти инструменты становятся все менее актуальными, поскольку все больше пакетов используют ESM, и запрос ESM из CommonJS (require(esm)) сейчас работает в Node.js достаточно хорошо:

  • tshy — TypeScript HYbridizer — компилирует TS в гибридные пакеты ESM/CommonJS
  • ESM-CJS Interop Test — немного устаревший, но полезный список вещей, которые важно учитывать при импорте модулей CommonJS из ESM


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩


ссылка на оригинал статьи https://habr.com/ru/articles/884516/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *