基于WebSocket的HTML:几乎不需要JavaScript的实时SPA
摘要
本文介绍了HTML over WebSockets模式,用于以极少的JavaScript构建实时SPA,并与HTTP和SSE变体进行了比较,同时追溯了其源自Phoenix LiveView的历史。
暂无内容
查看缓存全文
缓存时间: 2026/08/12 17:21
# HTML over WebSockets:几乎不用 JavaScript 的实时 SPA | Andros Fenollosa
来源:https://en.andros.dev/blog/ef4968f5/html-over-websockets-real-time-spas-with-barely-any-javascript/
构建单页应用(SPA)是一个复杂的难题:一个负责绘制视图的 JavaScript 框架、一个提供 JSON 的 API,以及两个被迫通过契约来理解彼此的独立代码库。这是一种被接受且专业化的场景。但成为标准并不意味着它是唯一的方式。我想向你展示另一种方法,它并不新鲜,但近年来获得了越来越多的关注:**HTML over WebSockets**。其思路是:服务器不再发送 JSON 并让浏览器拼装 HTML,而是直接发送已经构建好的 HTML,客户端只需将其放到正确的位置。所有渲染逻辑都留在后端,使用单一语言,无需契约或 API。这种模式被称为 **hypermedia** 或 **HTML over the wire**。HTML 的传输方式很重要,因为它决定了通信的**延迟**和**双向性**。有三种变体:
- 通过 **HTTP**,逐请求进行,例如 htmx (https://htmx.org/) 或 Unicorn (https://django-unicorn.com/)。
- 通过 **SSE**,打开一条从服务器到客户端的单向持续通道,例如 Datastar (https://data-star.dev/)。
- 通过 **WebSockets**,一条永久、双向的通道,例如 Phoenix LiveView (https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html) 或 Django LiveView (https://django-liveview.andros.dev/)。
通道非常重要,它决定了应用程序的架构及其通信模式。在这篇文章中,我要讨论的是 **HTML over WebSockets**:该家族中**实时且双向**的变体。它让你可以用几乎不需要 JavaScript 的方式构建一个 SPA,单一语言,没有契约,只有一个渲染引擎。我们将看到它是什么、如何工作,以及与它的 HTTP 或 SSE 近亲相比,何时值得使用。
## 起源
**Chris McCord**,Phoenix(Elixir 生态中最流行的框架)的创建者,在 **ElixirConf 2019** 上展示了一项名为 LiveView (https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html) 的技术。在 15 分钟内,他构建了一个实时运行的 Twitter 克隆 (https://www.youtube.com/watch?v=MZvmYaFkNJI),**没有添加任何渲染 JavaScript** 或流行的框架(React、Angular、Vue……)来管理视图,证明了你可以留在后端,并且借助一丝良好的性能保持高效。此后该解决方案越来越流行,**激励了其他开发者**在**其他语言**中构建 HTML-over-WebSockets 的实现。你可以回到后端,而不必放弃前端的优点。
## 它是如何工作的?
尽管乍一看可能不是这样,但客户端确实使用了 JavaScript。它的工作不是渲染,而是通过 WebSockets 创建一条通信通道,并将接收到的 HTML 放到正确的位置。此外还有一些次要任务,如动画、事件处理等。McCord 的解决方案**不是向前端发送 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 正好相反,htmx 是刻意**无状态**的。
- **直接连接数据库**,没有 JSON 或 GraphQL 中间层。
- **真正的实时**:客户端尽可能快地收到变更,无需轮询服务器。
- **广播**:服务器可以一次向所有已连接的客户端推送变更。构建聊天室、仪表板或多人在线游戏简直是举手之劳。
- 每次操作更少的流量和更低的延迟:一条持久连接避免了在每次交互时重复 TCP 握手和 HTTP 头。不是“WebSocket 协议神奇地更快”(HTTP/2 和 HTTP/3 在请求-响应方面已经大大缩小了这一差距),而是**你跳过了往返,并直接发送组装好的 HTML**。
- 构建**几乎不需要 JavaScript 的 SPA**,无需 React、Angular 或 Vue 等重型框架。
- 合理的 **SEO**:由于 HTML 在服务器端渲染,首次加载是可索引的。不过要注意,爬虫看不到之后通过 WebSocket 到达的更新,因此重要内容必须包含在首次响应中。
- **更安全,可防御注入**:因为服务器在通过通道发送 HTML 之前会渲染并转义它,所以试图混入的 `<script>` 会作为惰性文本传输,并以普通字母而非代码的形式到达你邻居的屏幕上。这种让聊天变得微不足道的架构,同时也使其免受 XSS 攻击。
## 它的缺点是什么?
- 服务器需要**更多资源**:它需要保持 WebSocket 打开,并且通常需要**在内存中保存每个客户端的状态**。水平扩展迫使你共享该状态(在 Django 中,使用 **Channels + ASGI 服务器 + Redis** 作为通道层)。话虽如此,真正的问题只在大量同时在线客户端时才会显现,而且精心设计可以缓解它。我的网站在类似于 Raspberry Pi 3 的硬件上、同时运行其他服务的情况下,处理了 600 个同时在线读者的峰值而没有出现问题。
- **延迟**:在物理延迟很高的情况下,“即时”的体验会受到影响。
- **无法离线工作**。如果连接断开,网站就会停止工作。你必须设计重连体验和容错机制。
- **初始学习曲线更陡峭**,比引入一个 `<script>` 要高:运行 WebSocket 服务器并非易事,你还必须学习处理 LiveView 模式。
## 当前格局:有哪些框架?
超媒体运动在几乎每种语言中都已经有了实现。请看**传输**列:通过 WebSocket 运行的那些(LiveView 模式,实时且双向)与 HTTP 和 SSE 近亲并存,适用于你不需要那种双向通道的场景。你可以从这里开始:
| 语言 | 框架 | 传输 | 服务器推送? | 状态 |
|---|---|---|---|---|
| Elixir | Phoenix LiveView | WebSocket | 是 | 成熟(1.x,LiveView 1.0 于 2024 年 12 月发布) |
| Ruby | Hotwire (Turbo + Stimulus) | HTTP + WebSocket/SSE (Streams) | 是 | Turbo 8,支持 *morphing* |
| Python / Django | Django LiveView | WebSocket | 是 | 活跃(我的项目) |
| Python / Django | Reactor | WebSocket | 是 | 活跃 |
| Python / Django | djust | WebSocket | 是 | 较新,使用 Rust VDOM |
| 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 (https://reverb.laravel.com/),Laravel 自己的 WebSocket 服务器(2024) |
| 通用(JS) | htmx | HTTP + WS/SSE 扩展 | 是(扩展) | 2.0 |
| 通用(JS) | Datastar | SSE | 是 | 1.0 |
## SSE,廉价选项
WebSockets 很强大,但为每个客户端保持一条双向通道是有成本的。而且通常你并不需要它:如果流程主要是**服务器到客户端**(通知、实时动态、仪表板、AI 响应的 token),那么**服务器发送事件(SSE)** 就足够了。这是同样的思路,通过线路发送现成的 HTML,但是通过一条只单向流动的普通 HTTP 通道。它是廉价选项:基础设施最简单。由于不为每个客户端保持有状态进程,因此更容易负载均衡和扩展。然而,它也有局限性:
- **它是单向的。**只有服务器可以推送。如果客户端想要发送什么,没有对应的通道:它必须发起单独的 HTTP 请求。
- **仅支持文本。**它承载 UTF-8,不支持二进制(WebSocket 支持)。
- **不适合繁重的双向工作。**在聊天、协作编辑或游戏中,那种通过 HTTP 进行的松散往返比始终打开的 WebSocket 更耗费资源。
htmx 在精神上有一个几乎相同的实现,即它的 SSE 扩展 (https://htmx.org/extensions/sse/)。你用一个属性声明通道,每个事件中到达的 HTML 会自动放置:
```
<div hx-ext="sse" sse-connect="/chat" sse-swap="message">
实时内容显示在这里
</div>
```
在底层,它使用浏览器原生的 `EventSource`,自带重连功能,服务器通过 `text/event-stream` 发送 HTML 片段。这与本文的哲学相同,只是更换了传输方式。沿着同样思路的还有 Datastar (https://data-star.dev/),它通过 SSE 统一了 Alpine 风格的反应式。
快速规则:如果你需要**双向**、低延迟的通信(聊天、协作、游戏),用 WebSocket;如果你只是**从服务器推送**,SSE 更简单、运维成本更低。
## 最后的话
HTML over WebSockets 并不是万能答案,这些技术中也没有哪个是。传输方式由你的问题决定:如果你需要实时的来回通信(聊天、实时面板、协作性内容),用 WebSockets;如果你只是从服务器推送,用 SSE;如果请求-响应就够了,用基于 HTTP 的 htmx。每个项目都有自己的世界,有自己的特殊之处和限制。
如果你只记住一件事,那就是底层的理念:发送 HTML 而不是 JSON,留在单一语言中,把 API、契约和一半的前端从你的清单上划掉。相信好的架构,而不是追逐潮流的框架或模式。
## 来源
- Phoenix LiveView (https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html),官方文档:规范模式,服务器端保存每个客户端的状态,并通过 WebSocket 发送 diff。
- Phoenix LiveView 1.0 released (https://phoenixframework.org/blog/phoenix-liveview-1.0-released),Phoenix 博客:1.0 里程碑(2024 年 12 月),距离第一次提交已六年。
- Hotwire (https://hotwired.dev/),官方网站:“HTML Over The Wire”这个名称的出处,以及为什么 Turbo 主要在 HTTP 上运行。
- htmx docs (https://htmx.org/docs/):基于 HTTP 的超媒体,刻意保持*无状态*,仅通过扩展支持 WebSockets 和 SSE。
- Turbo Handbook: Page Refreshes (https://turbo.hotwired.dev/handbook/page_refreshes),关于 Turbo 8 的 *morphing*,只更新发生变化的部分并保留滚动位置和焦点。
- idiomorph (https://github.com/bigskysoftware/idiomorph),多个上述解决方案使用的 DOM *morphing* 库。
- Datastar (https://data-star.dev/),押注 SSE 而非 WebSockets 的超媒体框架。
- Using server-sent events (https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events),MDN:SSE 如何工作(`EventSource`、自动重连、`Last-Event-ID`、`text/event-stream`)。
- Laravel Reverb (https://reverb.laravel.com/),Laravel 官方 WebSocket 服务器(2024):证明 PHP 无需第三方扩展也能实现实时功能。
- ASP.NET Core Blazor render modes (https://learn.microsoft.com/en-us/aspnet/core/blazor/components/render-modes),Microsoft Learn:Interactive Server 模式通过 SignalR(WebSockets)运行。
相似文章
HTMX 太酷了,我自己写了一个
本文探讨了 HTMX 这个库,它偏爱服务器端渲染的 HTML,而非大量使用 JavaScript 的前端。作者发现 HTMX 对于持久化的 UI 组件很有用,但最终出于个人对最小依赖的偏好,决定自己实现一套解决方案。
HTML 的新能力
文章重点介绍了新的 HTML 特性,如 <dialog>、弹出框(popovers)和分组 <details>,这些特性无需 JavaScript 即可实现动态功能,展示了 Web 标准和浏览器兼容性的进步。
为什么选择原生JavaScript
一篇讨论不使用库或框架的原生JavaScript的优点和用例的文章。
现代前端复杂性:本质的还是偶然的?
本文分析了现代前端开发为何日趋复杂,回顾了从静态 HTML 文档、AJAX 到基于 React、Vue、Angular 和 Svelte 等框架的单页应用(SPA)的演进历程,并探讨这种复杂性属于本质复杂度还是偶然复杂度。
Show HN: Nectar——一个类 Rust 的 React,编译为 WebAssembly
Nectar 是一个新型 Web 框架,将类 Rust 代码编译为 WebAssembly,通过 O(1) 信号更新和零依赖构建消除了 JavaScript 依赖。