htmx
htmx за 13 минут: AJAX через HTML-атрибуты без JavaScript. hx-get, hx-target, hx-swap, триггеры, индикаторы, hx-boost и живой поиск на примерах.
htmx — это маленькая библиотека, которая возвращает гипермедиа в центр веб-разработки. Вместо того чтобы писать JavaScript и гонять JSON, вы добавляете атрибуты прямо в HTML: ссылка или кнопка сама шлёт AJAX-запрос, а сервер отвечает готовым куском HTML, который htmx вставляет на страницу. Этот тур — вся суть htmx на одной странице, через плотно закомментированный код. Читайте сверху вниз.
Что такое htmx
htmx расширяет обычный HTML несколькими атрибутами. Любой элемент может делать HTTP-запросы, а ответ — это фрагмент разметки, а не данные.
<!-- Обычная ссылка перезагружает страницу целиком -->
<a href="/news">Новости</a>
<!-- С htmx кнопка шлёт GET /news через AJAX -->
<!-- и подставляет ответ внутрь #content без перезагрузки -->
<button hx-get="/news" hx-target="#content">Новости</button>
<div id="content"><!-- сюда придёт HTML с сервера --></div>
<!-- Идея htmx: HTML-first и гипермедиа. -->
<!-- Логика живёт в атрибутах, сервер отдаёт готовую разметку, -->
<!-- а не JSON, который надо превращать в DOM руками. -->
Подключение
Достаточно одного тега <script>. Никакой сборки и npm не требуется.
<!-- Подключаем htmx с CDN в <head> или перед </body> -->
<script src="https://unpkg.com/[email protected]"></script>
<!-- Для продакшена обычно указывают integrity-хеш -->
<!-- или кладут файл локально и раздают со своего сервера. -->
<!-- После подключения все hx-атрибуты на странице -->
<!-- начинают работать автоматически. -->
HTTP-запросы
Четыре атрибута соответствуют HTTP-методам. Значение — это URL, на который уйдёт запрос.
<!-- GET: получить данные (чаще всего для чтения) -->
<button hx-get="/articles">Загрузить статьи</button>
<!-- POST: создать новую сущность -->
<button hx-post="/articles">Создать</button>
<!-- PUT: обновить существующую сущность целиком -->
<button hx-put="/articles/42">Сохранить</button>
<!-- DELETE: удалить сущность -->
<button hx-delete="/articles/42">Удалить</button>
<!-- Без hx-target ответ по умолчанию заменит -->
<!-- внутренность (innerHTML) самого элемента. -->
Цель обновления: hx-target и hx-swap
hx-target указывает, какой элемент обновить, а hx-swap — как именно вставить ответ.
<!-- hx-target принимает CSS-селектор -->
<button hx-get="/profile" hx-target="#box">Профиль</button>
<div id="box"></div>
<!-- hx-swap: способ вставки ответа -->
<!-- innerHTML — заменить содержимое цели (по умолчанию) -->
<!-- outerHTML — заменить сам элемент целиком -->
<!-- beforeend — добавить в конец цели (дописать) -->
<!-- afterbegin — добавить в начало цели -->
<!-- delete — удалить цель после ответа -->
<button hx-get="/more"
hx-target="#list"
hx-swap="beforeend">Ещё</button>
<ul id="list"><!-- новые <li> допишутся в конец --></ul>
<!-- Особые значения hx-target: -->
<!-- this — сам элемент, closest tr — ближайший предок tr -->
<button hx-delete="/row/7" hx-target="closest tr">X</button>
Триггеры: hx-trigger
Атрибут hx-trigger задаёт событие, по которому уходит запрос. У каждого элемента есть разумное событие по умолчанию.
<!-- По умолчанию: click для кнопок, change для select, -->
<!-- submit для форм, change для input. Это можно переопределить. -->
<!-- Запрос при потере фокуса полем -->
<input hx-get="/check" hx-trigger="blur">
<!-- keyup: на каждое нажатие клавиши -->
<input hx-get="/search" hx-trigger="keyup">
<!-- load: запрос сразу после появления элемента в DOM -->
<div hx-get="/widget" hx-trigger="load">Загрузка...</div>
<!-- Модификаторы события: -->
<!-- delay:500ms — подождать паузу (сбрасывается при новом событии) -->
<!-- throttle:1s — не чаще раза в секунду -->
<!-- changed — только если значение реально изменилось -->
<input hx-get="/search"
hx-trigger="keyup changed delay:500ms">
Передача данных
Формы отправляют свои поля сами. Дополнительные значения добавляют через hx-vals, а чужие поля подмешивают через hx-include.
<!-- Форма: все её поля уходят с запросом автоматически -->
<form hx-post="/login" hx-target="#result">
<input name="email">
<input name="password" type="password">
<button>Войти</button>
</form>
<!-- hx-vals: добавить статичные значения (JSON) к запросу -->
<button hx-post="/save"
hx-vals='{"source": "button", "lang": "ru"}'>Сохранить</button>
<!-- hx-include: подмешать значения полей вне элемента -->
<input id="q" name="query">
<button hx-get="/search" hx-include="#q">Искать</button>
Индикаторы загрузки: hx-indicator
Пока запрос в полёте, htmx навешивает класс на индикатор. Через hx-indicator можно показать спиннер.
<!-- На время запроса элемент с классом htmx-indicator -->
<!-- становится видимым; по завершении снова скрывается. -->
<button hx-get="/slow" hx-indicator="#spin">
Загрузить
</button>
<img id="spin" class="htmx-indicator" src="/spinner.gif">
<!-- htmx сам управляет классами: -->
<!-- htmx-request — есть на элементе во время запроса -->
<!-- htmx-indicator — базовая невидимость индикатора -->
<!-- Стили этих классов вы пишете сами в CSS. -->
История и URL: hx-push-url
hx-push-url кладёт новый адрес в историю браузера, чтобы кнопка «Назад» работала, а ссылку можно было сохранить.
<!-- true: занести URL запроса в адресную строку и историю -->
<a hx-get="/page/2"
hx-target="#content"
hx-push-url="true">Страница 2</a>
<!-- Можно явно указать, какой адрес показать пользователю -->
<a hx-get="/api/page/2"
hx-target="#content"
hx-push-url="/page/2">Страница 2</a>
<!-- Кнопка «Назад» вернёт предыдущий фрагмент из истории. -->
Подтверждение: hx-confirm
Для опасных действий hx-confirm показывает нативное окно подтверждения перед запросом.
<!-- Перед DELETE покажется window.confirm с этим текстом. -->
<!-- Если пользователь отменит — запрос не уйдёт. -->
<button hx-delete="/account"
hx-confirm="Точно удалить аккаунт? Это необратимо.">
Удалить аккаунт
</button>
Бустинг ссылок и форм: hx-boost
hx-boost превращает обычные ссылки и формы внутри элемента в AJAX-запросы — прогрессивное улучшение без переписывания разметки.
<!-- Все <a> и <form> внутри будут грузиться через AJAX, -->
<!-- а body страницы-ответа подставится в текущий body. -->
<!-- Без JS (или без htmx) ссылки работают как обычно. -->
<nav hx-boost="true">
<a href="/about">О нас</a>
<a href="/blog">Блог</a>
</nav>
<!-- Отключить буст для отдельной ссылки -->
<a href="/file.pdf" hx-boost="false">Скачать PDF</a>
События и hx-on
htmx во время своего жизненного цикла шлёт DOM-события. На них можно вешать обработчики через hx-on или обычный addEventListener.
<!-- hx-on:: реагирует на htmx-события прямо в разметке -->
<!-- Двоеточия в имени события заменяются дефисами. -->
<button hx-get="/data"
hx-on::before-request="this.disabled = true"
hx-on::after-request="this.disabled = false">
Запросить
</button>
<!-- Ключевые события htmx (всплывают на document): -->
<!-- htmx:beforeRequest — перед отправкой -->
<!-- htmx:afterRequest — после ответа -->
<!-- htmx:afterSwap — после вставки HTML в DOM -->
<!-- htmx:responseError — сервер ответил ошибкой (4xx/5xx) -->
Серверный ответ: фрагменты HTML, а не JSON
Ключевая концепция: сервер отдаёт готовую разметку. Это меняет роль бэкенда — он рендерит куски страницы, а не сериализует данные.
<!-- Клиент шлёт запрос... -->
<button hx-get="/users/7" hx-target="#card">Показать</button>
<div id="card"></div>
<!-- ...а сервер отвечает НЕ так: -->
<!-- { "name": "Аня", "role": "admin" } <- это JSON, не для htmx -->
<!-- ...а ВОТ так — готовым фрагментом HTML: -->
<!-- (этот ответ htmx просто вставит в #card) -->
<article>
<h3>Аня</h3>
<p>Роль: администратор</p>
</article>
<!-- Вёрстку делает сервер любым шаблонизатором: -->
<!-- Jinja, Blade, ERB, Django-templates, Go html/template. -->
Типичный пример: живой поиск
Соберём всё вместе. Поле ищет по мере набора, шлёт запрос с паузой и показывает индикатор — и всё это без единой строчки JavaScript.
<!-- Живой поиск: -->
<!-- 1) keyup changed delay:500ms — ждём паузу в наборе -->
<!-- 2) hx-post шлёт значение поля name="q" на сервер -->
<!-- 3) ответ (список результатов) кладём в #results -->
<!-- 4) на время запроса показываем спиннер -->
<input type="search"
name="q"
placeholder="Поиск..."
hx-post="/search"
hx-trigger="keyup changed delay:500ms"
hx-target="#results"
hx-indicator="#spin">
<span id="spin" class="htmx-indicator">Ищем...</span>
<div id="results"><!-- сюда сервер кладёт HTML с результатами --></div>
<!-- Бесконечная прокрутка строится похоже: последний элемент -->
<!-- списка делает hx-get с hx-trigger="revealed" и -->
<!-- hx-swap="beforeend", дописывая следующую порцию. -->