Воссоздание htmx с нуля: как устроена архитектура популярной JS-библиотеки
Популярность библиотеки htmx в современном веб-девелопменте вызвала новый виток дискуссий вокруг архитектуры веб-приложений. На фоне усложнения клиентских SPA-фреймворков (Single Page Applications) с их реактивными сторами, виртуальным DOM, громоздкими сборочными цепочками и передачей данных в формате JSON, htmx возвращает разработчиков к базовым принципам REST и гипермедиа (HATEOAS). Основная идея заключается в том, чтобы расширить стандартный HTML декларативными атрибутами, позволяя любому элементу отправлять AJAX-запросы и подставлять полученные с сервера HTML-фрагменты прямо в страницу.
Понимание того, что под капотом htmx нет «магии», а лишь грамотное применение штатных возможностей современного браузерного JavaScript API (Vanilla JS), помогает инженерам точнее оценивать архитектурные компромиссы. Написание собственного минимального аналога позволяет заглянуть во внутреннее устройство библиотеки и разобрать ключевые механизмы: делегирование событий, сетевое взаимодействие через fetch, работу с FormData, отслеживание изменений DOM с помощью MutationObserver и подстановку узлов.
Четыре столпа гипермедиа-контракта htmx
Чтобы воссоздать базовый функционал библиотеки, необходимо сначала зафиксировать четыре независимых шага, составляющих ее жизненный цикл:
- Декларативное намерение в HTML-разметке: Элемент содержит кастомные атрибуты, задающие HTTP-метод, URL, целевой селектор для замены и стратегию обновления. Например,
hx-get="/api/items",hx-target="#list",hx-swap="innerHTML". - Перехват пользовательских событий: Клиентский скрипт отлавливает клики по кнопкам или отправку форм, предотвращая стандартное поведение браузера по умолчанию (
event.preventDefault()). - Сетевой запрос и получение фрагмента: Браузер выполняет асинхронный запрос к серверу и принимает от него готовую верстку в формате HTML, а не структурированные данные JSON.
- Обновление DOM-дерева: Полученный результат встраивается в указанный элемент-цель согласно выбранному правилу замены.
В такой модели сервер остается единственным источником истины для бизнес-логики и визуального представления, а клиент выполняет роль универсального транспорта и точки локального применения изменений.
Пошаговая сборка минимального ядра на Vanilla JS
Для написания учебного аналога достаточно воспользоваться механизмом делегирования событий (Event Delegation), чтобы не навешивать индивидуальные слушатели на каждый новый элемент страницы.
Шаг 1. Глобальный перехват событий
Создается единый слушатель событий click и submit на уровне всего документа document:
document.addEventListener('click', async (event) => {
// Находим ближайший элемент с атрибутом hx-get или hx-post
const triggerEl = event.target.closest('[hx-get], [hx-post]');
if (!triggerEl) return;
event.preventDefault();
await processHtmxElement(triggerEl);
});
Использование метода Element.closest() критически важно: если пользователь кликает по иконке или внутреннему тегу <span>, находящемуся внутри кнопки, скрипт корректно поднимается вверх по дереву и находит родительский элемент с атрибутом hx-get.
Шаг 2. Формирование запроса и отправка данных
Внутри функции processHtmxElement считываются управляющие атрибуты. Если инициатором выступает HTML-форма или элемент внутри нее, данные собираются через объект FormData:
async function processHtmxElement(element) {
const method = element.hasAttribute('hx-post') ? 'POST' : 'GET';
const url = element.getAttribute('hx-get') || element.getAttribute('hx-post');
const targetSelector = element.getAttribute('hx-target');
const swapStrategy = element.getAttribute('hx-swap') || 'innerHTML';
const targetEl = targetSelector ? document.querySelector(targetSelector) : element;
if (!targetEl) return;
try {
const options = { method, headers: { 'HX-Request': 'true' } };
if (method === 'POST' && element.tagName === 'FORM') {
options.body = new FormData(element);
}
const response = await fetch(url, options);
if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);
const htmlSnippet = await response.text();
applySwap(targetEl, htmlSnippet, swapStrategy);
} catch (error) {
console.error('htmx execution failed:', error);
}
}
Заголовок HX-Request: true позволяет серверному бэкенду определить, что запрос пришел от AJAX-клиента, и вернуть не полную HTML-страницу с тегами <html> и <body>, а только локальный обновляемый фрагмент.
Шаг 3. Реализация стратегий подстановки (Swap Strategies)
В оригинальной библиотеке htmx поддерживается несколько режимов вставки. Функция applySwap демонстрирует работу базовых вариантов:
function applySwap(target, html, strategy) {
switch (strategy) {
case 'outerHTML':
target.outerHTML = html;
break;
case 'beforebegin':
target.insertAdjacentHTML('beforebegin', html);
break;
case 'afterend':
target.insertAdjacentHTML('afterend', html);
break;
case 'beforeend':
target.insertAdjacentHTML('beforeend', html);
break;
case 'innerHTML':
default:
target.innerHTML = html;
break;
}
}
Выбор правильной стратегии зависит от решаемой задачи:
innerHTMLменяет только содержимое контейнера, сохраняя сам целевой элемент и его идентификаторid.outerHTMLполностью замещает целевой узел новым HTML-кодом, что удобно для удаления или кардинальной смены компонента.beforeendиafterbeginиспользуются для добавления новых элементов в конец или начало списка (например, в ленте сообщений или таблице).
Границы применимости: от учебного скрипта к Production
Простой скрипт из 50 строк наглядно объясняет базовую концепцию, однако для реального использования в коммерческих проектах оригинальная htmx содержит огромный пласт защитных механизмов и дополнительных функций:
1. Безопасность и CSRF-защита
При выполнении мутирующих запросов (POST, PUT, DELETE) обязательна передача CSRF-токенов в заголовках. Кроме того, динамическая вставка ответа через innerHTML требует полной уверенности в надежности сервера: если HTML содержит пользовательский ввод без экранирования, приложение становится уязвимым для XSS-атак.
2. Обработка состязательности запросов (Race Conditions)
Если пользователь быстро кликает по кнопке несколько раз подряд, браузер отправляет несколько параллельных запросов fetch. Без использования AbortController для отмены предыдущих сетевых вызовов более старый ответ может прийти позже и перезаписать свежие данные в DOM.
3. Доступность (Accessibility) и фокус
При удалении узла через outerHTML пользовательский фокус клавиатуры может «потеряться», сломав навигацию по клавише Tab. Продакшен-библиотека отслеживает активные элементы и перемещает фокус на новые узлы, а также обновляет атрибуты ARIA-live для скринридеров.
4. Управление историей браузера
Официальная библиотека htmx умеет взаимодействовать с window.history.pushState, сохраняя снимки DOM-дерева в localStorage и позволяя пользователю корректно нажимать кнопки «Назад» и «Вперед» в браузере.
Сравнительный анализ: htmx против классических SPA
| Критерий сравнения | Классический SPA (React / Vue / Angular) | Гипермедиа-подход (htmx / Alpine.js) |
|---|---|---|
| Формат обмена данными | JSON (требуется парсинг и сборка моделей) | Готовый HTML-фрагмент с сервера |
| Управление состоянием | Сложные клиенты: Redux, Zustand, Pinia | Состояние хранит сервер и разметка DOM |
| Объем JS-бандла | Сотни килобайт или мегабайты кода | Около 14 КБ (gzipped) для htmx |
| Семантика разработки | Высокий порог входа, сборщики, TS-интерфейсы | Декларативные HTML-атрибуты, быстрый старт |
| Лучшие сценарии применения | Офлайн-приложения, редакторы, личные кабинеты | CRUD-системы, админки, корпоративные порталы |
Инженерные выводы
htmx — это не просто библиотека, а демонстрация того, насколько мощными стали веб-стандарты. Использование декларативного HTML в сочетании со штатным fetch и DOM API позволяет закрывать до 80% стандартных задач веб-разработки без раздувания клиентского бандла. Знание базовых механизмов перехвата событий и правил подстановки узлов помогает разработчикам осознанно выбирать инструмент под конкретный проект, не перегружая простые интерфейсы лишними слоями абстракций.

