--- prefix: HTMX --- # Веб-UI на htmx Как пишется код веб-UI: частичный своп фрагментов, поллинг живых обновлений, обработчики действий, деградация без JS, ошибки. Что именно UI показывает и какие действия поддерживает — в спеках, не здесь. Форма записи — `LANGUAGE.md`. Логирование запросов — конвенция `logging` (HTTP-поля, рутинно-частое на `DEBUG`). Трансляция доменных ошибок наружу — конвенция `errors` (приватный канал = логи, публичный = сообщение плюс корреляционный ключ). Здесь — только специфика htmx-транспорта, без дублирования. ## Область действия Утверждения о поведении htmx относятся к **2.x**: дефолты обработки ответов между мажорами менялись. Правила описывают то, как написан код веб-UI, а не то, какие экраны и действия у приложения есть. ## Стек и границы ### HTMX-1. Стек: роутер, серверные шаблоны, htmx **ДОЛЖЕН.** UI собирается из серверных шаблонов и htmx — без шага сборки, без Node и бандлера, без реактивного фреймворка. **Почему.** Шаг сборки — это второй язык, второй менеджер зависимостей и артефакт, который расходится с исходником; приложению, где разметку целиком отдаёт сервер, он не покупает ничего. Реактивный фреймворк добавляет вторую модель состояния рядом с серверной (HTMX-2), и дальше на каждом экране приходится решать, какая из них главная. Сам htmx — вендорный ассет и живёт по правилам вендоринга (HTMX-32, HTMX-33): внешний CDN добавил бы к аптайму приложения аптайм чужого хоста. ### HTMX-2. Клиент не пересчитывает доменное состояние **НЕ ДОЛЖЕН.** Свой JS делает только то, чего серверу знать не нужно (копирование в буфер обмена и подобное); доменное состояние считает сервер, клиент свопит присланную разметку. **Почему.** Пересчёт на клиенте — вторая реализация той же логики, которую никто не сверяет: расходится она тихо, а проявляется как «на экране одно, в базе другое». Вдобавок клиентский пересчёт по определению не работает в деградированном режиме (HTMX-11, HTMX-12) — значит, серверную версию того же вычисления всё равно придётся держать. ### HTMX-3. Реактивный слой вводится отдельным решением **НЕ ДОЛЖЕН.** Alpine.js и подобное не появляется попутно с задачей — только когда есть виджет, которому он действительно нужен, и отдельным решением. **Почему.** Реактивный слой, попавший в проект ради одного выпадающего списка, немедленно доступен всему остальному коду — и граница HTMX-1/HTMX-2 перестаёт держаться сама собой. Отдельное решение — единственный момент, когда цену видно целиком: она не в килобайтах, а в том, что дальше на каждом экране есть выбор между двумя моделями состояния. ## Единый источник разметки ### HTMX-4. Партиал = страница = фрагмент **ДОЛЖЕН.** Переиспользуемый кусок разметки — именованный шаблон в `partials/`, и он же рендерится инлайн на странице и как ответ-фрагмент обработчика; отдельной разметки под фрагмент нет. **Почему.** Две копии одной разметки расходятся молча: правку вносят в ту, что открыта, и страница начинает выглядеть иначе, чем результат свопа того же региона. Заметно это становится только на глаз и только тому, кто открыл оба пути подряд. ### HTMX-5. Корень партиала — элемент с целевым `id` **ДОЛЖЕН.** Корневой узел шаблона несёт тот `id`, по которому адресуют регион, и ответный фрагмент несёт тот же `id`. **Почему.** `hx-swap="outerHTML"` заменяет корневой узел целиком, вместе с его атрибутами. Если пришедший фрагмент несёт другой `id` или не несёт его вовсе, первый своп проходит успешно, а следующее действие и поллер уже не находят таргет: регион застывает без единой ошибки — ни в консоли, ни в логе. ### HTMX-6. Сборку view делает общая функция **СЛЕДУЕТ.** Один view-builder зовут и обработчик полной страницы, и htmx-ветка. **Почему.** Общий шаблон (HTMX-4) гарантирует одинаковую разметку, но не одинаковые данные: скопированная сборка view расходится по набору полей, и фрагмент начинает показывать не то, что показала бы страница. Это ровно тот класс расхождений, который HTMX-4 закрывает для разметки. ## Обработчик действия ### HTMX-7. Доменный вызов одинаков для htmx и обычного запроса **ДОЛЖЕН.** Обработчик определяет htmx-запрос по заголовку `HX-Request: true`, зовёт доменную операцию до ветвления и ветвится только на способе ответа: | № | Запрос | Ответ | |---|---|---| | HTMX-7.1 | `HX-Request: true` | фрагмент тем же партиалом (HTMX-4) по перечитанному состоянию | | HTMX-7.2 | обычный | PRG-редирект (303) | ```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) // фрагмент = тот же шаблон ``` **Почему.** Ветвление до вызова даёт две реализации одного действия, и дальше дефект воспроизводится только на одной поверхности — причём деградированный путь (HTMX-11) открывают реже, то есть чинить будут не тот. Перечитанное состояние в htmx-ветке нужно потому, что своп заменяет регион целиком: view, собранный из аргументов запроса, покажет намерение, а не результат. ### HTMX-8. Шаблон рендерится в буфер, потом в ответ **ДОЛЖЕН.** Именованный шаблон собирается целиком в буфер, и только затем буфер пишется в ответ. **Почему.** Прямая запись в ответ отправляет клиенту статус и часть разметки раньше, чем шаблон дошёл до ошибки: сообщить об отказе уже нечем, а htmx свопит в DOM полученный обрывок. Внешне это «исчезла половина региона», и причина по такому симптому не читается. ## Одно действие — два региона ### HTMX-9. Второй регион едет тем же ответом через `hx-swap-oob` **СЛЕДУЕТ.** Когда действие меняет не только свой регион, второй фрагмент отдаётся в том же ответе с `hx-swap-oob="true"` — обычным именованным партиалом с тем же `id`, что и на странице (HTMX-4, HTMX-5). **Почему.** Второй запрос с клиента вводит гонку: два ответа считают состояние в разные моменты и приезжают в произвольном порядке, поэтому панель действий может отразить состояние до действия. Плюс лишний раунд-трип на каждое действие. ### HTMX-10. Отдельный запрос за вторым регионом — когда он обновляется реже действия **ДОПУСКАЕТСЯ.** `HX-Trigger` в ответе плюс отдельный `hx-get`, если второй регион меняется не на каждое действие. **Почему.** Явное разрешение нужно, чтобы HTMX-9 не читался как запрет любого второго запроса. Когда регион обновляется редко, oob-ветка гоняет одинаковую разметку на каждое действие и связывает два шаблона там, где связи нет; гонка же тем менее наблюдаема, чем реже обновление. ## Graceful degradation ### HTMX-11. Форма действия работает без JS **ДОЛЖЕН.** Действие — обычная `