# Веб-UI на htmx Как пишется код веб-UI: частичный своп фрагментов, поллинг живых обновлений, обработчики действий, деградация без JS, ошибки. Что именно UI показывает и какие действия поддерживает — в спеках, не здесь. Форма записи — `common/language.md`. Логирование запросов — `lang/go/logging.md` (HTTP-поля, рутинно-частое на `DEBUG`). Трансляция доменных ошибок наружу — `lang/go/errors.md` (приватный канал = логи, публичный = сообщение плюс корреляционный ключ). Здесь — только специфика htmx-транспорта, без дублирования. ## Область действия Утверждения о поведении htmx относятся к **2.x**: дефолты обработки ответов между мажорами менялись. Правила описывают то, как написан код веб-UI, а не то, какие экраны и действия у приложения есть. ## Стек и границы ### R1. Стек: роутер, серверные шаблоны, htmx **ДОЛЖЕН.** UI собирается из серверных шаблонов и htmx — без шага сборки, без Node и бандлера, без реактивного фреймворка. **Почему.** Шаг сборки — это второй язык, второй менеджер зависимостей и артефакт, который расходится с исходником; приложению, где разметку целиком отдаёт сервер, он не покупает ничего. Реактивный фреймворк добавляет вторую модель состояния рядом с серверной (R2), и дальше на каждом экране приходится решать, какая из них главная. Сам htmx — вендорный ассет и живёт по правилам вендоринга (R32, R33): внешний CDN добавил бы к аптайму приложения аптайм чужого хоста. ### R2. Клиент не пересчитывает доменное состояние **НЕ ДОЛЖЕН.** Свой JS делает только то, чего серверу знать не нужно (копирование в буфер обмена и подобное); доменное состояние считает сервер, клиент свопит присланную разметку. **Почему.** Пересчёт на клиенте — вторая реализация той же логики, которую никто не сверяет: расходится она тихо, а проявляется как «на экране одно, в базе другое». Вдобавок клиентский пересчёт по определению не работает в деградированном режиме (R11, R12) — значит, серверную версию того же вычисления всё равно придётся держать. ### R3. Реактивный слой вводится отдельным решением **НЕ ДОЛЖЕН.** 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 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) // фрагмент = тот же шаблон ``` **Почему.** Ветвление до вызова даёт две реализации одного действия, и дальше дефект воспроизводится только на одной поверхности — причём деградированный путь (R11) открывают реже, то есть чинить будут не тот. Перечитанное состояние в htmx-ветке нужно потому, что своп заменяет регион целиком: view, собранный из аргументов запроса, покажет намерение, а не результат. ### R8. Шаблон рендерится в буфер, потом в ответ **ДОЛЖЕН.** Именованный шаблон собирается целиком в буфер, и только затем буфер пишется в ответ. **Почему.** Прямая запись в ответ отправляет клиенту статус и часть разметки раньше, чем шаблон дошёл до ошибки: сообщить об отказе уже нечем, а htmx свопит в DOM полученный обрывок. Внешне это «исчезла половина региона», и причина по такому симптому не читается. ## Одно действие — два региона ### R9. Второй регион едет тем же ответом через `hx-swap-oob` **СЛЕДУЕТ.** Когда действие меняет не только свой регион, второй фрагмент отдаётся в том же ответе с `hx-swap-oob="true"` — обычным именованным партиалом с тем же `id`, что и на странице (R4, R5). **Почему.** Второй запрос с клиента вводит гонку: два ответа считают состояние в разные моменты и приезжают в произвольном порядке, поэтому панель действий может отразить состояние до действия. Плюс лишний раунд-трип на каждое действие. ### R10. Отдельный запрос за вторым регионом — когда он обновляется реже действия **ДОПУСКАЕТСЯ.** `HX-Trigger` в ответе плюс отдельный `hx-get`, если второй регион меняется не на каждое действие. **Почему.** Явное разрешение нужно, чтобы R9 не читался как запрет любого второго запроса. Когда регион обновляется редко, oob-ветка гоняет одинаковую разметку на каждое действие и связывает два шаблона там, где связи нет; гонка же тем менее наблюдаема, чем реже обновление. ## Graceful degradation ### R11. Форма действия работает без JS **ДОЛЖЕН.** Действие — обычная `
`, на которую `hx-post`/`hx-target`/`hx-swap` накладываются сверху; `action` ведёт на рабочий обработчик. **Почему.** htmx может не загрузиться — ошибка вендоринга, блокировщик, медленная сеть, — и без рабочего `action` форма в этот момент не отправляет ничего, молча. Тот же `action` — единственное, что делает действие проверяемым без браузера с JS. ### R12. Фильтр, поиск и пагинация — серверные **ДОЛЖЕН.** Отбор списка задаётся 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-конфиг на клиенте, и платится она из R1 и R2. Для REST API и не-JS редиректа с `?err=` статус по-прежнему используется: там его кто-то читает. Цена решения: в логе доступа провалившееся действие выглядит как `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"` в разметке: ```html {{define "progress"}}
...
{{end}} ``` ### R18. Поллер самозавершается **ДОЛЖЕН.** Когда состояние вышло из «живого», фрагмент возвращается без `hx-*`-атрибутов. **Почему.** Иначе опрос не прекращается никогда: каждая открытая вкладка держит постоянный поток запросов за неизменными данными, и закрывает его только пользователь. Условие остановки живёт в разметке ответа, потому что это единственный канал, которым сервер управляет поллером. Встроенная альтернатива — ответ со статусом 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" ``` **Почему.** Отдельный `/fragments/…`-роут в этом случае дублирует обработчик страницы целиком — вместе с перечитыванием состояния и сборкой view, — и дальше два обработчика расходятся по тому же сценарию, что и две копии разметки (R4). Инвариант корневого `id` (R5) действует и здесь: `hx-select` выбирает тот же узел, который свопится. ## Своп и выход со страницы ### R25. Действие не уводит со страницы, если предмет остаётся на ней **НЕ ДОЛЖЕН.** Такое действие свопит свой регион на месте. **Почему.** Своп сохраняет прокрутку и не трогает серверные фильтр, поиск и пагинацию — они в query (R12). Полная навигация ради изменения одного региона возвращает пользователя в начало списка и стоит перерисовки всей страницы. Не сохраняется при свопе только контекст внутри самого заменяемого поддерева — фокус, выделение, введённый текст (R21). ### R26. Выход со страницы — форма без `hx-*` **ДОЛЖЕН.** Действие, после которого предмет покидает страницу, остаётся обычной POST-формой без htmx-атрибутов, то есть полной навигацией. **Почему.** Своп для такого действия оставил бы на месте регион, описывающий объект, которого на странице больше нет. Отсутствие htmx-атрибутов при этом само работает маркером «это выход»: намерение видно прямо в разметке, и не нужен `HX-Redirect` — то есть ещё один способ сменить страницу, существующий только на htmx-пути. ### R27. Асинхронное действие свопит промежуточное состояние **ДОЛЖЕН.** Если работу доделывает воркер, ответ на действие показывает промежуточное состояние, а итог догоняет самозавершающийся поллер (R18). **Почему.** Мнимый результат расходится с сервером до следующего тика, и всё это время пользователь принимает решения по несуществующему исходу — включая повтор действия, которое на самом деле выполняется. Промежуточное состояние вдобавок объясняет, почему регион продолжает обновляться сам. ## Различение поверхности одного действия ### R28. Поверхность различается скрытым полем формы **ДОЛЖЕН.** Когда один роут зовут с разных страниц и своп-ответ различается фрагментом, поверхность передаётся явным скрытым полем (`surface=list|detail`), а не выводится из `HX-Target` или `Referer`. **Почему.** `HX-Target` — свойство разметки вызывающей страницы, `Referer` может не прийти вовсе; и то и другое меняется без участия обработчика, и ответ начинает приходить не тем фрагментом. Поле же видно в форме рядом с действием, поэтому связь «эта страница → этот фрагмент» читается там, где её заводят. ## Статика, вендоринг, кэш Раздел не про htmx — это упаковка любого server-rendered приложения; разъедется в языковой слой, когда понадобится там. ### 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 просто не отвечает.