Files
dev-conventions/stack/htmx/web-ui.md
T
av 4a59c71737 заведён канон общих конвенций для личных проектов
- 13 конвенций по осям arch / lang / stack / common; репозитории берут
  оттуда копии в свой docs/conventions/ и коммитят их у себя
- conv — синхронизация копий: add / status / diff / pull / push, локальные
  регионы исключены из сравнения, поэтому расхождение не даёт шума
2026-07-25 18:18:18 +03:00

14 KiB
Raw Blame History

status
status
рекомендуемая

Веб-UI на htmx

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

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

Утверждения о поведении htmx относятся к 2.x: дефолты обработки ответов между мажорами менялись.

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

htmx-first: роутер + серверные шаблоны + htmx. Без шага сборки, без Node и бандлера, без реактивных фреймворков. htmx вендорится и самохостится, без CDN.

  • Свой JS сведён к минимуму: только то, что серверу знать не нужно (например, копирование в буфер обмена). Клиентского пересчёта доменного состояния нет — состояние считает сервер, клиент свопит присланную разметку.
  • Реактивный слой (Alpine.js и подобное) не вводим до появления виджета, которому он действительно нужен, и вводим отдельным решением, а не попутно.

Единый источник разметки: партиал = страница = фрагмент

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

Инвариант: корень шаблона — элемент с целевым id. hx-swap="outerHTML" заменяет весь корневой узел; если ответный фрагмент не несёт тот же корневой id, следующее действие или поллер не найдёт таргет. Разметку и id держим в одном партиале.

Сборку view выносим в переиспользуемую функцию и зовём её и на полной странице, и во фрагменте — чтобы htmx-ветка не копипастила сборку.

Обработчик действия: ветвление htmx / редирект

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

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)       // фрагмент = тот же шаблон

render собирает именованный шаблон в буфер и только затем пишет ответ — при ошибке шаблона клиент не получит «полустраницу».

Одно действие — два региона: hx-swap-oob

Когда действие меняет не только свой регион (сменился выбор — обновилась и панель действий), второй регион едет тем же ответом через hx-swap-oob="true". Оба фрагмента — обычные именованные партиалы с теми же id, что и на странице; отдельной разметки под oob не заводим по тому же правилу, что и для основного свопа.

Альтернатива — второй запрос с клиента — вводит гонку между двумя ответами и лишний раунд-трип; HX-Trigger с последующим hx-get уместен только если второй регион обновляется реже, чем происходит действие.

Graceful degradation

Формы действий остаются обычными <form method="post" action="…">; hx-post/hx-target/hx-swap лишь накладываются сверху на ту же форму. Без JS действие работает через POST и редирект. action формы — рабочий фолбэк, а не декорация.

Фильтр, поиск и пагинация списка — серверные, через GET-параметры, тоже без JS. Клиентской фильтрации нет намеренно.

Требование распространяется на действия и навигацию. Интерактивный виджет выбора, у которого нет осмысленного не-JS поведения, может требовать JS — но это отступление, и оно записывается, а не подразумевается.

Ошибки на htmx-пути: HTTP 200 плюс фрагмент

В htmx 2.x ответы 4xx/5xx по умолчанию не свопят DOM. Это настраивается (htmx.config.responseHandling, расширение response-targets, слушатель htmx:responseError), но любая настройка — это свой JS-конфиг на клиенте, что противоречит разделу «Стек и границы». Поэтому сознательно берём 200 с фрагментом, несущим сообщение, и доменную ошибку на htmx-пути не транслируем в HTTP-статус — в отличие от REST API и no-JS редиректа с ?err=.

  • Сообщение — нейтральный текст публичного канала; сырой err.Error() наружу не идёт.
  • Ошибку кладём в отдельное поле под ошибку действия, не перегружая доменные поля: у них может быть своё непустое значение, которое сообщение перекроет.
  • При ошибке активное состояние не меняем — перечитанный view показывает прежний выбор плюс сообщение.
  • Цена: в логе доступа провалившееся действие выглядит как 200. Искать его надо по доменной записи об исходе операции (lang/go/logging.md), а не по коду ответа.

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

Фрагмент-эндпоинт под /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}}
  • Поллер самозавершается. Когда состояние выходит из «живого», фрагмент возвращается без hx-* — htmx больше не опрашивает. Условие живости ведёт собственное состояние приложения, а не внешний сервис. (Встроенная альтернатива — ответ со статусом 286 — не используется: она не совместима с инвариантом «партиал = страница», свежезагруженная страница тоже должна рендериться без поллера.)
  • outerHTML-своп всего фрагмента удаляет старый узел вместе с его поллером и инициализирует новый — двойного опроса нет при условии совпадения корневого id.
  • Поллер не свопит контейнер с активными полями ввода. Своп поддерева теряет фокус, выделение и незасабмиченный текст внутри него: живость включается только в состояниях, где редактировать нечего.
  • Инвариант: браузер не опрашивает внешний сервис напрямую — только свой сервер.
  • Если тик проксирует состояние внешнего сервиса, данные берутся из in-memory снимка, обновляемого воркером, без сети на каждый тик; контракт снимка узкий и не зависит от способа доставки (путь к SSE остаётся изолированным). Тик, показывающий собственное состояние приложения, читает своё хранилище — это нормально и снимка не требует.

Поллинг полной страницы

Когда живой фрагмент — это почти вся страница, отдельный /fragments/…-роут дублировал бы обработчик. Тогда допустимо опрашивать сам URL страницы и вырезать нужный узел на клиенте:

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

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

Своп сохраняет контекст; выход — навигация

hx-swap="outerHTML" не сбрасывает прокрутку и не трогает серверные фильтр, поиск и пагинацию (они в query). Внутри свопаемого поддерева контекст не сохраняется — фокус, выделение и введённый текст теряются (см. правило про поллер выше).

Действие не должно уводить пользователя со страницы, если предмет остаётся на ней — своп на месте. Действие, после которого предмет покидает страницу, остаётся обычной POST-формой без hx-* → полная навигация. Маркер «это выход» — форма без htmx-атрибутов; так не нужен HX-Redirect, а «уйти с экрана» выражено самой навигацией.

Асинхронные действия. Если доменное действие асинхронно (переводит в промежуточное состояние, работу доделывает воркер), своп отдаёт промежуточное состояние, а не мнимый результат; итог догоняет самозавершающийся поллер. Не обещаем в UI мгновенный итог async-операции.

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

Если один роут зовут с разных страниц и своп-ответ должен быть разным фрагментом, различаем явным скрытым полем формы (surface=list|detail), а не эвристикой по HX-Target или Referer: поле самодокументируемо и не зависит от резолва таргета.

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

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

  • Ассеты встроены в бинарь (go:embed) и отдаются с длинным иммутабельным кэшем (Cache-Control: public, max-age=31536000, immutable).
  • Меняемые ассеты (css/js) версионируются через ?v=<hash> — короткий sha256 содержимого; URL строит хелпер шаблона. Свежий деплой не отдаёт устаревший файл.
  • Вендор адресуется по неизменному имени файла и в ?v= не нуждается. В git его не коммитим: идемпотентная задача добывает его по манифесту (путь url sha256) с проверкой контрольной суммы, и сборка от неё зависит.
  • Шрифты и скрипты — self-hosted, без внешних хостов: бинарь самодостаточен, внешних ресурсов времени выполнения нет.