Files
dev-conventions/conventions/stack/htmx/web-ui.md
T
av 682fa075bb снятое правило остаётся заглушкой, нумерация сплошная
- META-31 и META-32: номера идут без пропусков, ссылка обязана разрешаться;
  обе проверки стали механическими — данных со стороны языка им хватает
- заведена метка СНЯТО: заголовок и номер снятого правила сохраняются, норму
  с обоснованием заменяет блок с датой и причиной, отдельный реестр снятых
  номеров не нужен
- META-9, META-16 и META-26 переписаны из таблицы «Освободившиеся номера» в
  заглушки; три висячие ссылки, тянувшиеся с утра, закрылись
2026-07-26 15:59:22 +03:00

36 KiB
Raw Blame History

topic, prefix
topic prefix
web-ui HTMX

Веб-UI на htmx

Как пишется код веб-UI: частичный своп фрагментов, поллинг живых обновлений, обработчики действий, деградация без JS, ошибки. Что именно UI показывает и какие действия поддерживает — в спеках, не здесь.

Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки ПОЧЕМУ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке конвенций версии 1 — тогда и только тогда, когда написаны заглавными.

Логирование запросов — конвенция logging (HTTP-поля, рутинно-частое на DEBUG). Трансляция доменных ошибок наружу — конвенция errors (приватный канал = логи, публичный = сообщение плюс корреляционный ключ). Здесь — только специфика htmx-транспорта, без дублирования.

Область действия

Утверждения о поведении htmx относятся к 2.x: дефолты обработки ответов между мажорами менялись. Правила описывают то, как написан код веб-UI, а не то, какие экраны и действия у приложения есть.

Стек и границы

HTMX-1. Стек: роутер, серверные шаблоны, htmx

ДОЛЖЕН. UI собирается из серверных шаблонов и htmx — без шага сборки, без Node и бандлера, без реактивного фреймворка.

ПОЧЕМУ. Шаг сборки — это второй язык, второй менеджер зависимостей и артефакт, который расходится с исходником; приложению, где разметку целиком отдаёт сервер, он не покупает ничего. Реактивный фреймворк добавляет вторую модель состояния рядом с серверной (HTMX-2), и дальше на каждом экране приходится решать, какая из них главная. Сам htmx — вендорный ассет и живёт по правилам вендоринга (HTMX-32, HTMX-33): внешний CDN добавил бы к аптайму приложения аптайм чужого хоста.

HTMX-2. Клиент не пересчитывает доменное состояние

НЕ ДОЛЖЕН. Свой JS делает только то, чего серверу знать не нужно (копирование в буфер обмена и подобное); доменное состояние считает сервер, клиент свопит присланную разметку.

ПОЧЕМУ. Пересчёт на клиенте — вторая реализация той же логики, которую никто не сверяет: расходится она тихо, а проявляется как «на экране одно, в базе другое». Вдобавок клиентский пересчёт по определению не работает в деградированном режиме (HTMX-11, HTMX-12) — значит, серверную версию того же вычисления всё равно придётся держать.

HTMX-3. Реактивный слой вводится отдельным решением

НЕ ДОЛЖЕН. Alpine.js и подобное не появляется попутно с задачей — только когда есть виджет, которому он действительно нужен, и отдельным решением.

ПОЧЕМУ. Реактивный слой, попавший в проект ради одного выпадающего списка, немедленно доступен всему остальному коду — и граница HTMX-1/HTMX-2 перестаёт держаться сама собой. Отдельное решение — единственный момент, когда цену видно целиком: она не в килобайтах, а в том, что дальше на каждом экране есть выбор между двумя моделями состояния.

Единый источник разметки

HTMX-4. Партиал = страница = фрагмент

ДОЛЖЕН. Переиспользуемый кусок разметки — именованный шаблон в partials/, и он же рендерится инлайн на странице и как ответ-фрагмент обработчика; отдельной разметки под фрагмент нет.

