заведён канон общих конвенций для личных проектов
- 13 конвенций по осям arch / lang / stack / common; репозитории берут оттуда копии в свой docs/conventions/ и коммитят их у себя - conv — синхронизация копий: add / status / diff / pull / push, локальные регионы исключены из сравнения, поэтому расхождение не даёт шума
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
---
|
||||
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 -->
|
||||
Reference in New Issue
Block a user