HTML over WebSockets:用极简 JavaScript 构建实时 SPA
构建 SPA(单页应用)像是在拼一幅复杂的拼图:前端用 JavaScript 框架绘制界面,后端提供 JSON 接口,两个独立代码库还得通过契约互相理解对方。这套模式早已被业界广泛接受并标准化。但"标准"并不意味着"唯一"。我想向你介绍另一种思路,它并不新颖,却在这些年中逐渐受到关注:HTML over WebSockets。
核心思路很简单:服务端不再发送 JSON 让浏览器去拼装 HTML,而是直接把构建好的 HTML 推过来,客户端只负责把内容放到该放的位置。所有渲染逻辑都留在后端,用同一种语言完成,不需要契约,也不需要 API。这种模式被称为 hypermedia(超媒体)或 HTML over the wire(HTML 直传)。至于 HTML 如何传输,决定了通信的延迟和双向性,由此衍生出三种变体:
- 基于 HTTP,每次请求一次响应,代表项目有 htmx 和 Unicorn。
- 基于 SSE,建立一条由服务端到客户端的单向持续通道,代表项目是 Datastar。
- 基于 WebSockets,建立一条永久的双向通道,代表项目有 Phoenix LiveView 和 Django LiveView。
传输通道的选择至关重要,它直接决定了应用的架构与通信模式。
本文将重点讨论 HTML over WebSockets——这一家族中支持实时双向通信的变体。它让你用极少的 JavaScript、单一语言、无需契约、单一渲染引擎,就能构建一个 SPA。我们将一起了解它是什么、如何运作,以及在什么场景下,相比 HTTP 或 SSE 方案它更值得选用。
起源
Chris McCord 是 Phoenix(Elixir 生态中最流行的框架)的作者,他在 ElixirConf 2019 上展示了一项名为 LiveView 的技术。仅用 15 分钟,他就搭建出了一个实时运行的 Twitter 克隆,无需编写任何用于渲染的 JavaScript,也没用 React、Angular、Vue 等主流前端框架来管理视图。这证明了你可以留在后端,同时保持高效开发和不错的性能。此后,这套方案逐渐流行开来,激发了其他开发者用 其他语言 实现 HTML-over-WebSockets。你可以回归后端,却不必放弃前端那些好的体验。
它是如何工作的?
虽然乍看之下并非如此,但客户端确实用到了 JavaScript。它的职责不是渲染,而是与服务器建立 WebSocket 通信通道,并把收到的 HTML 放到正确的位置。此外还承担一些辅助工作,比如动画、事件处理等。
McCord 的思路是 不向前端发送 JSON,而是直接发送 HTML,这样前端就无需再做预处理。如此一来,渲染工作及其全部逻辑就被搬到了后端。可是……怎么让服务器主动、即时地把新内容推给我们,而不用我们自己去请求呢?答案很简单:WebSocket。
先回顾一下开篇提到的传统流程:在浏览器中发起 HTTP 请求,服务端处理后返回一个包含所有原始数据的 JSON,接着浏览器解析 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 的无状态设计相反。
- 直接对接数据库,无需 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 作为 channel layer)。不过,真正的瓶颈只有在大量客户端同时在线时才会显现,而合理的设计可以缓解这一点。我的网站在类似 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 |
| Ruby | Live / Lively(socketry) | WebSocket | 是 | 在维护(v0.18,2026),小众:纯 WebSocket + morphdom,非 Rails 生态,以演示为导向 |
| 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,Laravel 自家的 WebSocket 服务(2024) |
| 语言无关(JS) | htmx | HTTP + WS/SSE 扩展 | 是(扩展) | 2.0 |
| 语言无关(JS) | Datastar | SSE | 是 | 1.0 |
SSE:廉价的选项
WebSockets 虽然强大,但为每个客户端维持一条双向连接是有成本的。而且很多时候你并不需要它:如果流量主要是服务端到客户端(通知、实时信息流、仪表盘、AI 回复的逐字输出),Server-Sent Events(SSE) 就够了。它的思路是一样的——在链路上发送现成的 HTML——但走的是一条普通的单向 HTTP 连接。
这是最省事的方案:基础设施也最简单。由于不需要为每个客户端维持有状态的进程,负载均衡和横向扩展都更容易。
不过它也有局限:
- 单向。只有服务端能主动推送。客户端想发送内容时没有对应的通道,必须另外发起一次 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,自带断线重连,服务端通过 text/event-stream 发送 HTML 片段。和本文是同一套理念,只是换了传输方式。同类思路的还有 Datastar,它在 SSE 之上统一了 Alpine 风格的响应式。
简单判断标准:需要双向、低延迟通信(聊天、协同、游戏),选 WebSocket;只需要服务端主动推送,SSE 更简单、运维成本更低。
写在最后
HTML over WebSockets 并非万能解,这些技术也都没有银弹。传输方式取决于你的问题:需要实时双向交互(聊天、实时面板、协同场景),用 WebSockets;只需要服务端推送,用 SSE;普通的请求-响应就够,用 htmx over HTTP。
每个项目都有自身的背景与约束。如果只能带走一句话,那就是核心思路:传输 HTML 而非 JSON,坚守单一语言,让 API、契约和半数前端代码从你的待办清单上消失。
相信好的架构,而不是流行的框架或模式。
参考资料
- Phoenix LiveView,官方文档:经典模式,服务器端持有每个客户端的状态,通过 WebSocket 发送差异更新。
- Phoenix LiveView 1.0 发布,Phoenix 博客:1.0 里程碑(2024 年 12 月),距首次提交已有六年。
- Hotwire,官网:"HTML Over The Wire" 一词的来源,以及 Turbo 主要基于 HTTP 运行的原因。
- htmx 文档:基于 HTTP 的超媒体方案,明确采用无状态设计,WebSockets 和 SSE 仅作为扩展提供。
- Turbo 手册:页面刷新,介绍 Turbo 8 通过morphing 仅更新变化部分,同时保留滚动位置和焦点。
- idiomorph,被多个方案采用的 DOM morphing 库。
- Datastar,押注 SSE 而非 WebSockets 的超媒体框架。
- 使用 server-sent events,MDN:SSE 的工作方式(`EventSource`、自动重连、`Last-Event-ID`、`text/event-stream`)。
- Laravel Reverb,Laravel 官方出品的 WebSocket 服务器(2024 年):证明 PHP 也能在不依赖第三方扩展的情况下实现实时功能。
- ASP.NET Core Blazor 渲染模式,Microsoft Learn:Interactive Server 模式基于 SignalR(WebSockets)运行。
本作品采用 署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可。
我在本文中是如何使用 AI 的?想读那些我从不发表的内容吗?
我会发送那些不适合放进文章里的东西:完整的配置、零散的笔记,以及我当下正在测试的各种内容。不发垃圾邮件,不搞销售套路。
给 newsletter@andros.dev 发一封邮件,主题写上 SUBSCRIBE,就能加入。