--- 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 Формы действий остаются обычными `