htmx изнутри: переписываем библиотеку с нуля
Технический разбор · Август 2026

htmx с нуля:
что происходит под капотом

htmx обещает динамические веб-приложения без написания JavaScript. Этот материал шаг за шагом собирает минимальный клон htmx, чтобы показать, как устроена магия: scan, send, swap.

Олег Максимов 5 августа 2026 14 мин чтения

Введение

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 это ровно тот промежуточный путь, о котором там идёт речь.

Основная идея: scan, send, swap

Посмотрите на типичный пример использования 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());
};

Сканирование DOM

Теперь нужно найти все элементы с объявленным поведением и привязать их триггеры. Триггер по умолчанию следует 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 };
  });
}

Настоящий 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. Относительные цели делают компоненты переносимыми: виджет всегда может обновить свой собственный контейнер, где бы его ни разместили на странице.

События и протокол HX-Trigger

Самая интересная часть htmx — не замена HTML, а протокол событий, который позволяет серверу управлять клиентом. Клон генерирует четыре пользовательских события на элементе-инициаторе: x:beforeSend, x:afterSend, x:beforeSwap и x:afterSwap. Каждое несёт полный контекст запроса, а beforeSend и beforeSwap можно отменить вызовом event.preventDefault().

Затем голос получает сервер — через заголовки ответа HX-*:

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, — а всё остальное становится плагином. Зайцев перечисляет дюжину таких плагинов, не требующих изменений в ядре:

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)

Клон честно признаёт свои пределы. Настоящий 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 против фреймворков: где ему место

htmx — не конкурент React, Vue или Angular в смысле SPA, это альтернативная архитектура. Для серверного приложения с умеренной интерактивностью (формы, фильтры, пагинация, дашборды) htmx оставляет логику на бэкенде, убирает этап сборки и делает страницы легче. Для приложения с тяжёлым клиентом, офлайн-режимом, оптимистичными обновлениями и сложным локальным состоянием фреймворк по-прежнему правильный выбор. Моё сравнение React, Vue и Angular подробно разбирает сторону фреймворков.

Философия htmx совпадает и с общим трендом индустрии на HTML-first разработку: CSS берёт на себя всё больше презентационной логики, которая раньше была работой JavaScript. О том, как это происходит, — мой разбор того, как CSS заменяет JavaScript.

FAQ

Что такое htmx и почему его называют HTML-first?
htmx — это небольшая JavaScript-библиотека, которая добавляет AJAX, переходы и интерактивность прямо в HTML через атрибуты hx-*. Она называется HTML-first, потому что поведение живёт в разметке: hx-get, hx-post, hx-target и hx-swap описывают запрос, цель и способ замены страницы. Внутри это движок scan/send/swap: находит элементы с атрибутами hx-*, привязывает триггеры, отправляет fetch() и заменяет полученный HTML в DOM.
Как htmx заменяет HTML на странице?
htmx разбирает текст ответа в DOM-узлы через элемент template и применяет стратегию замены. По умолчанию это innerHTML (замена содержимого цели), также поддерживаются outerHTML, beforebegin, afterbegin, beforeend, afterend, delete и none. С помощью hx-swap-oob элемент ответа с атрибутом hx-swap-oob="true" извлекается и заменяется в соответствующем элементе страницы — так один ответ может обновить несколько областей интерфейса.
Что делают заголовки HX-Trigger, HX-Redirect и HX-Reswap?
Это заголовки ответа, которые сервер отправляет, чтобы управлять клиентом после AJAX-запроса. HX-Trigger сообщает htmx, что нужно инициировать пользовательское событие на странице (простое имя или JSON-объект с именами событий и данными). HX-Redirect выполняет перенаправление, HX-Refresh перезагружает страницу, а HX-Retarget и HX-Reswap переопределяют цель и способ замены. Вместе они превращают обычный HTML-ответ в полноценный протокол взаимодействия.
Нужен ли JavaScript-фреймворк вместе с htmx?
Нет. htmx построен на обратном компромиссе: сервер рендерит HTML-шаблоны, а htmx занимается AJAX и обновлением DOM, оставляя логику на бэкенде и убирая этап сборки, клиентскую маршрутизацию и управление состоянием. Для серверного приложения с умеренной интерактивностью htmx может полностью заменить React или Vue. Если вы решаете этот вопрос для бизнес-сайта, прочитайте моё руководство «React или обычный HTML».
Стоит ли учить htmx в 2026 году?
Да — для бэкенд-разработчиков и команд, создающих серверные приложения, htmx одно из самых полезных дополнений к стеку. Библиотека занимает около 14 КБ в минифицированном виде, не имеет зависимостей, работает с Django, Rails, Laravel, ASP.NET, Go и Node.js и убирает целый класс клиентской сложности. Идеи, стоящие за ней — декларативные атрибуты, гипермедиа, прогрессивное улучшение — объясняют и общий сдвиг индустрии в сторону HTML-first разработки.
Где взять код из этой статьи?
Реализация, описанная в статье, — это шуточный проект Сергея Зайцева «Let's make the worst htmx ever» (июль 2026, упомянут в JavaScript Weekly № 797). Полный исходный код на GitHub: github.com/zserge/x, текст — на zserge.com/posts/worst-htmx-ever. Исходники настоящего htmx лежат на github.com/bigskysoftware/htmx.

Хотите построить HTML-first приложение?

Понимание того, что htmx делает под капотом, помогает трезво решать, когда его использовать, а когда лучше другая архитектура. Как full-stack разработчик я строю и серверные приложения, и SPA — выбирая подход, который минимизирует сложность под каждый конкретный проект.

Если вы планируете веб-приложение и хотите услышать взгляд со стороны — подойдёт ли вашему продукту HTML-first подход или JavaScript-фреймворк, — напишите мне: первая консультация бесплатна, без навязывания и продаж.

Контакты

Давайте построим что-то быстрое

Есть проект? Помогу выбрать правильную архитектуру — гипермедиа или фреймворк — и сделаю её правильно. Первая консультация бесплатно.