Как сделать один плагин сразу для всех сборщиков фронтенда?
Здравствуйте, товарищи! Сегодня мы снова поговорим про тулинг для фронтенда. В этот раз обсудим разработку плагинов для сборщиков, таких как: Webpack, Vite, esbuild и подобных. За основу мы возьмем Unplugin.
Короткий ответ на вопрос из заголовка: пока никак. Чуть более длинный — изображен на обложке. А в качестве полноценного ответа, предлагаю вам эту статью. Попробуем хотя бы приблизиться к заявленному результату.
Сразу стоит уточнить: это не туториал и не пересказ документации, а скорее case-study.
С чего все началось
Я делаю open source проект — mlut. Это инструмент для верстки с подходом Atomic CSS. Что-то похожее на Tailwind, но по некоторым параметрам mlut превосходит все существующие аналоги. Если хотите узнать об инструменте подробнее, то рекомендую мою предыдущую статью. Вкратце напомню основную схему работы инструмента:
Пишем в HTML/JSX/etc разметке атомарные CSS-классы
JIT-движок смотрит наш код и генерирует CSS, на основе этих классов
Реализовав JIT-движок, я сразу же сделал CLI — это база, без которой инструмент нельзя было бы просто поставить и запустить. Но кроме CLI, второй частый кейс использования (а возможно и первый по частоте) — подключение в процесс сборки проекта. Это значит, что нужно как-то внедряться в сборку и запускать наш инструмент.
Для подобных целей, в большинстве сборщиках можно создавать плагины. В случае работы со сборщиком, мне нужна была часть, которая заменяла бы то, что делал CLI. А именно:
Принимать опции работы JIT: input/output файлы, минификация и т.д. В mlut почти все эти опции можно передавать прямо в Sass-конфиге. Но считывает их оттуда тоже CLI)
Находить файлы с контентом: в CLI мы их указываем явно. В случае со сборкой задачу чуть проще, поскольку нам сразу будут доступны файлы, из которых собирается бандл
Следить за изменениями: тут тоже просто — сборщики это умеют из коробки
Выполнять основную логику: закидывать файлы в JIT-движок и писать скомпилированный CSS в файл
Процессить итоговый CSS: минифицировать и добавлять вендорные префиксы по необходимости. Это также добавляется отдельно теми, кто настраивает сборку
Поиск решения
Со сборщиками мой опыт был скромный, поскольку с SPA я в принципе работал мало. Тем более, я плохо представлял, как вообще делать для них плагины. Поэтому первым делом я пошел смотреть: как подобное реализовали «конкурент»)
Хорошие художники копируют, великие — воруют © Бэтмен
Tailwind — тут все хитро: он интегрируется через PostCSS, поскольку сам Tailwind v3 — просто большой PostCSS плагин)
UnoCSS — с Vite он работает by design, а для остальных случаев есть отдельные пакеты. Для Webpack, например
Atomizer — здесь я обнаружил кое-что интересное. Часть интеграций были написаны через Unplugin! Погуглив, я вспомнил, как однажды на него натыкался, поскольку был немного знаком с экосистемой unjs
Дополнение
Если ранее не слышали про unjs, то рекомендую ознакомиться. Ребята делают достойные framework-agnostic инструменты. Среди участников Anthony Fu и кое-кто из команды Nuxt
Мне хотелось упростить себе задачу и реиспользовать один и тот же код для нескольких интеграций. Тем более, у меня не было опыта с плагинами и хотелось начать с чего-то попроще. Так Unplugin стал идеальным решением.
Unplugin
По заявлению авторов — это унифицированная система плагинов для разных сборщиков. Пишешь нужную логику и просто экспортируешь плагины для желаемых сборщиков:
import { createUnplugin } from 'unplugin'
export const unplugin = createUnplugin((options) => ({
name: 'unplugin-starter',
transformInclude(id) {
return id.endsWith('main.ts')
},
transform(code) {
return code.replace(//, 'Injected')
},
// ...
}));
export const rollup = unplugin.rollup;
export const vite = unplugin.vite;
export const webpack = unplugin.webpack;
export const esbuild = unplugin.esbuild;
В том или ином виде есть поддержка:
«Звучит мощно» — подумал я. Ребята наверное заморочились и придумали универсальную систему для всех сборщиков! Правда первое, что мне показалось подозрительным: относительное небольшая документация. Но в любой случае, я взялся разбираться с этим делом.
Мое подозрение оправдалось, и первое ожидание быстро разбилось. Оказалось, что авторы не изобретали какую-то новую универсальную систему, а просто взяли систему плагинов Rollup и сделали ее адаптеры для остальных. Ни к коем случае не преуменьшаю заслуг создателей, скорее смеюсь над собой)
Когда ожидал новую систему плагинов, а там api Rollup
Процесс разработки
Логика работы нашего плагина должна быть следующая:
Закидываем в JIT-движок исходники с разметкой
Следим за изменениями нашего input Sass-файла
В конце сборки генерируем CSS-файл с утилитами
Звучит относительно просто, и ничего не предвещало беды…
Как работают плагины (на примере Rollup)
Технически плагин — просто объект со свойствами и методами, который возвращает функция-фабрика. Чтобы взаимодействовать с процессом сборки, объект плагина содержит «хуки». Хуки — это функции, которые вызываются на различных этапах сборки. Хуки могут влиять на запуск сборки, предоставлять информацию в процессе сборки или делать что-то после ее завершения. Хуки бывают различных видов и сейчас мы их разбирать конечно не будет. Изучить вопрос можно тут, а для общего понимания, прикреплю вот такую схему:
Схема применения хуков плагинов
Дальше стоит отметить, что нужная нам логика не совсем типичная для плагинов. Дело в том, что результатом работы должен стать новый CSS-файл, который будет записан в конце сборки отдельно от основного бандла. В процессе сборки есть хуки, на которых можно эмитить новые файлы через api, но это не наш случай. А ведь этот файл мы можем захотеть потом как-то еще процессить, ну и подключить к бандлу… Но обо всем по порядку.
Следующая особенность нашего плагина в том, что он может работать в двух режимах: обычная сборка и watch-mode. Во втором, сборщик следит за исходниками и пересобирает проект при их изменении. Обычно при таком режиме добавляется и dev-сервер с livereload. Стандартная история, когда мы что-то верстаем. Все это надо учитывать, чтобы в watch режиме у нас ничего лишнего не срабатывало повторно.
Теперь обсудим процесс разработки по этапам.
Получаем опции плагина
Поначалу здесь все было просто: функции фабрики для каждого плагина принимают опции в аргументах.
// vite.config.js
import { vite } from '@mlut/plugins';
const mlut = vite({
input: 'src/style.scss',
output: 'dist/assets/style.css',
// few more options...
});
export default defineConfig(() => {
return {
plugins: [mlut],
}
});
// plugin.js
import { createUnplugin } from 'unplugin';
export const unplugin = createUnplugin((options) => {
const inputPath = options.input;
// ...
});
// ...
Но как упоминалось ранее, в mlut большую часть опций можно прописать прямо в input Sass-файле:
@use 'mlut' with (
$jit: (
'output': 'src/assets/css/style.css',
'minify': true
),
/* ... */
);
Правда путь до input файла все равно надо будет где-то указать)
На первый взгляд, все тоже казалось понятным. Есть хук buildStart — пишем там логику инициализации плагина, считываем input файл и вот это все. И пока в начале я тестировал только на Rollup — все было хорошо. Но когда я попробовал запустить плагин в остальных сборщиках — лафа закончилась…
Логично, что инициализацию нужно выполнять единожды. Но в watch режиме, Vite вызывает этот хук на каждой пересборке. Пришлось добавлять специфичный для него хук и выполнять инициализацию там
Оказалось, что в Webpack есть баг (на момент публикации), из-за которого
buildStart
в нем не асинхронный! Благо, заботливые авторы Unplugin дали возможность писать костыли добавлять специфичный код для конкретного сборщика
Это была разминка, двигаемся далее…
Находим файлы с контентом
Казалось бы, тут нам искать особо нечего: все файлы с разметкой сами проходят через сборку — только успевай ловить их в хуке transform и закидывать в JIT-движок) Input файл тоже явно получаем в опциях. Но и тут все оказалось не так гладко…
В Vite есть свой хук transformIndexHtml. Это связанно с тем, что по умолчанию, именно index.html является там точкой входа, а не js. Так что по хорошему, надо обрабатывать и его на наличие атомарных классов
Если вы хотите использовать
html-webpack-plugin
в Webpack (например, для модификации index.html, в котором подключается бандл), то вас ждет сюрприз! Оказывается, плагины на основе Unplugin не могут загрузить html при использовании этого плагина) Здесь мне помог хук transformInclude, чтобы запретить нашему плагину обрабатывать HTML в работе с Webpack
Вроде как-то разобрались, но дальше больше.
Следим за изменениями исходников
По логике, в watch режиме вызываются те же самые хуки, что и при обычной сборке. Так что слежение за файлами с разметкой у нас вроде как есть. Но надо кое-что добавить для наблюдения за input файлом. Ведь там мы можем менять конфиг mlut и просто писать какие-то стили. Логично, что Sass от таких действий должен перекомпилироваться.
У нас есть возможность добавить вручную файл для отслеживания по ходу сборки. Но не в esbuild (на момент публикации), поэтому для него у нас пока не будет плагина)
esbuild оказался лишним на этом празднике жизни
Далее берем хук watchChange и если в нем пришло изменение input файла, то даем команду JIT-движку, чтобы тот обновил Sass-конфиг. Тут же можно добавить и обработку удаления файлов.
Здесь Vite тоже отличился. Оказалось что Vite only хук transformIndexHtml
в watch режиме вызывается несколько раз подряд! Просто потому что) Приходится выносить логику записи итогового CSS в отдельную функцию и делать ее debounce версию специально для Vite. Плюс, добавлять костыльный флаг, что это watch в Vite и чего-то делать надо/не надо.
Vite снова подкачал
Процессим итоговый CSS
Поначалу я думал так: «мне надо как-то закинуть созданный mlut CSS в основной пайплайн сборки, по которому проходят другие стили». Так он будет обработан таким образом, как это настроил автор сборки: с минификацией, автопрефиксером и т.д.
Но и здесь меня ждало препятствие. Как мы выяснили ранее, наш итоговый CSS генерируется в самом конце сборки, когда все файлы уже ее прошли. И нет возможности сделать эксклюзивный прогон сборки стилей именно для нашего файла) (может и есть, но я не нашел).
Я не стал особо грустить по этому поводу. Просто написал фасад, который трансформирует CSS на основе опций для JIT, которые мы передаем в плагин, при его инициализации. Чтобы не тянуть новых зависимостей, в этом модуле я динамическим импортом перебирал все популярные инструменты для процессинга) Наверняка какой-то из них уже будет интегрирован в сборку. А если человек захотел минификацию, но ничего для этого не установил — кидаем ошибку с предложением поставить недостающие пакеты.
// ...
const transformByAvailableTool = await import('csso')
.then(({ minify }) => (
(css, { noMergeMq }) =>
Promise.resolve(minify(css, { forceMediaMerge: !noMergeMq }).css)
))
.catch(async () => {
const { transform, browserslistToTargets } = (await import('lightningcss'));
// ...
})
// ...
.catch(() => {
throw new Error('No CSS minifier was found. You can install one of these: csso, lightningcss, clean-css, cssnano or esbuild');
});
// ...
Шок-контент про минификаторы CSS
Один из наиболее популярных сейчас CSS-минификаторов — cssnano. Когда я писал проход по всем минификаторам, я заметил странность. При тестировании кейса с cssnano, логика до него не дошла и остановилась на csso — другом минификаторе (моем любимом), который шел раньше в моей цепочке.
После некоторых разбирательств выяснилось следующее. cssnano в своем дефолтном сетапе тянет в зависимостях svgo. А тот в свою очередь, зависит от csso!) Классика JS-экосистемы!
Самое забавное, что по старым бенчмаркам, csso разносил дефолтный preset cssnano!) И я сомневаюсь, что сейчас он стал сильно лучше)
Бонус: настраиваем работу с livereload
Основной функционал у нас реализован. Теперь надо убедиться, что все работает так, как задуманно со всеми заявленными сборщиками (их получилось 3) в стандартных юзкейсах. Первый и самый очевидный — просто сборка всего проекта. Тут все хорошо. А второй — разработка в watch режиме с dev-сервером и livereload.
Для каждого сборщика я собрал сетап со вторым случаем. В Webpack и Rollup все было ожидаемо: просто поставил нужные плагины и настроил конфиг. Vite это все умеет из коробки, но не тут-то было)
Выяснилось, что HMR в Vite, при изменении исходников с разметкой, по умолчанию не подхватывал новый CSS, который выдает наш плагин. Помог следующий набор костылей:
в Vite only хуке config, патчим конфиг server.watch, чтобы тот не игнорил наш output CSS-файл
еще в одном Vite хуке configureServer добавляем в список отслеживаемых файлов этот же файл
в уже известном нам
transformIndexHtml
прописываем inject нашего output файла в index.html
И вот только теперь наш плагин заработал корректно! Ура, товарищи!
Я, когда удалось сделать универсальный плагин
Заключение
Несмотря на обилие костылей и специфичного кода, мне удалось сделать один плагин, который работает для трех сборщиков: Rollup, Vite и Webpack! Как по мне, это вполне достойный результат. Даже с учетом всех подводных камней, это получилось быстрее, чем писать для каждого отдельный плагин. Ведь не пришлось разбираться с api каждого сборщика и для каждого оформлять свой пакет. Но еще проще это поддерживать: будь то фикс или фича, не нужно будет бегать по трем пакетам и дублировать изменения.
Да и как было подмечено в начале, логика моего плагина довольно специфичная. Если у вас что-то более стандартное, возможно все полетит как часы)
Хотя вступление вышло не самым позитивным, я отдаю респект авторам Unplugin и могу его рекомендовать. А оценить мое творчество можно тут. Будет особенно круто, если попробуете его в деле и отправите ишьюсы с пожеланиями, при их наличии)
На этом все! Подписывайтесь на мой телеграм-канал, ставьте звезды на гитхабе, ну и буду рад видеть ваши комменты!