ПОЧЕМУ. Две копии одной разметки расходятся молча: правку вносят в ту, что открыта, и страница начинает выглядеть иначе, чем результат свопа того же региона. Заметно это становится только на глаз и только тому, кто открыл оба пути подряд.

HTMX-5. Корень партиала — элемент с целевым id

ДОЛЖЕН. Корневой узел шаблона несёт тот id, по которому адресуют регион, и ответный фрагмент несёт тот же id.

ПОЧЕМУ. hx-swap="outerHTML" заменяет корневой узел целиком, вместе с его атрибутами. Если пришедший фрагмент несёт другой id или не несёт его вовсе, первый своп проходит успешно, а следующее действие и поллер уже не находят таргет: регион застывает без единой ошибки — ни в консоли, ни в логе.

HTMX-6. Сборку view делает общая функция

СЛЕДУЕТ. Один view-builder зовут и обработчик полной страницы, и htmx-ветка.

ПОЧЕМУ. Общий шаблон (HTMX-4) гарантирует одинаковую разметку, но не одинаковые данные: скопированная сборка view расходится по набору полей, и фрагмент начинает показывать не то, что показала бы страница. Это ровно тот класс расхождений, который HTMX-4 закрывает для разметки.

Обработчик действия

HTMX-7. Доменный вызов одинаков для htmx и обычного запроса

ДОЛЖЕН. Обработчик определяет htmx-запрос по заголовку HX-Request: true, зовёт доменную операцию до ветвления и ветвится только на способе ответа:

Запрос Ответ
HTMX-7.1 HX-Request: true фрагмент тем же партиалом (HTMX-4) по перечитанному состоянию
HTMX-7.2 обычный PRG-редирект (303)
actionErr := fn(r.Context(), id) // доменный вызов идентичен для htmx и не-htmx

if !isHTMX(r) {
    redirect(w, r, id, msg) // без htmx — обычный PRG-редирект (303)
    return
}
data, _ := s.deps.Read(r.Context(), id) // перечитать актуальное состояние
view := buildView(id, data, "")         // тем же view-builder'ом
if actionErr != nil {
    view.BlockError = userErr(r, actionErr, id)
}
s.render(w, "source_block", view)       // фрагмент = тот же шаблон

ПОЧЕМУ. Ветвление до вызова даёт две реализации одного действия, и дальше дефект воспроизводится только на одной поверхности — причём деградированный путь (HTMX-11) открывают реже, то есть чинить будут не тот. Перечитанное состояние в htmx-ветке нужно потому, что своп заменяет регион целиком: view, собранный из аргументов запроса, покажет намерение, а не результат.

HTMX-8. Шаблон рендерится в буфер, потом в ответ

ДОЛЖЕН. Именованный шаблон собирается целиком в буфер, и только затем буфер пишется в ответ.

ПОЧЕМУ. Прямая запись в ответ отправляет клиенту статус и часть разметки раньше, чем шаблон дошёл до ошибки: сообщить об отказе уже нечем, а htmx свопит в DOM полученный обрывок. Внешне это «исчезла половина региона», и причина по такому симптому не читается.

Одно действие — два региона

HTMX-9. Второй регион едет тем же ответом через hx-swap-oob

СЛЕДУЕТ. Когда действие меняет не только свой регион, второй фрагмент отдаётся в том же ответе с hx-swap-oob="true" — обычным именованным партиалом с тем же id, что и на странице (HTMX-4, HTMX-5).

ПОЧЕМУ. Второй запрос с клиента вводит гонку: два ответа считают состояние в разные моменты и приезжают в произвольном порядке, поэтому панель действий может отразить состояние до действия. Плюс лишний раунд-трип на каждое действие.

HTMX-10. Отдельный запрос за вторым регионом — когда он обновляется реже действия

ДОПУСКАЕТСЯ. HX-Trigger в ответе плюс отдельный hx-get, если второй регион меняется не на каждое действие.

ПОЧЕМУ. Явное разрешение нужно, чтобы HTMX-9 не читался как запрет любого второго запроса. Когда регион обновляется редко, oob-ветка гоняет одинаковую разметку на каждое действие и связывает два шаблона там, где связи нет; гонка же тем менее наблюдаема, чем реже обновление.

