- 13 конвенций по осям arch / lang / stack / common; репозитории берут оттуда копии в свой docs/conventions/ и коммитят их у себя - conv — синхронизация копий: add / status / diff / pull / push, локальные регионы исключены из сравнения, поэтому расхождение не даёт шума
14 KiB
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, без внешних хостов: бинарь самодостаточен, внешних ресурсов времени выполнения нет.