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

212 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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`. Обработчик зовёт
доменную операцию **одинаково** в обеих ветках и ветвится только после:
```go
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"`:
```html
{{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 страницы и вырезать нужный узел на клиенте:
```html
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**, без внешних хостов: бинарь
самодостаточен, внешних ресурсов времени выполнения нет.
<!-- local:эталоны -->
<!-- /local -->
<!-- local:отступления -->
<!-- /local -->