Публикация пакета npm с ESM и TypeScript
- четверг, 13 марта 2025 г. в 00:00:10

За последние 2 года поддержка ESM в TypeScript, Node.js и браузерах сильно улучшилась. В этой статье я объясню мою современную настройку, которая является относительно простой по сравнению с тем, что нам приходилось делать раньше:
tsc, но упоминаю поддержку других инструментов в разделе "Компиляция TS с помощью других инструментов"Обратная связь приветствуется: что вы делаете по-другому? Что может быть улучшено?
Пример пакета: в @rauschma/helpers используется настройка, описываемая в этой статье.
Наш пакет 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/ — для исходного кода TStest/ — для интеграционных тестов — тестов, охватывающих несколько модулей (о юнит-тестах мы поговорим позже)dist/ — для результата компиляции TSДля управления версиями я использую Git. Вот как выглядит мой .gitignore (находящийся в корневой директории проекта):
node_modules
dist
.DS_Storenode_modules — директория с зависимостями проекта, как правило, не включается в удаленный репозиторийdist — результат компиляции TS не включается в удаленный репозиторий, но загружается в реестр npm.DS_Store — актуально только для пользователей macOS, хотя это можно настроить глобальноОбычно, я располагаю юнит-тесты для определенного модуля рядом с ним:
src/
util.ts
util_test.tsУчитывая, что юнит-тесты помогают понять, как работает модуль, их должно быть легко искать.
Если пакет npm содержит exports, он может ссылаться сам на себя по названию пакета:
// util_test.js
import { helperFunc } from 'my-package/util.js';Документация Node.js содержит больше информации о циклических ссылках и отмечает: "Циклические ссылки доступны, только если package.json содержит exports. Импортировать можно только то, что разрешает exports".
Преимущества циклических ссылок:
В этом разделе мы рассмотрим основные моменты tsconfig.json. Дополнительные материалы:
@rauschma/helpers{
"include": ["src/**/*", "test/**/*"],
"compilerOptions": {
// Определяем явно (не полагаемся на пути исходных файлов):
"rootDir": ".",
"outDir": "dist",
// ···
}
}src/util.tsdist/src/util.jstest/util_test.tsdist/test/util_test.jsФайл src/util.ts компилируется tsc в следующие файлы:
dist/src/
util.js
util.js.map
util.d.ts
util.d.ts.maputil.js — код JS, содержащийся в util.tsutil.js.map — карта исходников (source map) для кода JS. Благодаря этому файлу при запуске util.js мы получаем следующее:util.d.ts — типы, определенные в util.tsutil.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 |
Компилятор TS выполняет 3 задачи:
В настоящее время внешние инструменты могут выполнять последние две задачи намного быстрее, чем tsc. Следующие настройки помогают таким инструментам:
"compilerOptions": {
//----- Помогает с генерацией .js -----
// Заставляет использовать `type` для импортов типов и др.
"verbatimModuleSyntax": true, // применяет "isolatedModules"
// Запрещает неспецифичные для JS конструкции, такие как
// JSX, перечисления (enums), свойства параметров конструктора и пространства имен.
// Имеет важное значение для удаления типов
"erasableSyntaxOnly": true, // TS 5.8+
//----- Помогает с генерацией .d.ts -----
// - Запрещает выводить тип значения, возвращаемого экспортируемой функцией и др.
// - Может использоваться только совместно с `declaration` или `composite`
"isolatedDeclarations": true,
//----- tsc не генерирует файлы, только проверяет типы -----
"noEmit": true,
}Некоторые настройки в package.json также влияют на TS. Мы рассмотрим их далее. Дополнительные материалы:
@rauschma/helpersПо умолчанию файлы .js интерпретируются как модули CommonJS. Следующая настройка включает режим ESM:
"type": "module",Необходимо определить, какие файлы должны загружаться в 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/Если мы хотим, чтобы пакет поддерживал старый код, существует несколько настроек package.json, которые следует принять во внимание:
В современном коде нам требуется только одно свойство:
"exports": {
// Экспорты пакета
},Перед тем, как двигаться дальше, ответим на 2 вопроса:
import { someFunc } from 'my-package'; // голый импорт
import { someFunc } from 'my-package/sub/path'; // импорт субпутейДля ответа на эти вопросы следует учитывать следующее:
Поразмыслив, я пришел к следующему:
// Голый экспорт
".": "./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Импорты пакетов 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. В этом случае мы не можем использовать относительные пути для доступа к файлам на верхнем уровне.
Скрипты позволяют определять синонимы для таких команд оболочки, как 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, что добавляет аккуратные номера строк в трассировку стека--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",Для конвертации комментариев 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.htmlrobin) — https://robin.github.io/my-package/api/index.htmlМожете взглянуть на документацию API @rauschma/helpers.
Несмотря на то, что у моего пакета нет обычных зависимостей, ему требуются следующие зависимости для разработки:
"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.jsshx — предоставляет кроссплатформенную реализацию команд оболочки Unix. Я часто использую:shx rm -rfshx chmod u+xЯ также устанавливаю еще 2 инструмента командной строки локально внутри моих проектов, чтобы гарантировать их наличие. Прикольной фичей npm run является то, что она добавляет локально установленные команды в path. Это означает, что они могут использоваться в скриптах пакета так, будто установлены глобально.
mocha и @types/mocha — я по-прежнему использую Mocha для тестирования, но встроенный тест-раннер Node.js стал интересной альтернативойtypedoc — для генерации документации API я использую TypeDocОбщий линтинг:
package.json"engines), определенным в package.json"Линтинг модулей:
Линтинг типов TS:
Эти инструменты становятся все менее актуальными, поскольку все больше пакетов используют ESM, и запрос ESM из CommonJS (require(esm)) сейчас работает в Node.js достаточно хорошо:
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