Graceful degradation

HTMX-11. Форма действия работает без JS

ДОЛЖЕН. Действие — обычная <form method="post" action="…">, на которую hx-post/hx-target/hx-swap накладываются сверху; action ведёт на рабочий обработчик.

ПОЧЕМУ. htmx может не загрузиться — ошибка вендоринга, блокировщик, медленная сеть, — и без рабочего action форма в этот момент не отправляет ничего, молча. Тот же action — единственное, что делает действие проверяемым без браузера с JS.

HTMX-12. Фильтр, поиск и пагинация — серверные

ДОЛЖЕН. Отбор списка задаётся GET-параметрами и выполняется на сервере; клиентской фильтрации загруженной разметки нет.

ПОЧЕМУ. Клиент видит только текущую страницу списка, поэтому клиентский фильтр отвечает по неполным данным и делает это молча — результат выглядит валидным. Вдобавок состояние отбора в query переживает своп (HTMX-25) и перезагрузку, его можно послать ссылкой и увидеть в логе.

HTMX-13. Область обязательной деградации

ДОЛЖЕН. Требование работать без JS распространяется не на весь UI:

Поверхность Поведение без JS
HTMX-13.1 действия и навигация работают полностью (HTMX-11, HTMX-12)
HTMX-13.2 интерактивный виджет выбора, у которого нет осмысленного не-JS поведения может не работать; записывается в отступления

ПОЧЕМУ. Без явной границы правило деградации читается как запрет на любой JS-виджет — и тогда его либо тихо нарушают, либо отказываются от виджета, который был нужен. Запись в отступления держит список честным: видно, какие именно места ломаются с выключенным JS, а не «где-то что-то».

Ошибки на htmx-пути

HTMX-14. Ошибка действия на htmx-пути — 200 с фрагментом

ДОЛЖЕН. Провалившееся действие отдаёт статус 200 и фрагмент с сообщением; доменная ошибка на htmx-пути не транслируется в HTTP-статус.

ПОЧЕМУ. В htmx 2.x ответы 4xx/5xx по умолчанию не свопят DOM — то есть пользователь не увидит ничего. Своп ошибочных ответов настраивается (htmx.config.responseHandling, расширение response-targets), но любая такая настройка — свой JS-конфиг на клиенте, и платится она из HTMX-1 и HTMX-2. Сообщить о сбое, для которого фрагмента нет вовсе, — отдельная задача, и её решает глобальный слушатель (HTMX-34). Для REST API и не-JS редиректа с ?err= статус по-прежнему используется: там его кто-то читает.

Цена решения: в логе доступа провалившееся действие выглядит как 200. Искать его надо по доменной записи об исходе операции (конвенция logging), а не по коду ответа.

HTMX-34. Сбой без ответа-фрагмента показывается глобальным слушателем

ДОЛЖЕН. Один глобальный слушатель htmx:responseError и htmx:sendError показывает нейтральное сообщение о неудаче запроса; своп ошибочных ответов в целевые регионы (htmx.config.responseHandling, response-targets) не настраивается.

ПОЧЕМУ. HTMX-14 закрывает доменный отказ, до которого обработчик дошёл. Паника, сбой шаблона и обрыв сети отдают 5xx или ничего, htmx 2.x такое не свопит — регион не меняется, интерфейс замирает без единого признака сбоя, и пользователь повторяет действие, которое могло уже примениться. Слушатель — несколько строк без доменного состояния, то есть внутри границы HTMX-2, и он не спорит с HTMX-14: там настройки отвергнуты как замена фрагменту, который обработчик в состоянии отдать, а здесь фрагмента нет по определению. Своп тела ошибки в целевой регион стоил бы дороже: страница 500 не несёт целевого id, и после первого же такого свопа регион перестаёт находиться (HTMX-5).

HTMX-15. Наружу идёт сообщение публичного канала

