В прошлой статье я рассказывал про библиотеку компонентов и утилит handy-ones. Я задумал её не только чтобы делиться с сообществом своими наработками на постоянной основе, но главное - чтобы понять, как должен выглядеть, собираться, тестироваться и дистрибутироваться современный JavaScript-проект.

Спойлер

Я уже делился получившимся решением, поэтому здесь сразу опишу, какие инструменты и для чего я выбрал:

  1. npm для установки внешних и перелинковки локальных зависимостей

  2. turborepo для запуска тасок

  3. tsc и postcss-cli для сборки библиотечного кода - /packages

  4. vite для сборки сервисных бандлов - /services

  5. ladle как движок для демо-стенда

Особенность получившийся конфигурации в том, что любой ее компонент можно безболезненно изъять, заменить на другой или поставить рядом что-то еще. Здесь нет огромного фреймворкоподобного инструмента, вроде lerna. Поэтому если завтра появится лучший способ решения какой-то из задач (я очень жду несколько фичей, особенно в vite), я переключусь на них безболезненно. Это одна из главных причин именно такой конфигурации, но далеко не единственная.

Многорепозиторий vs Монорепозиторий

Даже учитывая то, что мой проект очень маленький и над ним вряд ли когда-нибудь будет работать несколько человек, я изначально организовывал его именно как монорепозиторий. Сегодня мне кажется, что это правильный способ думать о своем коде, а зрелость инструментов позволяет избежать большинства технических трудностей.

Монорепозиторий - это, на самом деле, всего два конкретных инструмента:

  1. Возможность работать с локальными зависимостями, как с внешними. В мире Node.js эта технология обычно называется workspaces.

  2. Оркестрация тасок. Важно иметь инструмент, который позволит декларативно описывать зависимости одних скриптов от других. Например, тестирование packageA должно начинаться только после сборки packageB и так далее.

Конечно, хорошо бы также иметь средства версионирования, кеширования сборок, интеграции с CI, плагины для текстовых редакторов, но два этих инструмента основные, без них монорепозиторий не имеет смысла.

Не буду долго рассказывать про workspaces, эта технология существует уже много лет, все к ней более-менее привыкли, в интернете тонны статей о том, как организовать воркспейсы в вашем любимом менеджере пакетов. Я выбрал для этой задачи npm, мне хотелось посмотреть, как обстоят дела в самой нативной из библиотек. Все остальные мои аргументы скорее вопрос вкуса:

  • уarn переживает смену поколений, начинать новый проект на 2.x немного неуютно, а классический yarn может в ближайшее время перестать поддерживаться;

  • pnpm - замечательный, гораздо более революционных, чем yarn, инструмент, он многое делает по-своему и решает врожденные проблемы npm. Но у меня с ним меньше всего опыта, я никогда не использовал его в production и не уверен в стабильности работы. Наверное, переход на pnpm - моя следующая задача. Все материалы по pnpm внушают доверие и оптимизм.

На что обратить внимание при работе с npm в монорепозиториях:

  1. npm все еще не поддерживает no-hoist, если у вас проект на React Native, это может быть критично;

  2. в 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, потому что:

  1. Это более новый инструмент, он активно развивается и за его развитием интересно наблюдать;

  2. У него меньше инструментарий и, соответственно, меньше порог входа;

  3. Turborepo решает одну конкретную задачу, не стремится стать всеобъемлющим фреймворком и навязать свой подход;

  4. Мне очень понравилась презентация ее создателя Джареда Палмера, формулировка проблемы и его подход к ее решению.

Я бы однозначно выбрал 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 не вызвала серьезных трудностей, вот несколько советов, которые позволят сэкономить время:

  1. Сначала настройте запуск и зависимости в тасках, а потом переходите к кешированию.

  2. Позаботьтесь о clean-тасках, вам захочется "начинать сначала". Сделайте таску, которая удаляет все артефакты и чистит кеш.

  3. Используйте визуализацию, чтобы понять, что происходит. В случае таких декларативных конфигов "работает" не значит "работает так, как задумано".

Компиляция пакетов

Удивительно, но именно задача компиляции typescript в ES6 вызвала больше всего проблем. Я знаю про esbuild и про tsup, и я знаю, что у vite есть library-mode, но:

  1. esbuild и tsup это бандлеры, они собирают исходники в один файл, а на выходе хочется иметь нормальную файловую структуру.

  2. esbuild и tsup не дают никакого выигрыша, если вам необходимо генерировать d.ts-файлы. В этом случае оба инструмента предлагают использовать tsc напрямую.

  3. Такая же проблема с генерацией 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" переводится, как "черпак" и это, наверное, моя единственная претензия к библиотеке. В остальном работать с ней было одно удовольствие, из безусловных плюсов:

  1. Быстрый старт с минимумом конфигурации.

  2. Поддержка Component Story Format, то есть уже написанные для Cторибука stories можно сразу использовать в Ladle.

  3. Очень быстрый запуск в дев-режиме, и такая же быстрая сборка production-бандла.

  4. Инструменты для скриншот-тестирования из коробки.

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

Черпак - очень молодой инструмент, появился только в марте 2022 года и его единственный существенный недостаток сегодня - отсутствие поддержки MDX. Писать красивую документацию на нем не получится. Создатель Ladle Vojtech Miksu планировал поддержать MDX до середины октября, но на момент написания статьи эта фича так и не была реализована.

А еще Черпак довольно аскетично выглядит, но это компенсируется его скоростью и удовольствием от разработки. Я горячо рекомендую попробовать Ladle, а если вам не хватает какой-то его фичи - принять участие в разработке, пока он еще маленький.


Получившаяся конфигурация - удобная, современная, а еще она легко масштабируется и не зависит ни от какого фундаментального инструмента. Я без сомнения использовал бы описанный подход в продакшене, начал бы на похожем бойлерплейте новый проект, переводил бы на него текущие.

Я уверен, что поставленную задачу можно было решить и по-другому. Поэтому особенно интересно поделиться решением, которым остался доволен я сам, и услышать обоснованную критику.

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

HandyOnes на Xабре | Исходный код на Гитхабе | Демо-стенд на Ladle

Комментарии (0)