остальные конвенции переведены на формальный язык
- 11 файлов разобраны на нумерованные правила: 220 правил в каноне, у каждого модальность и обязательный блок «Почему» - классифицирующие места оформлены таблицами, файловый статус снят отовсюду, локальные регионы сохранены под прежними именами
This commit is contained in:
+371
-123
@@ -1,54 +1,106 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
---
|
||||
|
||||
# Веб-UI на htmx
|
||||
|
||||
Как пишется код веб-UI: частичный своп фрагментов, поллинг живых
|
||||
обновлений, обработчики действий, деградация без JS, ошибки. Что именно UI
|
||||
показывает и какие действия обязан поддерживать — в спеках, не здесь.
|
||||
показывает и какие действия поддерживает — в спеках, не здесь. Форма записи
|
||||
— `common/language.md`.
|
||||
|
||||
Логирование запросов — `lang/go/logging.md` (HTTP-поля, рутинно-частое на
|
||||
`DEBUG`). Трансляция доменных ошибок наружу — `lang/go/errors.md`
|
||||
(приватный канал = логи, публичный = сообщение плюс корреляционный ключ).
|
||||
Здесь — только специфика htmx-транспорта, без дублирования.
|
||||
|
||||
Утверждения о поведении htmx относятся к **2.x**: дефолты обработки
|
||||
ответов между мажорами менялись.
|
||||
## Область действия
|
||||
|
||||
Утверждения о поведении htmx относятся к **2.x**: дефолты обработки ответов
|
||||
между мажорами менялись. Правила описывают то, как написан код веб-UI, а не
|
||||
то, какие экраны и действия у приложения есть.
|
||||
|
||||
## Стек и границы
|
||||
|
||||
htmx-first: роутер + серверные шаблоны + htmx. **Без шага сборки, без Node
|
||||
и бандлера, без реактивных фреймворков.** htmx вендорится и самохостится,
|
||||
без CDN.
|
||||
### R1. Стек: роутер, серверные шаблоны, htmx
|
||||
|
||||
- Свой JS сведён к минимуму: только то, что серверу знать не нужно
|
||||
(например, копирование в буфер обмена). **Клиентского пересчёта доменного
|
||||
состояния нет** — состояние считает сервер, клиент свопит присланную
|
||||
разметку.
|
||||
- Реактивный слой (Alpine.js и подобное) не вводим до появления виджета,
|
||||
которому он действительно нужен, и вводим отдельным решением, а не
|
||||
попутно.
|
||||
**ДОЛЖЕН.** UI собирается из серверных шаблонов и htmx — без шага сборки,
|
||||
без Node и бандлера, без реактивного фреймворка.
|
||||
|
||||
## Единый источник разметки: партиал = страница = фрагмент
|
||||
**Почему.** Шаг сборки — это второй язык, второй менеджер зависимостей и
|
||||
артефакт, который расходится с исходником; приложению, где разметку целиком
|
||||
отдаёт сервер, он не покупает ничего. Реактивный фреймворк добавляет вторую
|
||||
модель состояния рядом с серверной (R2), и дальше на каждом экране
|
||||
приходится решать, какая из них главная. Сам htmx — вендорный ассет и
|
||||
живёт по правилам вендоринга (R32, R33): внешний CDN добавил бы к аптайму
|
||||
приложения аптайм чужого хоста.
|
||||
|
||||
Переиспользуемый кусок — это именованный шаблон в `partials/`. Тот же
|
||||
шаблон рендерится **и** инлайн на странице, **и** как ответ-фрагмент того
|
||||
же обработчика. Отдельной разметки для фрагмента не заводим — иначе она
|
||||
дрейфует от страницы.
|
||||
### R2. Клиент не пересчитывает доменное состояние
|
||||
|
||||
**Инвариант: корень шаблона — элемент с целевым `id`.**
|
||||
`hx-swap="outerHTML"` заменяет весь корневой узел; если ответный фрагмент не
|
||||
несёт тот же корневой `id`, следующее действие или поллер не найдёт таргет.
|
||||
Разметку и `id` держим в одном партиале.
|
||||
**НЕ ДОЛЖЕН.** Свой JS делает только то, чего серверу знать не нужно
|
||||
(копирование в буфер обмена и подобное); доменное состояние считает сервер,
|
||||
клиент свопит присланную разметку.
|
||||
|
||||
Сборку view выносим в переиспользуемую функцию и зовём её и на полной
|
||||
странице, и во фрагменте — чтобы htmx-ветка не копипастила сборку.
|
||||
**Почему.** Пересчёт на клиенте — вторая реализация той же логики, которую
|
||||
никто не сверяет: расходится она тихо, а проявляется как «на экране одно, в
|
||||
базе другое». Вдобавок клиентский пересчёт по определению не работает в
|
||||
деградированном режиме (R11, R12) — значит, серверную версию того же
|
||||
вычисления всё равно придётся держать.
|
||||
|
||||
## Обработчик действия: ветвление htmx / редирект
|
||||
### R3. Реактивный слой вводится отдельным решением
|
||||
|
||||
htmx-запрос определяем по заголовку `HX-Request: true`. Обработчик зовёт
|
||||
доменную операцию **одинаково** в обеих ветках и ветвится только после:
|
||||
**НЕ ДОЛЖЕН.** Alpine.js и подобное не появляется попутно с задачей —
|
||||
только когда есть виджет, которому он действительно нужен, и отдельным
|
||||
решением.
|
||||
|
||||
**Почему.** Реактивный слой, попавший в проект ради одного выпадающего
|
||||
списка, немедленно доступен всему остальному коду — и граница R1/R2
|
||||
перестаёт держаться сама собой. Отдельное решение — единственный момент,
|
||||
когда цену видно целиком: она не в килобайтах, а в том, что дальше на
|
||||
каждом экране есть выбор между двумя моделями состояния.
|
||||
|
||||
## Единый источник разметки
|
||||
|
||||
### R4. Партиал = страница = фрагмент
|
||||
|
||||
**ДОЛЖЕН.** Переиспользуемый кусок разметки — именованный шаблон в
|
||||
`partials/`, и он же рендерится инлайн на странице и как ответ-фрагмент
|
||||
обработчика; отдельной разметки под фрагмент нет.
|
||||
|
||||
**Почему.** Две копии одной разметки расходятся молча: правку вносят в ту,
|
||||
что открыта, и страница начинает выглядеть иначе, чем результат свопа того
|
||||
же региона. Заметно это становится только на глаз и только тому, кто открыл
|
||||
оба пути подряд.
|
||||
|
||||
### R5. Корень партиала — элемент с целевым `id`
|
||||
|
||||
**ДОЛЖЕН.** Корневой узел шаблона несёт тот `id`, по которому адресуют
|
||||
регион, и ответный фрагмент несёт тот же `id`.
|
||||
|
||||
**Почему.** `hx-swap="outerHTML"` заменяет корневой узел целиком, вместе с
|
||||
его атрибутами. Если пришедший фрагмент несёт другой `id` или не несёт его
|
||||
вовсе, первый своп проходит успешно, а следующее действие и поллер уже не
|
||||
находят таргет: регион застывает без единой ошибки — ни в консоли, ни в
|
||||
логе.
|
||||
|
||||
### R6. Сборку view делает общая функция
|
||||
|
||||
**СЛЕДУЕТ.** Один view-builder зовут и обработчик полной страницы, и
|
||||
htmx-ветка.
|
||||
|
||||
**Почему.** Общий шаблон (R4) гарантирует одинаковую разметку, но не
|
||||
одинаковые данные: скопированная сборка view расходится по набору полей, и
|
||||
фрагмент начинает показывать не то, что показала бы страница. Это ровно тот
|
||||
класс расхождений, который R4 закрывает для разметки.
|
||||
|
||||
## Обработчик действия
|
||||
|
||||
### R7. Доменный вызов одинаков для htmx и обычного запроса
|
||||
|
||||
**ДОЛЖЕН.** Обработчик определяет htmx-запрос по заголовку
|
||||
`HX-Request: true`, зовёт доменную операцию до ветвления и ветвится только
|
||||
на способе ответа:
|
||||
|
||||
| № | Запрос | Ответ |
|
||||
|---|---|---|
|
||||
| R7.1 | `HX-Request: true` | фрагмент тем же партиалом (R4) по перечитанному состоянию |
|
||||
| R7.2 | обычный | PRG-редирект (303) |
|
||||
|
||||
```go
|
||||
actionErr := fn(r.Context(), id) // доменный вызов идентичен для htmx и не-htmx
|
||||
@@ -65,60 +117,137 @@ if actionErr != nil {
|
||||
s.render(w, "source_block", view) // фрагмент = тот же шаблон
|
||||
```
|
||||
|
||||
`render` собирает именованный шаблон **в буфер** и только затем пишет
|
||||
ответ — при ошибке шаблона клиент не получит «полустраницу».
|
||||
**Почему.** Ветвление до вызова даёт две реализации одного действия, и
|
||||
дальше дефект воспроизводится только на одной поверхности — причём
|
||||
деградированный путь (R11) открывают реже, то есть чинить будут не тот.
|
||||
Перечитанное состояние в htmx-ветке нужно потому, что своп заменяет регион
|
||||
целиком: view, собранный из аргументов запроса, покажет намерение, а не
|
||||
результат.
|
||||
|
||||
## Одно действие — два региона: `hx-swap-oob`
|
||||
### R8. Шаблон рендерится в буфер, потом в ответ
|
||||
|
||||
Когда действие меняет не только свой регион (сменился выбор — обновилась и
|
||||
панель действий), второй регион едет **тем же ответом** через
|
||||
`hx-swap-oob="true"`. Оба фрагмента — обычные именованные партиалы с теми
|
||||
же `id`, что и на странице; отдельной разметки под oob не заводим по тому
|
||||
же правилу, что и для основного свопа.
|
||||
**ДОЛЖЕН.** Именованный шаблон собирается целиком в буфер, и только затем
|
||||
буфер пишется в ответ.
|
||||
|
||||
Альтернатива — второй запрос с клиента — вводит гонку между двумя ответами
|
||||
и лишний раунд-трип; `HX-Trigger` с последующим `hx-get` уместен только
|
||||
если второй регион обновляется реже, чем происходит действие.
|
||||
**Почему.** Прямая запись в ответ отправляет клиенту статус и часть
|
||||
разметки раньше, чем шаблон дошёл до ошибки: сообщить об отказе уже нечем,
|
||||
а htmx свопит в DOM полученный обрывок. Внешне это «исчезла половина
|
||||
региона», и причина по такому симптому не читается.
|
||||
|
||||
## Одно действие — два региона
|
||||
|
||||
### R9. Второй регион едет тем же ответом через `hx-swap-oob`
|
||||
|
||||
**СЛЕДУЕТ.** Когда действие меняет не только свой регион, второй фрагмент
|
||||
отдаётся в том же ответе с `hx-swap-oob="true"` — обычным именованным
|
||||
партиалом с тем же `id`, что и на странице (R4, R5).
|
||||
|
||||
**Почему.** Второй запрос с клиента вводит гонку: два ответа считают
|
||||
состояние в разные моменты и приезжают в произвольном порядке, поэтому
|
||||
панель действий может отразить состояние до действия. Плюс лишний
|
||||
раунд-трип на каждое действие.
|
||||
|
||||
### R10. Отдельный запрос за вторым регионом — когда он обновляется реже действия
|
||||
|
||||
**ДОПУСКАЕТСЯ.** `HX-Trigger` в ответе плюс отдельный `hx-get`, если второй
|
||||
регион меняется не на каждое действие.
|
||||
|
||||
**Почему.** Явное разрешение нужно, чтобы R9 не читался как запрет любого
|
||||
второго запроса. Когда регион обновляется редко, oob-ветка гоняет
|
||||
одинаковую разметку на каждое действие и связывает два шаблона там, где
|
||||
связи нет; гонка же тем менее наблюдаема, чем реже обновление.
|
||||
|
||||
## Graceful degradation
|
||||
|
||||
Формы действий остаются обычными `<form method="post" action="…">`;
|
||||
`hx-post`/`hx-target`/`hx-swap` лишь **накладываются сверху** на ту же
|
||||
форму. Без JS действие работает через POST и редирект. `action` формы —
|
||||
рабочий фолбэк, а не декорация.
|
||||
### R11. Форма действия работает без JS
|
||||
|
||||
Фильтр, поиск и пагинация списка — **серверные**, через GET-параметры, тоже
|
||||
без JS. Клиентской фильтрации нет намеренно.
|
||||
**ДОЛЖЕН.** Действие — обычная `<form method="post" action="…">`, на которую
|
||||
`hx-post`/`hx-target`/`hx-swap` накладываются сверху; `action` ведёт на
|
||||
рабочий обработчик.
|
||||
|
||||
Требование распространяется на **действия и навигацию**. Интерактивный
|
||||
виджет выбора, у которого нет осмысленного не-JS поведения, может требовать
|
||||
JS — но это отступление, и оно записывается, а не подразумевается.
|
||||
**Почему.** htmx может не загрузиться — ошибка вендоринга, блокировщик,
|
||||
медленная сеть, — и без рабочего `action` форма в этот момент не отправляет
|
||||
ничего, молча. Тот же `action` — единственное, что делает действие
|
||||
проверяемым без браузера с JS.
|
||||
|
||||
## Ошибки на htmx-пути: HTTP 200 плюс фрагмент
|
||||
### R12. Фильтр, поиск и пагинация — серверные
|
||||
|
||||
В htmx 2.x ответы 4xx/5xx по умолчанию **не свопят DOM**. Это настраивается
|
||||
**ДОЛЖЕН.** Отбор списка задаётся GET-параметрами и выполняется на сервере;
|
||||
клиентской фильтрации загруженной разметки нет.
|
||||
|
||||
**Почему.** Клиент видит только текущую страницу списка, поэтому клиентский
|
||||
фильтр отвечает по неполным данным и делает это молча — результат выглядит
|
||||
валидным. Вдобавок состояние отбора в query переживает своп (R25) и
|
||||
перезагрузку, его можно послать ссылкой и увидеть в логе.
|
||||
|
||||
### R13. Область обязательной деградации
|
||||
|
||||
**ДОЛЖЕН.** Требование работать без JS распространяется не на весь UI:
|
||||
|
||||
| № | Поверхность | Поведение без JS |
|
||||
|---|---|---|
|
||||
| R13.1 | действия и навигация | работают полностью (R11, R12) |
|
||||
| R13.2 | интерактивный виджет выбора, у которого нет осмысленного не-JS поведения | может не работать; записывается в отступления |
|
||||
|
||||
**Почему.** Без явной границы правило деградации читается как запрет на
|
||||
любой JS-виджет — и тогда его либо тихо нарушают, либо отказываются от
|
||||
виджета, который был нужен. Запись в отступления держит список честным:
|
||||
видно, какие именно места ломаются с выключенным JS, а не «где-то
|
||||
что-то».
|
||||
|
||||
## Ошибки на htmx-пути
|
||||
|
||||
### R14. Ошибка действия на htmx-пути — 200 с фрагментом
|
||||
|
||||
**ДОЛЖЕН.** Провалившееся действие отдаёт статус 200 и фрагмент с
|
||||
сообщением; доменная ошибка на htmx-пути не транслируется в HTTP-статус.
|
||||
|
||||
**Почему.** В htmx 2.x ответы 4xx/5xx по умолчанию не свопят DOM — то есть
|
||||
пользователь не увидит ничего. Настроить это можно
|
||||
(`htmx.config.responseHandling`, расширение `response-targets`, слушатель
|
||||
`htmx:responseError`), но любая настройка — это свой JS-конфиг на клиенте,
|
||||
что противоречит разделу «Стек и границы». Поэтому сознательно берём
|
||||
**200 с фрагментом**, несущим сообщение, и доменную ошибку на htmx-пути
|
||||
**не** транслируем в HTTP-статус — в отличие от REST API и no-JS редиректа
|
||||
с `?err=`.
|
||||
`htmx:responseError`), но любая настройка — свой JS-конфиг на клиенте, и
|
||||
платится она из R1 и R2. Для REST API и не-JS редиректа с `?err=` статус
|
||||
по-прежнему используется: там его кто-то читает.
|
||||
|
||||
- Сообщение — нейтральный текст публичного канала; сырой `err.Error()`
|
||||
наружу не идёт.
|
||||
- Ошибку кладём в **отдельное поле** под ошибку действия, не перегружая
|
||||
доменные поля: у них может быть своё непустое значение, которое сообщение
|
||||
перекроет.
|
||||
- **При ошибке активное состояние не меняем** — перечитанный view
|
||||
показывает прежний выбор плюс сообщение.
|
||||
- Цена: в логе доступа провалившееся действие выглядит как `200`. Искать
|
||||
его надо по доменной записи об исходе операции (`lang/go/logging.md`), а
|
||||
не по коду ответа.
|
||||
Цена решения: в логе доступа провалившееся действие выглядит как `200`.
|
||||
Искать его надо по доменной записи об исходе операции (`lang/go/logging.md`),
|
||||
а не по коду ответа.
|
||||
|
||||
### R15. Наружу идёт сообщение публичного канала
|
||||
|
||||
**ДОЛЖЕН.** Во фрагмент попадает нейтральный текст по правилам
|
||||
`lang/go/errors.md`; `err.Error()` в разметку не рендерится.
|
||||
|
||||
**Почему.** htmx-фрагмент выглядит внутренней деталью приложения, и на нём
|
||||
легче всего забыть, что это тот же публичный канал, что и страница:
|
||||
разметка уезжает в браузер пользователя целиком. Статус 200 (R14)
|
||||
дополнительно снимает ощущение «это ошибочный ответ, его никто не увидит».
|
||||
|
||||
### R16. Сообщение об ошибке — в отдельном поле view
|
||||
|
||||
**ДОЛЖЕН.** У view есть поле под ошибку действия; доменные поля под
|
||||
сообщение не переиспользуются.
|
||||
|
||||
**Почему.** У доменного поля может быть своё непустое значение, и сообщение
|
||||
его перекроет: пользователь получит текст ошибки вместо данных, а шаблон —
|
||||
необходимость угадывать, что сейчас лежит в поле. Отдельное поле делает оба
|
||||
состояния — данные и ошибку — выразимыми одновременно, а это ровно то, чего
|
||||
требует R17.
|
||||
|
||||
### R17. При ошибке активное состояние не меняется
|
||||
|
||||
**НЕ ДОЛЖЕН.** Фрагмент, отданный после неудачного действия, показывает
|
||||
прежний выбор плюс сообщение.
|
||||
|
||||
**Почему.** Своп заменяет регион целиком, поэтому фрагмент — единственное,
|
||||
что пользователь узнает о состоянии. Показав намеренное состояние вместо
|
||||
фактического, интерфейс расходится с сервером, и следующее действие человек
|
||||
делает по ложной картине — на сервере оно применится к другому объекту.
|
||||
|
||||
## Живой поллинг
|
||||
|
||||
Фрагмент-эндпоинт под `/fragments/…` плюс в разметке `hx-get`,
|
||||
`hx-trigger="every Ns"`, `hx-swap="outerHTML"`:
|
||||
Живое обновление устроено как фрагмент-эндпоинт под `/fragments/…` плюс
|
||||
`hx-get`, `hx-trigger="every Ns"`, `hx-swap="outerHTML"` в разметке:
|
||||
|
||||
```html
|
||||
{{define "progress"}}<div id="item-live-{{.ID}}"
|
||||
@@ -128,81 +257,200 @@ JS — но это отступление, и оно записывается,
|
||||
</div>{{end}}
|
||||
```
|
||||
|
||||
- **Поллер самозавершается.** Когда состояние выходит из «живого»,
|
||||
фрагмент возвращается **без `hx-*`** — htmx больше не опрашивает. Условие
|
||||
живости ведёт собственное состояние приложения, а не внешний сервис.
|
||||
(Встроенная альтернатива — ответ со статусом 286 — не используется: она
|
||||
не совместима с инвариантом «партиал = страница», свежезагруженная
|
||||
страница тоже должна рендериться без поллера.)
|
||||
- **`outerHTML`-своп всего фрагмента** удаляет старый узел вместе с его
|
||||
поллером и инициализирует новый — двойного опроса нет **при условии
|
||||
совпадения корневого `id`**.
|
||||
- **Поллер не свопит контейнер с активными полями ввода.** Своп поддерева
|
||||
теряет фокус, выделение и незасабмиченный текст внутри него: живость
|
||||
включается только в состояниях, где редактировать нечего.
|
||||
- **Инвариант: браузер не опрашивает внешний сервис напрямую** — только
|
||||
свой сервер.
|
||||
- Если тик **проксирует состояние внешнего сервиса**, данные берутся из
|
||||
in-memory снимка, обновляемого воркером, без сети на каждый тик; контракт
|
||||
снимка узкий и не зависит от способа доставки (путь к SSE остаётся
|
||||
изолированным). Тик, показывающий **собственное** состояние приложения,
|
||||
читает своё хранилище — это нормально и снимка не требует.
|
||||
### R18. Поллер самозавершается
|
||||
|
||||
### Поллинг полной страницы
|
||||
**ДОЛЖЕН.** Когда состояние вышло из «живого», фрагмент возвращается без
|
||||
`hx-*`-атрибутов.
|
||||
|
||||
Когда живой фрагмент — это почти вся страница, отдельный
|
||||
`/fragments/…`-роут дублировал бы обработчик. Тогда допустимо опрашивать
|
||||
сам URL страницы и вырезать нужный узел на клиенте:
|
||||
**Почему.** Иначе опрос не прекращается никогда: каждая открытая вкладка
|
||||
держит постоянный поток запросов за неизменными данными, и закрывает его
|
||||
только пользователь. Условие остановки живёт в разметке ответа, потому что
|
||||
это единственный канал, которым сервер управляет поллером.
|
||||
|
||||
Встроенная альтернатива — ответ со статусом 286 — не используется: она не
|
||||
совместима с R4, ведь свежезагруженная страница рендерится тем же партиалом
|
||||
и тоже без поллера.
|
||||
|
||||
### R19. Условие живости ведёт собственное состояние приложения
|
||||
|
||||
**ДОЛЖЕН.** Признак «живо ли ещё» вычисляется по состоянию, которым владеет
|
||||
приложение, а не по ответу внешнего сервиса.
|
||||
|
||||
**Почему.** Внешний сервис отвечает не всегда и не одинаково: на его
|
||||
недоступности поллер либо останавливается, пока работа идёт, либо не
|
||||
останавливается никогда. Приложение — единственный участник, который знает
|
||||
про операцию всё и может ответить на каждом тике.
|
||||
|
||||
### R20. Поллер свопит фрагмент целиком через `outerHTML`
|
||||
|
||||
**ДОЛЖЕН.** Тик заменяет весь фрагмент (`hx-swap="outerHTML"`), а не его
|
||||
содержимое.
|
||||
|
||||
**Почему.** `outerHTML` удаляет старый узел вместе с его `hx-trigger` и
|
||||
инициализирует новый — так поллер живёт ровно в одном экземпляре и так же
|
||||
выключается (R18). Своп содержимого оставил бы старый узел с его таймером,
|
||||
и через несколько обновлений опрос шёл бы в несколько потоков. Работает это
|
||||
при совпадении корневого `id` (R5).
|
||||
|
||||
### R21. Поллер не свопит контейнер с активными полями ввода
|
||||
|
||||
**НЕ ДОЛЖЕН.** Живость включается только в тех состояниях фрагмента, где
|
||||
редактировать нечего.
|
||||
|
||||
**Почему.** Своп поддерева теряет фокус, выделение и незасабмиченный текст
|
||||
внутри него. У поллера это происходит по таймеру, то есть в момент, который
|
||||
пользователь не выбирал: текст исчезает посреди набора и воспроизводится
|
||||
как «приложение стирает мой ввод».
|
||||
|
||||
### R22. Браузер не ходит во внешний сервис напрямую
|
||||
|
||||
**НЕ ДОЛЖЕН.** Поллинг и прочие запросы страницы идут на свой сервер.
|
||||
|
||||
**Почему.** Прямой запрос из браузера выносит наружу адрес и учётные данные
|
||||
внешнего сервиса и делает страницу заложником его CORS-политики. Вдобавок
|
||||
контракт внешнего сервиса протекает в разметку: его смена перестаёт быть
|
||||
серверным изменением.
|
||||
|
||||
### R23. Источник данных для тика
|
||||
|
||||
**ДОЛЖЕН.** Тик читает данные там, где они уже есть, не ходя в сеть:
|
||||
|
||||
| № | Что показывает тик | Откуда берёт |
|
||||
|---|---|---|
|
||||
| R23.1 | состояние внешнего сервиса | in-memory снимок, обновляемый воркером |
|
||||
| R23.2 | собственное состояние приложения | своё хранилище; снимок не требуется |
|
||||
|
||||
**Почему.** Тик умножается на число открытых вкладок, поэтому сеть на
|
||||
каждом тике превращает интерфейс в генератор нагрузки на внешний сервис — и
|
||||
его недоступность становится недоступностью страницы. Снимок разрывает эту
|
||||
связь: частоту обращений к внешнему сервису задаёт воркер, а не
|
||||
пользователи. Контракт снимка держат узким, чтобы способ доставки
|
||||
(поллинг сегодня, SSE потом) менялся, не задевая остальной код. Для
|
||||
собственного состояния той же цены нет: хранилище и так своё, а лишний слой
|
||||
кеша добавил бы только рассинхрон.
|
||||
|
||||
### R24. Поллинг URL страницы вместо отдельного фрагмент-роута
|
||||
|
||||
**ДОПУСКАЕТСЯ.** Когда живой фрагмент — почти вся страница, `hx-get` идёт
|
||||
на URL самой страницы, а нужный узел вырезается `hx-select`:
|
||||
|
||||
```html
|
||||
hx-get="/item/{{.ID}}" hx-trigger="every 3s"
|
||||
hx-select="#item-main" hx-swap="outerHTML"
|
||||
```
|
||||
|
||||
Инвариант корневого `id` действует и здесь: `hx-select` должен выбирать тот
|
||||
**Почему.** Отдельный `/fragments/…`-роут в этом случае дублирует
|
||||
обработчик страницы целиком — вместе с перечитыванием состояния и сборкой
|
||||
view, — и дальше два обработчика расходятся по тому же сценарию, что и две
|
||||
копии разметки (R4).
|
||||
|
||||
Инвариант корневого `id` (R5) действует и здесь: `hx-select` выбирает тот
|
||||
же узел, который свопится.
|
||||
|
||||
## Своп сохраняет контекст; выход — навигация
|
||||
## Своп и выход со страницы
|
||||
|
||||
`hx-swap="outerHTML"` не сбрасывает прокрутку и не трогает серверные
|
||||
фильтр, поиск и пагинацию (они в query). Внутри свопаемого поддерева
|
||||
контекст **не** сохраняется — фокус, выделение и введённый текст теряются
|
||||
(см. правило про поллер выше).
|
||||
### R25. Действие не уводит со страницы, если предмет остаётся на ней
|
||||
|
||||
Действие **не должно уводить** пользователя со страницы, если предмет
|
||||
остаётся на ней — своп на месте. Действие, после которого предмет
|
||||
**покидает** страницу, остаётся обычной POST-формой **без `hx-*`** → полная
|
||||
навигация. Маркер «это выход» — форма без htmx-атрибутов; так не нужен
|
||||
`HX-Redirect`, а «уйти с экрана» выражено самой навигацией.
|
||||
**НЕ ДОЛЖЕН.** Такое действие свопит свой регион на месте.
|
||||
|
||||
**Асинхронные действия.** Если доменное действие асинхронно (переводит в
|
||||
промежуточное состояние, работу доделывает воркер), своп отдаёт
|
||||
**промежуточное** состояние, а не мнимый результат; итог догоняет
|
||||
самозавершающийся поллер. Не обещаем в UI мгновенный итог async-операции.
|
||||
**Почему.** Своп сохраняет прокрутку и не трогает серверные фильтр, поиск и
|
||||
пагинацию — они в query (R12). Полная навигация ради изменения одного
|
||||
региона возвращает пользователя в начало списка и стоит перерисовки всей
|
||||
страницы. Не сохраняется при свопе только контекст внутри самого
|
||||
заменяемого поддерева — фокус, выделение, введённый текст (R21).
|
||||
|
||||
### R26. Выход со страницы — форма без `hx-*`
|
||||
|
||||
**ДОЛЖЕН.** Действие, после которого предмет покидает страницу, остаётся
|
||||
обычной POST-формой без htmx-атрибутов, то есть полной навигацией.
|
||||
|
||||
**Почему.** Своп для такого действия оставил бы на месте регион,
|
||||
описывающий объект, которого на странице больше нет. Отсутствие
|
||||
htmx-атрибутов при этом само работает маркером «это выход»: намерение видно
|
||||
прямо в разметке, и не нужен `HX-Redirect` — то есть ещё один способ
|
||||
сменить страницу, существующий только на htmx-пути.
|
||||
|
||||
### R27. Асинхронное действие свопит промежуточное состояние
|
||||
|
||||
**ДОЛЖЕН.** Если работу доделывает воркер, ответ на действие показывает
|
||||
промежуточное состояние, а итог догоняет самозавершающийся поллер (R18).
|
||||
|
||||
**Почему.** Мнимый результат расходится с сервером до следующего тика, и
|
||||
всё это время пользователь принимает решения по несуществующему исходу —
|
||||
включая повтор действия, которое на самом деле выполняется. Промежуточное
|
||||
состояние вдобавок объясняет, почему регион продолжает обновляться сам.
|
||||
|
||||
## Различение поверхности одного действия
|
||||
|
||||
Если один роут зовут с разных страниц и своп-ответ должен быть разным
|
||||
фрагментом, различаем **явным скрытым полем формы** (`surface=list|detail`),
|
||||
а не эвристикой по `HX-Target` или `Referer`: поле самодокументируемо и не
|
||||
зависит от резолва таргета.
|
||||
### R28. Поверхность различается скрытым полем формы
|
||||
|
||||
**ДОЛЖЕН.** Когда один роут зовут с разных страниц и своп-ответ различается
|
||||
фрагментом, поверхность передаётся явным скрытым полем
|
||||
(`surface=list|detail`), а не выводится из `HX-Target` или `Referer`.
|
||||
|
||||
**Почему.** `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**, без внешних хостов: бинарь
|
||||
самодостаточен, внешних ресурсов времени выполнения нет.
|
||||
### R29. Ассеты встроены в бинарь и отдаются иммутабельным кэшем
|
||||
|
||||
**ДОЛЖЕН.** Статика подключается через `go:embed` и отдаётся с
|
||||
`Cache-Control: public, max-age=31536000, immutable`.
|
||||
|
||||
**Почему.** Встроенные ассеты делают деплой одним артефактом: нет второго
|
||||
шага раскладки файлов, который может отстать от бинаря и оставить новую
|
||||
разметку со старым css. Иммутабельный кэш безопасен ровно потому, что URL
|
||||
меняется вместе с содержимым (R30, R31); без этого условия год кэша был бы
|
||||
способом навсегда закрепить у пользователя старый файл.
|
||||
|
||||
### R30. Меняемые ассеты версионируются хешем содержимого
|
||||
|
||||
**ДОЛЖЕН.** css и js адресуются с `?v=<короткий sha256 содержимого>`, и URL
|
||||
строит хелпер шаблона.
|
||||
|
||||
**Почему.** Хеш содержимого — единственная версия, которую невозможно
|
||||
забыть обновить: она меняется от самой правки. Ручной номер и дата сборки
|
||||
от этого не защищают, а цена промаха при иммутабельном кэше (R29) —
|
||||
устаревший файл у пользователя до ручной очистки кэша. Хелпер нужен, чтобы
|
||||
хеш не проставляли в каждом шаблоне руками.
|
||||
|
||||
### R31. Вендорный ассет в `?v=` не нуждается
|
||||
|
||||
**ДОПУСКАЕТСЯ.** Вендор адресуется по неизменному имени файла, без
|
||||
параметра версии.
|
||||
|
||||
**Почему.** Содержимое под этим именем не меняется: обновление вендора
|
||||
приходит новым именем файла, то есть новым URL. Кэш-бастер защищает от
|
||||
подмены содержимого под тем же адресом, а такой ситуации здесь нет — и
|
||||
явное разрешение снимает вопрос, не нарушает ли это R30.
|
||||
|
||||
### R32. Вендор не коммитится, а добывается по манифесту
|
||||
|
||||
**ДОЛЖЕН.** Идемпотентная задача скачивает вендорные файлы по манифесту
|
||||
(`путь url sha256`) с проверкой контрольной суммы; сборка зависит от этой
|
||||
задачи.
|
||||
|
||||
**Почему.** Манифест делает версию и происхождение ассета видимыми в
|
||||
diff'е — у закоммиченного минифицированного файла обновление выглядит
|
||||
стеной непрозрачных изменений, и подмену в ней не разглядеть. Sha256 —
|
||||
единственная проверка, что скачали то же самое, что проверяли; зависимость
|
||||
сборки от задачи не даёт собраться без ассета в свежем клоне.
|
||||
|
||||
### R33. Шрифты и скрипты — self-hosted
|
||||
|
||||
**ДОЛЖЕН.** Внешних хостов во время выполнения нет.
|
||||
|
||||
**Почему.** Каждый внешний хост — это чужой аптайм внутри своей страницы и
|
||||
третья сторона, видящая каждый запрос пользователя. Самодостаточный бинарь
|
||||
вдобавок разворачивается в сети без выхода наружу, где CDN просто не
|
||||
отвечает.
|
||||
|
||||
<!-- local:эталоны -->
|
||||
<!-- /local -->
|
||||
|
||||
Reference in New Issue
Block a user