Вчера релизнул версию 2.7.0 Микры и изменения были связаны с улучшенной работой с htmx. По этому поводу и статья.
htmx и Micra.js хорошо уживаются на одной странице, потому что отвечают за разное. htmx приносит готовый HTML с сервера в ответ на действие — клик, сабмит, таймер. Micra держит на этом HTML состояние, которое серверу знать незачем: открыт ли дропдаун, что набрано в неотправленном поле, какая вкладка активна. Это живёт в браузере, и запрос ради этого не нужен.
Чем хорош htmx
Серверный UI браузер умел всегда, но только через два элемента: ссылка шлёт GET, форма — GET или POST, и ответ в обоих случаях заменяет страницу целиком. htmx распространяет ту же механику на всё остальное — запрос может слать любой элемент по любому событию любым HTTP-методом, а ответ заменяет не страницу целиком, а указанный кусок DOM.
<button hx-post="/cart/add?id=42" hx-target="#cart" hx-swap="outerHTML">
В корзину
</button>
Клик шлёт POST, сервер возвращает HTML корзины, htmx подставляет его вместо #cart. Клиентского кода под это вы не написали.
В обычном SPA корзина живёт в двух местах сразу. Сервер считает и хранит её — и клиент держит свою копию в JavaScript: модель, которую надо наполнить из JSON, обновлять на каждое изменение и держать в согласии с сервером. Заметная часть фронтенд-кода — это и есть та синхронизация. htmx убирает копию: корзина остаётся только на сервере, а страница показывает ровно тот HTML, который он отдал. Держать в согласии нечего — нечему и расходиться.
Поведение элемента написано на самом элементе. Что он делает, видно там же, где видно разметку: не нужно искать обработчик по JS-файлам. У Карсона Гросса, автора htmx, об этом отдельное эссе locality of behaviour.
Модель очень богача на возможности. Например, триггеры с модификаторами hx-trigger="keyup changed delay:300ms" — реагировать на ввод, но не на каждую клавишу и только когда значение поменялось. Out-of-band swaps: один ответ обновляет сразу несколько участков страницы. hx-push-url кладёт настоящий URL в историю, и кнопка «назад» работает. hx-boost превращает обычные ссылки и формы в ajax-обновления, не ломая их без JS. Заголовки ответа (HX-Trigger, HX-Redirect, HX-Retarget) дают серверу управлять клиентом. Плюс индикаторы загрузки, hx-sync для гонок запросов, расширения для SSE и WebSocket.
htmx очень зрелый. Бэкенд-агностичен, большое сообщество, отдельная книга hypermedia.systems про этот подход целиком. Он закрывает большой класс приложений — CRUD, админки, дашборды, контентные сайты, формы, то, чем веб занят в основном.
Пробел htmx заключается в состоянии, которое живёт только в браузере и серверу не нужно. И вот сюда как раз отлично подходит Micra.
Зоны ответственности
Граница проходит по тому, кому состояние принадлежит.
Серверное — у htmx. Список заказов, результаты поиска, содержимое страницы: это данные, которые считает и отдаёт сервер. htmx запрашивает фрагмент и подменяет им часть DOM, без отдельного фронтенда.
Клиентское и эфемерное — у Micra. Открыт ли аккордеон, какие чекбоксы выбраны, черновик в форме до отправки. Эти значения нужны только пользователю и только сейчас.
Когда обе стороны на одной странице, htmx тянет разметку, а Micra добавляет на неё локальный интерактив.
Связка
С версии 2.7.0 мостик между ними составляет три строки кода. htmx подменяет DOM, поэтому нужно два действия: смонтировать Micra на пришедшем HTML и снести её с ушедшего.
Micra.start(); // смонтировать то, что уже на странице
Micra.autoCleanup(); // снести компонент, когда htmx убирает его DOM
// смонтировать [data-component], приехавшие в htmx-ответе
document.body.addEventListener("htmx:afterSettle", (e) =>
Micra.start(e.target),
);
Почему два действия, а не одно. Micra.start() отрабатывает один раз на загрузке и монтирует то, что есть в DOM на тот момент. Пришедший позже htmx-фрагмент тогда в DOM ещё не существовал, поэтому его компоненты монтируются отдельно — Micra.start(e.target) сканирует только подменённое поддерево и поднимает их. Повторные вызовы безопасны: уже смонтированные корни пропускаются.
Когда htmx удаляет старый HTML, слушатели на @event уходят вместе со своими узлами — они были на самих элементах. А то, что компонент добавил в onCreate на document или window — обработчик клика-снаружи у дропдауна, глобальный keydown у модалки — переживает удаление элемента и копится с каждым swap. autoCleanup() следит за DOM и, когда корень компонента покидает страницу, зовёт его destroy(): отрабатывает onDestroy, снимаются подписки шины и ровно эти глобальные слушатели. Прежде этот тиардаун делали вручную — обработчиком на htmx:beforeSwap, который находил живые инстансы в уходящем HTML и звал у них destroy. autoCleanup делает то же сам, без отдельного обработчика.
Пример: список с сервера, интерактив на месте
Страница просит у сервера список карточек:
<div hx-get="/cards" hx-trigger="load" hx-target="#list" hx-swap="innerHTML">
<div id="list"></div>
</div>
Сервер на каждую карточку отдаёт самодостаточный островок Micra — заголовок от сервера, разворачивание от клиента:
<article data-component="card">
<h3>Заказ #1204</h3>
<button @click="toggle" data-text="label()"></button>
<div data-show="open">
<!-- детали -->
</div>
</article>
Micra.define("card", {
state: { open: false },
toggle() {
this.state.open = !this.state.open;
},
label() {
return this.state.open ? "Свернуть" : "Подробнее";
},
});
htmx подменяет #list, срабатывает htmx:afterSettle, и Micra.start(e.target) монтирует все карточки. Дальше «Подробнее» открывает и закрывает деталь без обращения к серверу — это локальное состояние карточки. Когда список обновится снова, autoCleanup() снесёт старые карточки за нас.
Возможная ошибка
Не ставьте hx-swap="innerHTML" на сам [data-component], который подменяет своё содержимое:
<!-- так не надо -->
<div data-component="panel" hx-get="/panel" hx-swap="innerHTML">…</div>
После первой подмены инстанс panel ещё жив, но его закешированный скан указывает на исчезнувший DOM, и новые data-text / @click внутри ему не видны. Решение — обёртка: подменяется внешний <div>, а компонент Micra внутри сносится и монтируется заново мостиком выше.
<div
hx-get="/panel"
hx-trigger="every 30s"
hx-swap="innerHTML"
hx-target="this"
>
<div data-component="panel">…</div>
</div>
Если же нужно, чтобы инстанс пережил swap и сохранил клиентское состояние через серверное обновление, сделайте наоборот: data-component на внешнем элементе, а hx-target подменяет что-то внутри, что само не является компонентом.
Заключение
htmx и без Micra закрывает многое: формы, подгрузка, навигация по фрагментам, индикаторы загрузки. Если на странице нет клиентского состояния — Micra там не нужна. И наоборот, если страница статична и сервер не участвует в обновлениях, хватит одной Micra со скриптом и парой директив.
Есть ещё одно, что облегчает их соседство: обе оставляют поведение на самом элементе. У htmx это его locality of behaviour, о которой выше. У Micra — те же директивы: @click="toggle", data-show="open"; по разметке видно, на что элемент реагирует и что он трогает, без поиска обработчиков по селекторам в отдельном файле. Разница в степени: атрибут htmx самодостаточен, а директива Micra ссылается на метод, тело которого лежит в объекте компонента — но это одно связное место, где состояние и методы рядом. Поэтому смешанная страница остаётся понятной: по элементу видно, что он делает, отдаёт это сервер или держит клиент.
Связка нужна там, где есть обе половины: сервер отдаёт и обновляет разметку, а поверх неё живёт состояние, которое не стоит превращать в запрос. Каждый держит свою половину, и граница между ними проходит по тому, кому это состояние принадлежит.