ДОЛЖЕН. Во фрагмент попадает нейтральный текст публичного канала; err.Error() в разметку не рендерится.

ПОЧЕМУ. htmx-фрагмент выглядит внутренней деталью приложения, и на нём легче всего забыть, что это тот же публичный канал, что и страница: разметка уезжает в браузер пользователя целиком. Статус 200 (HTMX-14) дополнительно снимает ощущение «это ошибочный ответ, его никто не увидит».

HTMX-16. Сообщение об ошибке — в отдельном поле view

ДОЛЖЕН. У view есть поле под ошибку действия; доменные поля под сообщение не переиспользуются.

ПОЧЕМУ. У доменного поля может быть своё непустое значение, и сообщение его перекроет: пользователь получит текст ошибки вместо данных, а шаблон — необходимость угадывать, что сейчас лежит в поле. Отдельное поле делает оба состояния — данные и ошибку — выразимыми одновременно, а это ровно то, чего требует HTMX-17.

HTMX-17. При ошибке активное состояние не меняется

НЕ ДОЛЖЕН. Фрагмент, отданный после неудачного действия, показывает прежний выбор плюс сообщение.

ПОЧЕМУ. Своп заменяет регион целиком, поэтому фрагмент — единственное, что пользователь узнает о состоянии. Показав намеренное состояние вместо фактического, интерфейс расходится с сервером, и следующее действие человек делает по ложной картине — на сервере оно применится к другому объекту.

Живой поллинг

Живое обновление устроено как фрагмент-эндпоинт под /fragments/… плюс hx-get, hx-trigger="every Ns", hx-swap="outerHTML" в разметке:

{{define "progress"}}<div id="item-live-{{.ID}}"
  {{if .Active}} hx-get="/fragments/items/{{.ID}}/progress"
                 hx-trigger="every 3s" hx-swap="outerHTML"{{end}}>
  ...
</div>{{end}}

HTMX-18. Поллер самозавершается

ДОЛЖЕН. Когда состояние вышло из «живого», фрагмент возвращается без hx-*-атрибутов.

ПОЧЕМУ. Иначе опрос не прекращается никогда: каждая открытая вкладка держит постоянный поток запросов за неизменными данными, и закрывает его только пользователь. Условие остановки живёт в разметке ответа, потому что это единственный канал, которым сервер управляет поллером.

Встроенная альтернатива — ответ со статусом 286 — не используется: она не совместима с HTMX-4, ведь свежезагруженная страница рендерится тем же партиалом и тоже без поллера.

HTMX-19. Условие живости ведёт собственное состояние приложения

ДОЛЖЕН. Признак «живо ли ещё» вычисляется по состоянию, которым владеет приложение, а не по ответу внешнего сервиса.

ПОЧЕМУ. Внешний сервис отвечает не всегда и не одинаково: на его недоступности поллер либо останавливается, пока работа идёт, либо не останавливается никогда. Приложение — единственный участник, который знает про операцию всё и может ответить на каждом тике.

HTMX-20. Поллер свопит фрагмент целиком через outerHTML

ДОЛЖЕН. Тик заменяет весь фрагмент (hx-swap="outerHTML"), а не его содержимое.

ПОЧЕМУ. outerHTML удаляет старый узел вместе с его hx-trigger и инициализирует новый — так поллер живёт ровно в одном экземпляре и так же выключается (HTMX-18). Своп содержимого оставил бы старый узел с его таймером, и через несколько обновлений опрос шёл бы в несколько потоков. Работает это при совпадении корневого id (HTMX-5).

HTMX-21. Поллер не свопит контейнер с активными полями ввода

НЕ ДОЛЖЕН. Живость включается только в тех состояниях фрагмента, где редактировать нечего.

ПОЧЕМУ. Своп поддерева теряет фокус, выделение и незасабмиченный текст внутри него. У поллера это происходит по таймеру, то есть в момент, который пользователь не выбирал: текст исчезает посреди набора и воспроизводится как «приложение стирает мой ввод».

