Публикация пакета 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_Store
node_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.ts
dist/src/util.js
test/util_test.ts
dist/test/util_test.js
Файл 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
мы получаем следующее: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 |
Компилятор 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.html
robin
) — 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 -rf
shx 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-канале ↩