htmx обещает динамические веб-приложения без написания JavaScript. Этот материал шаг за шагом собирает минимальный клон htmx, чтобы показать, как устроена магия: scan, send, swap.
htmx — это фронтенд-библиотека для бэкенд-разработчиков, которые предпочитают не
писать JavaScript. Вы расставляете несколько атрибутов hx-* в своём HTML,
и серверные страницы внезапно получают AJAX-запросы, частичные обновления и плавные
переходы — без этапа сборки, виртуального DOM и библиотек управления состоянием.
В июле 2026 года Сергей Зайцев опубликовал отличную статью "Let's make the worst htmx ever!" — очередную в своей серии крошечных клонов популярных фреймворков. JavaScript Weekly № 797 (4 августа 2026) упомянул её в подборке, и это идеальная оптика, чтобы понять, чем на самом деле занимается htmx: вся библиотека в своей сути — это три функции: scan, send и swap.
В этом материале мы вместе пройдём тот же путь, раздел за разделом: как атрибутные
триггеры запускают AJAX-запросы, как ответ разбирается и подставляется в DOM, как
работает выбор цели и способа замены, как заголовок ответа HX-Trigger
превращает серверный ответ в события на клиенте и как такая архитектура позволяет
писать плагины, не трогая ядро. Если вы когда-нибудь спрашивали себя «как работает
htmx?» — вот ответ, примерно в ста строках понятного JavaScript.
Если вы всё ещё решаете, нужен ли вашему сайту JavaScript-фреймворк вообще, начните с моего руководства «React или обычный HTML» — htmx это ровно тот промежуточный путь, о котором там идёт речь.
Посмотрите на типичный пример использования htmx. Он декларативно описывает: при
клике по кнопке отправить POST-запрос на /clicked и заменить элемент с id
#parent-div на HTML, который вернёт сервер.
<button hx-post="/clicked"
hx-trigger="click"
hx-target="#parent-div"
hx-swap="outerHTML">
Click Me!
</button>
Под капотом htmx связывает три вещи: события (триггер),
AJAX-запросы (fetch) и обновление DOM (замену).
Это и есть вся модель. Код htmx построен вокруг цикла: найти элементы с атрибутами
hx-* (scan), слушать их триггеры и отправлять запросы (send), заменять
части страницы (swap).
Минимальная версия этой идеи удивительно короткая. Вот кнопка, которая по клику загружает URL и заменяет себя ответом:
<button x-get="/click">Click me</button>
document.querySelectorAll('[x-get]').forEach(el => {
el.addEventListener('click', async (e) => {
e.preventDefault();
const url = el.getAttribute('x-get');
const response = await fetch(url);
const data = await response.text();
el.outerHTML = data;
});
});
Если сервер ответит «Button clicked», кнопка исчезнет, а на её месте появится текст. Это htmx в десяти строках. Вся остальная статья — о том, как сделать эти десять строк универсальными, надёжными и расширяемыми.
Версия из десяти строк жёстко задаёт триггер (click), цель (сам элемент)
и способ замены (outerHTML). Настоящая библиотека должна позволять
разметке задавать все четыре параметра: метод, триггер,
цель и способ замены.
Ответ приходит текстовой строкой. Первый трюк — разобрать его в настоящие DOM-узлы
через элемент template: это дешёвый HTML-парсер без побочных эффектов.
Дальше каждый способ замены — просто операция над DOM:
const attr = (el, name) => el.closest(`[${name}]`)?.getAttribute(name);
const SWAP = {
outerHTML: (t, f) => t.replaceWith(f),
beforebegin: (t, f) => t.before(f),
afterbegin: (t, f) => t.prepend(f),
beforeend: (t, f) => t.append(f),
afterend: (t, f) => t.after(f),
delete: t => t.remove(),
none: () => {},
};
const swap = (mode, target, html) => {
const tpl = document.createElement('template');
tpl.innerHTML = html;
(SWAP[mode] || ((t, f) => t.replaceChildren(f)))(target, tpl.content);
};
| Способ замены | Что делает |
|---|---|
innerHTML | Заменяет содержимое цели (по умолчанию) |
outerHTML | Заменяет сам целевой элемент |
beforebegin / afterend | Вставляет до / после цели |
afterbegin / beforeend | Вставляет первым / последним ребёнком |
delete | Удаляет целевой элемент |
none | Ничего не делает с телом (используются только заголовки) |
С готовой заменой универсальная функция send() читает цель и способ из
атрибутов, следует соглашению htmx и отправляет кастомный заголовок запроса
(HX-Request: true), чтобы сервер понимал, что это AJAX, и сериализует
форму, если элемент — это form:
const send = async (el, method, url) => {
const sel = attr(el, 'x-target');
const target = sel ? document.querySelector(sel) : el;
const mode = attr(el, 'x-swap') || 'innerHTML';
const opts = { method: method.toUpperCase(), headers: { 'X-Request': 'true' } };
if (el.matches('form')) opts.body = new URLSearchParams(new FormData(el));
const res = await fetch(url, opts);
swap(mode, target, await res.text());
};
Теперь нужно найти все элементы с объявленным поведением и привязать их триггеры.
Триггер по умолчанию следует HTML-соглашениям: формы срабатывают на
submit, поля ввода — на change, всё остальное — на
click. Флаг $hx защищает от двойной привязки при повторном
сканировании одного элемента:
const METHODS = ['get', 'post', 'put', 'patch', 'delete'];
const defaultTrigger = el =>
el.matches('form') ? 'submit' : el.matches('input,select,textarea') ? 'change' : 'click';
const scan = (root = document.body) =>
METHODS.forEach(m =>
root.querySelectorAll(`[x-${m}]`).forEach(el => {
if (el.$hx) return;
el.$hx = true;
const evt = attr(el, 'x-trigger') || defaultTrigger(el);
el.addEventListener(evt, e => {
e.preventDefault();
send(el, m, el.getAttribute(`x-${m}`));
});
})
);
scan();
Так как каждая замена может принести в DOM новые интерактивные элементы,
MutationObserver пересканирует только добавленные узлы — это сильно
дешевле, чем повторно обходить весь документ после каждого запроса:
new MutationObserver(ms => {
for (const m of ms) m.addedNodes.forEach(n => { if (n.nodeType === 1) scan(n); });
}).observe(document.body, { childList: true, subtree: true });
Вот и рабочий клон примерно в сорок строк: все HTTP-методы, свои цели и стратегии замены.
Настоящий htmx позволяет писать выражения вроде
hx-trigger="load, click one, change changed delay:500". Клон делает то же
самое с помощью небольшого парсера: разбиваем по запятым, затем каждый триггер по
пробелам на имя события и опции ключ:значение:
const parseTriggers = (s) => {
const triggers = s.split(',').map(s => s.trim());
return triggers.map(trigger => {
const [event, ...rest] = trigger.split(' ');
const options = {};
rest.forEach(opt => {
const [key, value = true] = opt.split(':');
options[key] = value;
});
return { event, options };
});
}
load — запустить запрос при появлении элемента (это setTimeout с нулём).changed — запоминать предыдущее значение и пропускать отправку, если оно не изменилось.once — снять обработчик после первого срабатывания.delay:500 — отложить срабатывание на указанные миллисекунды.
Настоящий htmx идёт гораздо дальше: триггеры по видимости в окне (revealed,
intersect), троттлинг, очереди, опрос через
hx-trigger="every 5s". Но паттерн тот же: декларативный мини-язык,
который превращается в обработчики событий.
Обычный document.querySelector умеет искать только по CSS-селектору.
htmx расширяет синтаксис цели относительными ключевыми словами, и клон повторяет это
функцией resolve():
const resolve = (el, sel) => {
if (!sel) return el;
if (sel === 'this') return el;
if (sel === 'next') return el.nextElementSibling;
if (sel === 'previous') return el.previousElementSibling;
if (sel === 'document') return document;
if (sel === 'body') return document.body;
if (sel === 'window') return window;
if (sel.startsWith('closest ')) return el.closest(sel.slice(8));
if (sel.startsWith('find ')) return el.querySelector(sel.slice(5));
return document.querySelector(sel);
};
Теперь можно писать x-target="closest .container" или
x-target="next" — и исходники htmx делают примерно то же самое для
hx-target. Относительные цели делают компоненты переносимыми: виджет
всегда может обновить свой собственный контейнер, где бы его ни разместили на странице.
Самая интересная часть htmx — не замена HTML, а протокол событий, который позволяет
серверу управлять клиентом. Клон генерирует четыре пользовательских события на
элементе-инициаторе: x:beforeSend, x:afterSend,
x:beforeSwap и x:afterSwap. Каждое несёт полный контекст
запроса, а beforeSend и beforeSwap можно отменить вызовом
event.preventDefault().
Затем голос получает сервер — через заголовки ответа HX-*:
HX-Trigger — инициировать пользовательское событие на странице: простое имя или JSON-объект с именами событий и данными.HX-Redirect — перевести браузер на указанный URL.HX-Refresh — перезагрузить страницу.HX-Retarget / HX-Reswap — переопределить цель и способ замены, выбранные в запросе.const send = async (el, method, url) => {
let target = resolve(el, attr(el, "x-target"));
let mode = attr(el, "x-swap") || "innerHTML";
const opts = {
method: method.toUpperCase(),
headers: { "HX-Request": "true" },
};
if (el.matches("form")) opts.body = new URLSearchParams(new FormData(el));
if (!fire(el, "x:beforeSend", { el, url, target, mode, opts }, true))
return;
const response = await fetch(url, opts);
const hdrTrigger = response.headers.get("HX-Trigger");
if (hdrTrigger) {
try {
const data = JSON.parse(hdrTrigger);
Object.entries(data).forEach(([ev, d]) => fire(document.body, ev, d));
} catch {
fire(document.body, hdrTrigger);
}
}
if (response.headers.get("HX-Redirect")) {
window.location.href = response.headers.get("HX-Redirect");
return;
}
if (response.headers.get("HX-Refresh") === "true") {
window.location.reload();
return;
}
if (response.headers.get("HX-Retarget"))
target = resolve(el, response.headers.get("HX-Retarget"));
if (response.headers.get("HX-Reswap"))
mode = response.headers.get("HX-Reswap");
const html = await response.text();
fire(el, "x:afterSend", { el, url, opts, target, mode, response, html });
const detail = { el, url, target, mode, html, response };
if (!fire(el, "x:beforeSwap", detail, true)) return;
swap(detail.mode, detail.target, detail.html);
fire(el, "x:afterSwap", detail);
scan(detail.target); // привязать всё, что принесла замена
};
Это сердце гипермедиа-модели: сервер не отдаёт JSON и не ждёт, что клиент сам решит, что делать, — он отдаёт HTML и инструкции, а клиент их выполняет. Страница может обновить дашборд, показать уведомление и поменять счётчик одним ответом — только через заголовки.
Раз каждый шаг жизненного цикла запроса — это перехватываемое событие, суть клона остаётся маленькой — scan + send + swap, — а всё остальное становится плагином. Зайцев перечисляет дюжину таких плагинов, не требующих изменений в ядре:
click).x-confirm — показать нативный confirm() перед отправкой, отмена через preventDefault() на x:beforeSend.x-indicator — показывать спиннер, пока идёт запрос (переключение класса в x:beforeSend / x:afterSwap).x-disable — отключать элемент на время запроса, чтобы не было двойной отправки.x-headers / x-vals — добавлять заголовки или значения в запрос.x-select — заменять только фрагмент ответа (проще шаблоны на бэкенде).x-sync — не больше одного запроса в полёте на цель.x-push-url — обновлять историю браузера через pushState после замены.x-sse / x-ws — потоковые ответы через Server-Sent Events или WebSocket.Boosting — отличный пример: ему даже не нужно знать о внутреннем цикле:
document.addEventListener('click', e => {
const boosted = e.target.closest('[x-boost]');
if (!boosted) return;
const link = e.target.closest('a');
if (!link) return;
const href = link.getAttribute('href');
if (!href || href.startsWith('#') || link.getAttribute('target')) return;
e.preventDefault();
window.x.send(boosted, 'get', href);
});
В этом главный архитектурный урок htmx: маленькое, чётко очерченное ядро с
событийной точкой расширения масштабируется лучше, чем монолитный список функций.
В настоящем htmx hx-ext и те же события жизненного цикла питают
официальную экосистему расширений.
Одна фича настоящего htmx, которую минимальный клон сознательно пропускает, — это
внеполосные замены (hx-swap-oob). Она решает частую
задачу: один запрос должен обновить несколько областей страницы — список, счётчик и
сообщение о статусе.
Трюк в том, что ответ трактуется как мини-документ. Любой элемент ответа с
hx-swap-oob="true" извлекается и заменяется в соответствующем элементе
страницы (по id); всё остальное попадает в цель как обычно. Форма комментариев —
классический пример:
<form hx-post="/comments" hx-target="#comments-list" hx-swap="beforeend">
<input name="text" required>
<button type="submit">Add comment</button>
</form>
<div id="comments-list">
<!-- существующие комментарии -->
</div>
Сервер отвечает новым комментарием и элементом-счётчиком, помеченным
hx-swap-oob="true":
<div id="comment-count" hx-swap-oob="true">42 comments</div>
<div class="comment">Great article, thanks!</div>
Счётчик улетает в #comment-count в другой части страницы, а комментарий
добавляется в конец списка. Один запрос, один ответ, два обновления интерфейса — без
клиентского состояния и логики перерисовки.
Клон честно признаёт свои пределы. Настоящий htmx добавляет обработку ошибок для
упавших fetch() и ответов не-2XX, отмену через промисы для асинхронных
диалогов подтверждения, координацию с view transitions, интеграцию с историей через
hx-push-url, хуки валидации форм и полную грамматику триггеров. Именно
здесь живут производственные крайние случаи.
Полный исходный код клона лежит на GitHub: github.com/zserge/x, а сам текст — на zserge.com. Настоящая библиотека — около 14 КБ в минифицированном виде, ноль зависимостей — на htmx.org.
htmx = scan (найти атрибуты hx-*) +
send (выполнить fetch() по триггерам, прочитать
заголовки HX-*) + swap (разобрать HTML-ответ и
обновить DOM). Всё остальное — триггеры, цели, плагины, внеполосные замены — лишь
уточнение этих трёх шагов.
htmx — не конкурент React, Vue или Angular в смысле SPA, это альтернативная архитектура. Для серверного приложения с умеренной интерактивностью (формы, фильтры, пагинация, дашборды) htmx оставляет логику на бэкенде, убирает этап сборки и делает страницы легче. Для приложения с тяжёлым клиентом, офлайн-режимом, оптимистичными обновлениями и сложным локальным состоянием фреймворк по-прежнему правильный выбор. Моё сравнение React, Vue и Angular подробно разбирает сторону фреймворков.
Философия htmx совпадает и с общим трендом индустрии на HTML-first разработку: CSS берёт на себя всё больше презентационной логики, которая раньше была работой JavaScript. О том, как это происходит, — мой разбор того, как CSS заменяет JavaScript.
Понимание того, что htmx делает под капотом, помогает трезво решать, когда его использовать, а когда лучше другая архитектура. Как full-stack разработчик я строю и серверные приложения, и SPA — выбирая подход, который минимизирует сложность под каждый конкретный проект.
Если вы планируете веб-приложение и хотите услышать взгляд со стороны — подойдёт ли вашему продукту HTML-first подход или JavaScript-фреймворк, — напишите мне: первая консультация бесплатна, без навязывания и продаж.
Есть проект? Помогу выбрать правильную архитектуру — гипермедиа или фреймворк — и сделаю её правильно. Первая консультация бесплатно.