Создание 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 WebSocketsreal-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).