--- 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 Формы действий остаются обычными `
`; `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"}}
...
{{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=` — короткий sha256 содержимого; URL строит хелпер шаблона. Свежий деплой не отдаёт устаревший файл. - Вендор адресуется по **неизменному имени файла** и в `?v=` не нуждается. В git его не коммитим: идемпотентная задача добывает его по манифесту (`путь url sha256`) с проверкой контрольной суммы, и сборка от неё зависит. - Шрифты и скрипты — **self-hosted**, без внешних хостов: бинарь самодостаточен, внешних ресурсов времени выполнения нет.