Создание SPA (Single-page Application) — сложная головоломка: JavaScript-фреймворк, отрисовывающий представление, API, отдающий JSON, и две независимые кодовые базы, вынужденные понимать друг друга через контракты. Это устоявшийся, профессионализированный сценарий. Но то, что он стандарт, не делает его единственным способом. Есть другой подход — не новый, но набирающий популярность в последние годы: HTML over WebSockets.
Идея в следующем: вместо отправки JSON и сборки HTML в браузере сервер отправляет уже готовый HTML, а клиенту остаётся лишь разместить его на нужном месте. Вся логика рендеринга остаётся на бэкенде, на одном языке, без необходимости в контрактах или API. Этот паттерн известен как hypermedia или HTML over the wire. Способ доставки HTML определяет задержку и двунаправленность коммуникации. Существует три варианта:
- Через HTTP, запрос за запросом, как в htmx или Unicorn.
- Через SSE, открывая односторонний непрерывный канал от сервера к клиенту, как в Datastar.
- Через WebSockets — постоянный двунаправленный канал, как в Phoenix LiveView или Django LiveView.
Канал настолько важен, что определяет архитектуру приложения и паттерн его коммуникации.
Речь пойдёт о HTML over WebSockets — real-time и двунаправленном варианте этого семейства. Том самом, который позволяет строить SPA почти без JavaScript, на одном языке, без контрактов и с единым движком рендеринга. Разберём, что это такое, как это работает и когда себя оправдывает по сравнению с HTTP- или SSE-родственниками.
Происхождение
Крис Маккорд, создатель Phoenix (самого популярного фреймворка в экосистеме Elixir), представил на ElixirConf 2019 технологию под названием LiveView. За 15 минут он построил клон Twitter, работающий в реальном времени, не добавив ни строчки рендерящего JavaScript и не подключив ни одного популярного фреймворка (React, Angular, Vue...) для управления представлением — доказав, что можно оставаться на бэкенде и быть продуктивным, сохраняя при этом хорошую производительность. С тех пор решение набрало популярность, вдохновив других разработчиков строить реализации HTML-over-WebSockets на других языках. Появилась возможность вернуться к бэкенду, не отказываясь от хороших сторон фронтенда.
Как это работает?
Даже если на первый взгляд это не так очевидно, JavaScript на клиенте всё же используется. Его задача — не рендеринг, а создание канала связи через WebSockets и размещение полученного HTML в нужном месте. Плюс второстепенные задачи вроде анимаций, обработки событий и так далее.
Решение Маккорда заключается в том, чтобы отправлять фронтенду не JSON, а HTML, не требующий предварительной обработки. Так нагрузка по рендерингу, вместе со всей своей логикой, переносится на бэкенд. Но как заставить сервер немедленно присылать новый контент без запроса со стороны клиента? Легко — с помощью WebSockets.
Рассмотрим традиционную систему из введения. С веб-страницы делается HTTP-запрос, браузер инициирует действие и получает в ответ JSON со всей сырой информацией. Следующий шаг — интерпретировать его и построить соответствующий HTML.
sequenceDiagram
participant C as Browser
participant S as Server
C->>S: 1. HTTP request (GET /api/article/2/) and maybe auth
S->>S: 2. Query the DB
S->>S: 3. Build a JSON with the article data
S-->>C: 4. Return JSON
C->>C: 5. Parse the JSON
C->>C: 6. Build the HTML with its rendering engine
При использовании HTML over WebSockets тот же самый запрос идёт через постоянный канал, а ответом сразу является собранный HTML, без промежуточного JSON. И поскольку канал никогда не закрывается, сервер даже может действовать на опережение и отправлять изменения без запроса со стороны клиента.
Поток данных с WebSockets выглядит следующим образом, если не учитывать первоначальное подключение и аутентификацию, которые происходят только один раз при открытии канала:
sequenceDiagram
participant C as Browser
participant S as Server (Back-End)
C->>S: 1. Sends a text: "I want /article/2/"
S->>S: 2. Query the DB
S->>S: 3. Render HTML with its template engine
S-->>C: 4. Return the assembled HTML/CSS/JS
"... "
C->>C: 5. Place the HTML where it belongs
Просто, элегантно и быстро. Клиент занимается размещением HTML в нужном месте и прослушиванием событий. Сервер делает всё остальное. Не нужно заботиться о состоянии клиента или логике рендеринга — всё это находится на бэкенде.
Полный, более сложный цикл, включающий открытие соединения и аутентификацию, выглядел бы так:
sequenceDiagram
participant C as Browser
participant S as Server (Back-End)
C->>S: 1. Opens WebSocket connection and authenticates
Note over C,S: A single persistent channel
C->>S: 2. Sends a text: "I want /article/2/"
S->>S: 3. Query the DB
S->>S: 4. Render HTML with its template engine
S-->>C: 5. Return the assembled HTML/CSS/JS
"... "
C->>C: 6. Place the HTML where it belongs
Note over S,C: The server can also push
changes without the client asking (broadcast)
Помимо этого, сама архитектура даёт ряд встроенных преимуществ по сравнению с другими решениями.
Какие у этого преимущества?
- Существует только один движок рендеринга, что снижает сложность.
- Не нужно строить API: сервер генерирует HTML и отправляет его клиенту напрямую, без посредника.
- Состояние живёт на сервере. Это не запрос-ответ без памяти: на каждого подключённого клиента есть процесс, который помнит, где тот находится. Это противоположность htmx, который намеренно stateless.
- Прямое подключение к базе данных, без JSON или GraphQL посередине.
- Настоящий real-time: клиенты получают изменения максимально быстро, без опроса сервера.
- Broadcast: сервер может разослать изменения всем подключённым клиентам одновременно. Построение чата, дашборда или мультиплеерной игры получается «бесплатно».
- Меньше трафика и меньше задержки на действие: одно постоянное соединение позволяет не повторять TCP-рукопожатие и HTTP-заголовки при каждом взаимодействии. Дело не в том, что «протокол WebSocket магически быстрее» (HTTP/2 и HTTP/3 сильно сократили этот разрыв в модели запрос-ответ), а в том, что пропускается лишний обмен и сразу отправляется собранный HTML.
- Можно построить SPA почти без JavaScript, без тяжёлых фреймворков вроде React, Angular или Vue.
- Приемлемое SEO: поскольку HTML рендерится на сервере, первая загрузка индексируется. Однако стоит помнить, что краулер не увидит обновлений, приходящих позже по WebSocket, поэтому важный контент должен быть уже в этом первом ответе.
- Безопаснее в плане инъекций: поскольку сервер рендерит и экранирует HTML перед отправкой по каналу, попытка подсунуть
<script>доходит как инертный текст и попадает на экран соседа в виде обычных букв, а не кода. Та же самая архитектура, которая делает чат тривиальным, делает его защищённым от XSS.
А какие недостатки?
- Серверу требуется больше ресурсов: он держит открытым WebSocket и, как правило, состояние каждого клиента в памяти. Горизонтальное масштабирование вынуждает делиться этим состоянием (в Django — через Channels + ASGI-сервер + Redis в качестве channel layer). Впрочем, реальная проблема проявляется только при большом количестве одновременных клиентов, и продуманный дизайн способен её смягчить. Собственный сайт автора выдерживал пики в 600 одновременных читателей без проблем, работая на железе уровня Raspberry Pi 3 вместе с другими сервисами.
- Задержка: при значительной физической задержке ощущение «мгновенности» страдает.
- Не работает офлайн. Если соединение обрывается, сайт перестаёт работать. Приходится продумывать опыт переподключения и отказоустойчивость.
- Более крутая начальная кривая обучения, чем просто подключить
<script>: запуск WebSocket-сервера — задача нетривиальная, и придётся освоить работу с паттерном LiveView.
Текущий ландшафт: какие фреймворки существуют?
У hypermedia-движения уже есть реализация почти на каждом языке. Обратите внимание на колонку transport: решения на WebSocket (паттерн LiveView, real-time и двунаправленный) сосуществуют с HTTP- и SSE-родственниками — для случаев, когда двусторонний канал не нужен.
| Язык | Фреймворк | Транспорт | Server push? | Статус |
|---|---|---|---|---|
| Elixir | Phoenix LiveView | WebSocket | Да | Зрелый (1.x, LiveView 1.0 в декабре 2024) |
| Ruby | Hotwire (Turbo + Stimulus) | HTTP + WebSocket/SSE (Streams) | Да | Turbo 8 с morphing |
| Python / Django | Django LiveView | WebSocket | Да | Активный (проект автора) |
| Python / Django | Reactor | WebSocket | Да | Активный |
| Python / Django | djust | WebSocket | Да | Новый, с VDOM на Rust |
| Python / Django | django-unicorn | HTTP / AJAX | Нет | Активный |
| Python / Django | Tetra | AJAX + WebSocket | Да | Молодой, на Alpine.js |
| C# / .NET | Blazor (Interactive Server) | WebSocket (SignalR) | Да | .NET 9, с render modes |
| PHP / Laravel | Livewire 3 + Reverb | WebSocket | Да | Reverb, собственный WebSocket-сервер Laravel (2024) |
| Агностик (JS) | htmx | HTTP + WS/SSE-расширения | Да (расширение) | 2.0 |
| Агностик (JS) | Datastar | SSE | Да | 1.0 |
SSE, дешёвый вариант
WebSockets — мощный инструмент, но держать открытым двунаправленный канал на каждого клиента стоит недёшево. И зачастую это не нужно: если поток данных идёт в основном от сервера к клиенту (уведомления, живая лента, дашборд, токены ответа от AI), достаточно Server-Sent Events (SSE). Идея та же — отправка готового HTML по проводам, но по обычному HTTP-каналу, идущему только в одну сторону.
Это дешёвый вариант: самая простая инфраструктура. Без stateful-процесса на каждого клиента проще балансировать нагрузку и масштабироваться.
Однако есть ограничения:
- Односторонность. Данные проталкивает только сервер. Если клиент хочет что-то отправить, для этого нет канала — приходится делать отдельный HTTP-запрос.
- Только текст. Передаётся UTF-8, без бинарных данных (в отличие от WebSocket).
- Хуже для интенсивного двустороннего обмена. В чате, совместном редактировании или игре такой рыхлый обмен по HTTP обходится дороже, чем постоянно открытый WebSocket.
У htmx есть почти идентичная по духу реализация — SSE-расширение. Канал объявляется атрибутом, и HTML, приходящий в каждом событии, размещается сам:
<div hx-ext="sse" sse-connect="/updates" sse-swap="message">
Real-time content appears here
</div>
Под капотом используется встроенный в браузер EventSource, с переподключением «из коробки», а сервер отправляет HTML-фрагменты через text/event-stream. Та же философия, что и в этой статье, просто с другим транспортом. В том же ключе работает Datastar, объединяющий реактивность в стиле Alpine с SSE.
Быстрое правило: если нужна двунаправленная связь с низкой задержкой (чат, совместная работа, игры) — WebSocket; если нужен только push от сервера — SSE проще и дешевле в эксплуатации.
Заключительные замечания
HTML over WebSockets — не ответ на всё, как и ни одна из этих технологий. Транспорт диктуется задачей: если нужен real-time обмен в обе стороны (чат, живая панель, что-то совместное) — WebSockets; если нужен только push от сервера — SSE; если достаточно модели запрос-ответ — htmx поверх HTTP.
Каждый проект — свой собственный мир, со своими особенностями и ограничениями. Если и стоит вынести из этого материала одну мысль, пусть это будет она: отправлять HTML вместо JSON, оставаться в рамках одного языка и вычеркнуть из списка задач API, контракты и половину фронтенда.
Стоит доверять хорошей архитектуре, а не модным фреймворкам или паттернам.
Источники
- Phoenix LiveView, официальная документация: канонический паттерн с состоянием клиента на сервере и диффами, отправляемыми по WebSocket.
- Phoenix LiveView 1.0 released, блог Phoenix: релиз версии 1.0 (декабрь 2024), спустя шесть лет после первого коммита.
- Hotwire, официальный сайт: откуда пошло название «HTML Over The Wire» и почему Turbo работает в основном поверх HTTP.
- Документация htmx: hypermedia поверх HTTP, намеренно stateless, с WebSockets и SSE только через расширения.
- Turbo Handbook: Page Refreshes — о morphing в Turbo 8, обновляющем только изменившееся и сохраняющем scroll и фокус.
- idiomorph, библиотека DOM-morphing, которую используют несколько из этих решений.
- Datastar, hypermedia-фреймворк, делающий ставку на SSE вместо WebSockets.
- Using server-sent events, MDN: как работает SSE (
EventSource, автоматическое переподключение,Last-Event-ID,text/event-stream). - Laravel Reverb, собственный WebSocket-сервер Laravel (2024): доказательство того, что PHP тоже умеет real-time без сторонних расширений.
- ASP.NET Core Blazor render modes, Microsoft Learn: режим Interactive Server работает поверх SignalR (WebSockets).