В прошлой статье я рассказывал про библиотеку компонентов и утилит handy-ones. Я задумал её не только чтобы делиться с сообществом своими наработками на постоянной основе, но главное - чтобы понять, как должен выглядеть, собираться, тестироваться и дистрибутироваться современный JavaScript-проект.
Спойлер
Я уже делился получившимся решением, поэтому здесь сразу опишу, какие инструменты и для чего я выбрал:
npm для установки внешних и перелинковки локальных зависимостей
turborepo для запуска тасок
tsc и postcss-cli для сборки библиотечного кода -
/packages
vite для сборки сервисных бандлов -
/services
ladle как движок для демо-стенда
Особенность получившийся конфигурации в том, что любой ее компонент можно безболезненно изъять, заменить на другой или поставить рядом что-то еще. Здесь нет огромного фреймворкоподобного инструмента, вроде lerna. Поэтому если завтра появится лучший способ решения какой-то из задач (я очень жду несколько фичей, особенно в vite), я переключусь на них безболезненно. Это одна из главных причин именно такой конфигурации, но далеко не единственная.
Многорепозиторий vs Монорепозиторий
Даже учитывая то, что мой проект очень маленький и над ним вряд ли когда-нибудь будет работать несколько человек, я изначально организовывал его именно как монорепозиторий. Сегодня мне кажется, что это правильный способ думать о своем коде, а зрелость инструментов позволяет избежать большинства технических трудностей.
Монорепозиторий - это, на самом деле, всего два конкретных инструмента:
Возможность работать с локальными зависимостями, как с внешними. В мире Node.js эта технология обычно называется workspaces.
Оркестрация тасок. Важно иметь инструмент, который позволит декларативно описывать зависимости одних скриптов от других. Например, тестирование packageA должно начинаться только после сборки packageB и так далее.
Конечно, хорошо бы также иметь средства версионирования, кеширования сборок, интеграции с CI, плагины для текстовых редакторов, но два этих инструмента основные, без них монорепозиторий не имеет смысла.
Не буду долго рассказывать про workspaces, эта технология существует уже много лет, все к ней более-менее привыкли, в интернете тонны статей о том, как организовать воркспейсы в вашем любимом менеджере пакетов. Я выбрал для этой задачи npm, мне хотелось посмотреть, как обстоят дела в самой нативной из библиотек. Все остальные мои аргументы скорее вопрос вкуса:
уarn переживает смену поколений, начинать новый проект на 2.x немного неуютно, а классический yarn может в ближайшее время перестать поддерживаться;
pnpm - замечательный, гораздо более революционных, чем yarn, инструмент, он многое делает по-своему и решает врожденные проблемы npm. Но у меня с ним меньше всего опыта, я никогда не использовал его в production и не уверен в стабильности работы. Наверное, переход на pnpm - моя следующая задача. Все материалы по pnpm внушают доверие и оптимизм.
На что обратить внимание при работе с npm в монорепозиториях:
npm все еще не поддерживает no-hoist, если у вас проект на React Native, это может быть критично;
в npm есть проблема с задваиванием пакетов, обычно ее называют "проблемой двойников" или doppelgangers, лучше всего ее описывают в документации к rush.js.
В остальном настройка воркспейсов в npm не вызывает трудностей, достаточно добавить пару строк в корневой package.json
:
"workspaces": [
"packages/*",
"services/*"
]
Дальше вы запускаете npm i
и все подпапки внутри указанных воркспейсов будут связаны через симлинки. Только проверьте, что внутри этих папок не создаются отдельные lock-файлы, это явный признак того, что что-то настроено неверно и это все ломает. Подробнее про воркспейсы неплохо написано в документации npm.
Я делю код очень просто, по принципу дистрибуции. В packages
попадают утилиты и отдельные компоненты, о которых можно думать как об изолированных npm-пакетах, а в папке services
лежит всё, что можно задеплоить: сервисы, документация, статические страницы, неважно. Этот подход можно считать стандартным, иногда папку services
назвают apps
, но сути это не меняет.
Красивые импорты
Когда я настраивал работу монорепозитория, мне было крайне важно, чтобы при подключении локальных пакетов я использовал тот же самый код и те же самые механизмы, которые будут использовать внешние потребители при установке через npm.
В настоящее время надежно поддерживается только два способа обозначить структуру проекта при публикации в npm, директивы main
и files
:
"main": "./dist/index.js",
"files": [
"dist/**",
"src/**"
]
Поле main
описывает точку входа, а files
- какие файлы, собственно, будут опубликованы. Основная проблема здесь - невозможность описать дополнительные модули или baseDir
для импорта.
Например, если ваш пакет состоит из нескольких компонентов, все они должны быть импортированы в корневой index.js
или доступ к ним будет возможен только через подпапку dist
, потребителю придется делать что-то вроде:
import {foo} from 'your-package';
import {bar} from 'your-package/dist/bar';
Есть разные способы обойти это ограничение. Один из самых популярных - копировать файл package.json
в папку dist
перед публикацией и делать публикацию изнутри папки dist
. Но при работе с монорепозиторием этот способ не подойдет, нарушится принцип зеркальности локального и внешнего потребления кода. К счастью, уже совсем скоро будет доступно цивилизованное решение проблемы.
В Node.js еще с 12 версии поддерживает в package.json
директиву exports
, через нее доступна гибкая настройка файловых путей при экспорте, но, к сожалению, она подойдет не всем typescript-проектам. Во-первых, нужен typescript версии 4.7+, а во-вторых, необходимо указать в поле module
вашего tsconfig.json
значение nodenext
, что подойдет не всем. Подробно эта конфигурация разобрана в блоге typescript.
Если указанные ограничения для вас несущественны - можно написать в package.json
конструкцию вида:
{
"exports": {
".": "./index.js",
"./bar.js": "./bar.js"
}
}
И иметь в проекте красивые импорты:
import {foo} from 'your-package';
import {bar} from 'your-package/bar';
Я пока отказался от такой настройки, ограничившись одной точкой входа для каждого package'a, которое указываю через поле main
. Сегодня conditional exports может потребовать серьезного апдейта кодовой базы потребителем, но через полгода-год я бы однозначно использовал для packages именно его.
Оркестрация тасок
Сегодня сообществу доступно несколько систем сборки для монорепозиториев, но без труда можно выделить двух главных игроков: nx и turborepo. Nx - старый, хорошо знакомый и хорошо зарекомендовавший себя инструмент, turborepo - новый (появился только во второй половине 2021 года), дерзкий, быстро набирающий популярность. Оба инструмента поддерживаются большими компаниями и сообществом, у них отличная документация, множество обучающих видео, они хорошо знают друг о друге, критикуют друг друга и вполне мирно сосуществуют.
Я нашел множество статей (1, 2, 3), сравнивающих nx.js и turborepo. Как это часто бывает, они помогают определиться только отчасти. Я выбрал turborepo, потому что:
Это более новый инструмент, он активно развивается и за его развитием интересно наблюдать;
У него меньше инструментарий и, соответственно, меньше порог входа;
Turborepo решает одну конкретную задачу, не стремится стать всеобъемлющим фреймворком и навязать свой подход;
Мне очень понравилась презентация ее создателя Джареда Палмера, формулировка проблемы и его подход к ее решению.
Я бы однозначно выбрал nx.js для более зрелого и масштабного проекта, но мои текущие требования turborepo вполне удовлетворял, к тому же, переключиться с одного решения на другое - задача нетрудоемкая. Удивительно, но до сих пор на Хабре не было ни одной статьи про turborepo, поэтому вкратце опишу принцип его работы.
FULL TURBO
В основе turborepo два инструмента: мультитаскинг и кеширование. В отличие от rush, lerna и yarn workspaces, turborepo строит граф зависимостей тасок, понимает самую эффективную последовательность их запуска и пытается запараллелить все, что может. Пользователю достаточно положить в корень проекта файл turbo.json
и запомнить несколько синтаксических конструкций:
{
"pipeline": {
// Символ '^' указывает, сначала надо дождаться выполнения скрипта 'build'
// во всех dependencies и devDependencies этого воркспейса
"build": {
"dependsOn": ["^build"]
},
// Написание без символа '^' декларирует, что для запуска таски
// необходимо дождать выполнения скрипта build в этом воркспейсе
"deploy": {
"dependsOn": ["build"]
},
// Написание без 'dependsOn' описывает таску без зависимостей
"clean": {}
}
}
Запуск скриптов в монорепозитории будет происходить через вызов turbo run
, скрипты в корневом package.json
выглядят следующим образом:
"scripts": {
"build": "turbo run build",
"deploy": "turbo run deploy"
}
Конфигурация turborepo крайне аскетична, тем не менее, она довольно гибкая и очень хорошо описана в документации. Чтобы лучше понять что, зачем, за чем и почему запускается, в turborepo предусмотрена возможность сгенерировать визуальный граф зависимостей.
Вот так, например, выглядит граф для таски build, здесь по сути видны все топологические зависимости в монорепозитории: единственный сервис showcase
зависит от всеx packages
, которые, в свою очередь, зависят от общих конфигов eslint и typescript:
Гораздо проще устроены зависимости таски dev, ее задача - запустить dev-режим во всех пакетах монорепозитория. Поэтому каждая такая таска каждого воркспейса не имеет зависимостей и граф получается плоским:
Второй способ оптимизации времени исполнения тасок - кеширование результата их выполнения. Turborepo высчитывает хеш от всех незаигноренных файлов в воркспейсе и складывает в ./node_modules/.cache/turbo/<files_hash>
артефакты, которые таска порождает, в том числе логи. Если таска перезапускается без изменений файлов в воркспейсе, ее выполнение займет миллисекунды, а если все запущенные таски удалось закешировать, вы увидите в консоли заветную цветастую надпись:
Кеширование также настраивается в файле turbo.json
через конфигурацию конкретного пайплайна:
{
"pipeline": {
"build": {
// Артефакты, которые порождает таска
// и которые необходимо восстановить из кеша
// По-умолчанию: ["dist/**", "build/**"]
"outputs": ["dist/**", ".next/**"],
// Файлы, изменения которых инвалидируют кеш.
// По-умолчанию все незаигноренные файлы воркспейса
"inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts"]
},
"pipeline": {
// Таска не порождает артефактов,
// будут закешированы только логи
"lint": {
"outputs": []
}
},
"pipeline": {
// Отключение кеша
"dev": {
"cache": false
}
}
}
}
Настроек кеша на самом деле значительно больше, turborepo поддерживает глобальные зависимости, env-переменные и много чего еще, все детально разобрано в документации. Кеширование очень помогает в разработке локально, но, конечно, наилучшего результата можно добиться при использовании распределенного кеша.
Представьте, что билды, собранные на локальных машинах при разработке, попадают в распределенный кеш и используются при автоматизированных сборка на CI. Таким же образом артефакты будут переиспользоваться между сборками.
Turborepo из коробки поддерживает и всячески продвигает распределенное кеширование, но сервер для такого кеша не выкладывает в опен-сорс. Сейчас можно использовать решение от Vercel или сторонний open-source сервер.
У меня настройка turborepo не вызвала серьезных трудностей, вот несколько советов, которые позволят сэкономить время:
Сначала настройте запуск и зависимости в тасках, а потом переходите к кешированию.
Позаботьтесь о clean-тасках, вам захочется "начинать сначала". Сделайте таску, которая удаляет все артефакты и чистит кеш.
Используйте визуализацию, чтобы понять, что происходит. В случае таких декларативных конфигов "работает" не значит "работает так, как задумано".
Компиляция пакетов
Удивительно, но именно задача компиляции typescript в ES6 вызвала больше всего проблем. Я знаю про esbuild и про tsup, и я знаю, что у vite есть library-mode, но:
esbuild и tsup это бандлеры, они собирают исходники в один файл, а на выходе хочется иметь нормальную файловую структуру.
esbuild и tsup не дают никакого выигрыша, если вам необходимо генерировать
d.ts
-файлы. В этом случае оба инструмента предлагают использоватьtsc
напрямую.Такая же проблема с генерацией
d.ts
-файлов у vite. Для их генерации необходимо использовать отдельный плагин, который на самом деле не генерирует отдельные файлы, а собирает файл, который реэкспортирует ваши исходники.
Поэтому для компиляции пакетов я воспользовался чистым tsc
и не испытал с ним никаких проблем. И таким же образом я поступил со стилями, вызываю postcss-cli прямо из package.json
. Оба инструмента поддерживают dev-режим, поэтому и тут проблем не возникло. Скрипты компиляции выглядят следующим образом:
"build:ts": "tsc --outDir ./dist",
"build:css": "postcss ./src/**/*.css --dir ./dist --base ./src",
"build": "concurrently \"npm:build:*\""
Запустить сборку css
и ts
параллельно можно было бы и через turborepo, но я воспользовался старой доброй утилитой concurrently, такая настройка показалась мне более прозрачной.
Если бы мне потребовалось организовать что-то еще более сложное - перекладывать файлы, готовить изображения, собирать под разные окружения или формировать конфигурацию сборки динамически - я бы достал из 2013 года еще один прекрасный и все еще актуальный инструмент - gulp. Gulp позволяет производить над файлами произвольные манипуляции, хорошо расширяется и имеет императивное апи и пока его сложно чем-то всерьез заменить.
Демо-стенд
Я начал со Cторибука. Это, безусловно, очень мощное, сильно изменившее индустрию решение. Я сам использовал его на нескольких проектах разного масштаба. Я начал со Cторибука, но я не дождался, пока он запустится. Даже с пятью базовыми stories сборка в дев-режиме занимает десятки секунд. Я пошел копаться в конфигурации и утонул в ней. Мне нужно было что-то проще, легче, быстрее, с минимальной конфигурацией.
Существует несколько альтернатив сторибуку: Histoire, Docz, Styleguidist, Ladle, я остановил свой выбор на последнем. Слово "ladle" переводится, как "черпак" и это, наверное, моя единственная претензия к библиотеке. В остальном работать с ней было одно удовольствие, из безусловных плюсов:
Быстрый старт с минимумом конфигурации.
Поддержка Component Story Format, то есть уже написанные для Cторибука stories можно сразу использовать в Ladle.
Очень быстрый запуск в дев-режиме, и такая же быстрая сборка production-бандла.
Инструменты для скриншот-тестирования из коробки.
Базовый, но вполне достаточный функционал: навигация, поиск, просмотр в разных разрешениях, интерактивные stories и еще несколько фичей, которых хватает для комфортной разработки, тестирования и презентации компонентов.
Черпак - очень молодой инструмент, появился только в марте 2022 года и его единственный существенный недостаток сегодня - отсутствие поддержки MDX. Писать красивую документацию на нем не получится. Создатель Ladle Vojtech Miksu планировал поддержать MDX до середины октября, но на момент написания статьи эта фича так и не была реализована.
А еще Черпак довольно аскетично выглядит, но это компенсируется его скоростью и удовольствием от разработки. Я горячо рекомендую попробовать Ladle, а если вам не хватает какой-то его фичи - принять участие в разработке, пока он еще маленький.
Получившаяся конфигурация - удобная, современная, а еще она легко масштабируется и не зависит ни от какого фундаментального инструмента. Я без сомнения использовал бы описанный подход в продакшене, начал бы на похожем бойлерплейте новый проект, переводил бы на него текущие.
Я уверен, что поставленную задачу можно было решить и по-другому. Поэтому особенно интересно поделиться решением, которым остался доволен я сам, и услышать обоснованную критику.
С деталями реализации можно ознакомиться на гитхабе, пул-реквесты, issue, любые вопросы, замечания и пожелания приветствуются.
HandyOnes на Xабре | Исходный код на Гитхабе | Демо-стенд на Ladle