HTMX-22. Браузер не ходит во внешний сервис напрямую

НЕ ДОЛЖЕН. Поллинг и прочие запросы страницы идут на свой сервер.

ПОЧЕМУ. Прямой запрос из браузера выносит наружу адрес и учётные данные внешнего сервиса и делает страницу заложником его CORS-политики. Вдобавок контракт внешнего сервиса протекает в разметку: его смена перестаёт быть серверным изменением.

HTMX-23. Источник данных для тика

ДОЛЖЕН. Тик читает данные там, где они уже есть, не ходя в сеть:

Что показывает тик Откуда берёт
HTMX-23.1 состояние внешнего сервиса in-memory снимок, обновляемый воркером
HTMX-23.2 собственное состояние приложения своё хранилище; снимок не требуется

ПОЧЕМУ. Тик умножается на число открытых вкладок, поэтому сеть на каждом тике превращает интерфейс в генератор нагрузки на внешний сервис — и его недоступность становится недоступностью страницы. Снимок разрывает эту связь: частоту обращений к внешнему сервису задаёт воркер, а не пользователи. Контракт снимка держат узким, чтобы способ доставки (поллинг сегодня, SSE потом) менялся, не задевая остальной код. Для собственного состояния той же цены нет: хранилище и так своё, а лишний слой кеша добавил бы только рассинхрон.

HTMX-24. Поллинг URL страницы вместо отдельного фрагмент-роута

ДОПУСКАЕТСЯ. Когда живой фрагмент — почти вся страница, hx-get идёт на URL самой страницы, а нужный узел вырезается hx-select:

hx-get="/item/{{.ID}}" hx-trigger="every 3s"
hx-select="#item-main" hx-swap="outerHTML"

ПОЧЕМУ. Отдельный /fragments/…-роут в этом случае дублирует обработчик страницы целиком — вместе с перечитыванием состояния и сборкой view, — и дальше два обработчика расходятся по тому же сценарию, что и две копии разметки (HTMX-4).

Инвариант корневого id (HTMX-5) действует и здесь: hx-select выбирает тот же узел, который свопится.

Своп и выход со страницы

HTMX-25. Действие не уводит со страницы, если предмет остаётся на ней

НЕ ДОЛЖЕН. Такое действие свопит свой регион на месте.

ПОЧЕМУ. Своп сохраняет прокрутку и не трогает серверные фильтр, поиск и пагинацию — они в query (HTMX-12). Полная навигация ради изменения одного региона возвращает пользователя в начало списка и стоит перерисовки всей страницы. Не сохраняется при свопе только контекст внутри самого заменяемого поддерева — фокус, выделение, введённый текст (HTMX-21).

HTMX-26. Выход со страницы — форма без hx-*

ДОЛЖЕН. Действие, после которого предмет покидает страницу, остаётся обычной POST-формой без htmx-атрибутов, то есть полной навигацией.

ПОЧЕМУ. Своп для такого действия оставил бы на месте регион, описывающий объект, которого на странице больше нет. Отсутствие htmx-атрибутов при этом само работает маркером «это выход»: намерение видно прямо в разметке, и не нужен HX-Redirect — то есть ещё один способ сменить страницу, существующий только на htmx-пути.

HTMX-27. Асинхронное действие свопит промежуточное состояние

ДОЛЖЕН. Если работу доделывает воркер, ответ на действие показывает промежуточное состояние, а итог догоняет самозавершающийся поллер (HTMX-18).

ПОЧЕМУ. Мнимый результат расходится с сервером до следующего тика, и всё это время пользователь принимает решения по несуществующему исходу — включая повтор действия, которое на самом деле выполняется. Промежуточное состояние вдобавок объясняет, почему регион продолжает обновляться сам.

Различение поверхности одного действия

HTMX-28. Поверхность различается скрытым полем формы

ДОЛЖЕН. Когда один роут зовут с разных страниц и своп-ответ различается фрагментом, поверхность передаётся явным скрытым полем (surface=list|detail), а не выводится из HX-Target или Referer.

