
За последние 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/
— для исходного кода TStest/
— для интеграционных тестов — тестов, охватывающих несколько модулей (о юнит-тестах мы поговорим позже)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
. Дополнительные материалы:
- моя статья «Чеклист для tsconfig.json»
- tsconfig.json
@rauschma/helpers
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 задачи:
- Проверка типов.
- Генерация файлов JS.
- Генерация файлов определений (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. Мы рассмотрим их далее. Дополнительные материалы:
- раздел «Пакеты: единицы JS для распространения ПО» «Скриптов оболочки Node.js»
- package.json
@rauschma/helpers
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.jsshx
— предоставляет кроссплатформенную реализацию команд оболочки 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/
Добавить комментарий