ПОЧЕМУ. HX-Target — свойство разметки вызывающей страницы, Referer может не прийти вовсе; и то и другое меняется без участия обработчика, и ответ начинает приходить не тем фрагментом. Поле же видно в форме рядом с действием, поэтому связь «эта страница → этот фрагмент» читается там, где её заводят.

HTMX-35. Запрос без поля поверхности получает 400

ДОЛЖЕН. Обработчик, различающий поверхности (HTMX-28), отвечает статусом 400, когда поля surface в запросе нет; поверхность по умолчанию не выбирается.

ПОЧЕМУ. Поле кладёт в форму наш же шаблон, поэтому его отсутствие — дефект формы, а не вход пользователя. Поверхность по умолчанию маскирует такой дефект молча неверным фрагментом: своп с чужим id проходит, после чего регион перестаёт находиться таргетом (HTMX-5), и ошибка воспроизводится как «интерфейс иногда застывает». 400 не свопится и всплывает сообщением глобального слушателя (HTMX-34) — сразу и на той странице, где форму сломали. Вкладка, открытая до появления поля, получает тот же 400 и чинится перезагрузкой; это дешевле, чем молча неверный фрагмент в актуальной разметке.

Статика, вендоринг, кэш

Раздел не про htmx — это упаковка любого server-rendered приложения; разъедется в языковой слой, когда понадобится там.

HTMX-29. Ассеты встроены в бинарь и отдаются иммутабельным кэшем

ДОЛЖЕН. Статика подключается через go:embed и отдаётся с Cache-Control: public, max-age=31536000, immutable.

ПОЧЕМУ. Встроенные ассеты делают деплой одним артефактом: нет второго шага раскладки файлов, который может отстать от бинаря и оставить новую разметку со старым css. Иммутабельный кэш безопасен ровно потому, что URL меняется вместе с содержимым (HTMX-30, HTMX-31); без этого условия год кэша был бы способом навсегда закрепить у пользователя старый файл.

HTMX-30. Меняемые ассеты версионируются хешем содержимого

ДОЛЖЕН. css и js адресуются с ?v=<короткий sha256 содержимого>, и URL строит хелпер шаблона.

ПОЧЕМУ. Хеш содержимого — единственная версия, которую невозможно забыть обновить: она меняется от самой правки. Ручной номер и дата сборки от этого не защищают, а цена промаха при иммутабельном кэше (HTMX-29) — устаревший файл у пользователя до ручной очистки кэша. Хелпер нужен, чтобы хеш не проставляли в каждом шаблоне руками.

HTMX-31. Вендорный ассет в ?v= не нуждается

ДОПУСКАЕТСЯ. Вендор адресуется по неизменному имени файла, без параметра версии.

ПОЧЕМУ. Содержимое под этим именем не меняется: обновление вендора приходит новым именем файла, то есть новым URL. Кэш-бастер защищает от подмены содержимого под тем же адресом, а такой ситуации здесь нет — и явное разрешение снимает вопрос, не нарушает ли это HTMX-30.

HTMX-32. Вендор не коммитится, а добывается по манифесту

ДОЛЖЕН. Идемпотентная задача скачивает вендорные файлы по манифесту (путь url sha256) с проверкой контрольной суммы; сборка зависит от этой задачи.

ПОЧЕМУ. Манифест делает версию и происхождение ассета видимыми в diff'е — у закоммиченного минифицированного файла обновление выглядит стеной непрозрачных изменений, и подмену в ней не разглядеть. Sha256 — единственная проверка, что скачали то же самое, что проверяли; зависимость сборки от задачи не даёт собраться без ассета в свежем клоне.

HTMX-33. Шрифты и скрипты — self-hosted

ДОЛЖЕН. Внешних хостов во время выполнения нет.

ПОЧЕМУ. Каждый внешний хост — это чужой аптайм внутри своей страницы и третья сторона, видящая каждый запрос пользователя. Самодостаточный бинарь вдобавок разворачивается в сети без выхода наружу, где CDN просто не отвечает.