传统 SPA 把服务器返回的数据当作 JSON,再交给浏览器中的 JavaScript 框架生成 DOM。HTML over WebSockets 则反过来:服务器负责生成 HTML 片段,并通过长连接把界面变化推送给浏览器。这样做可以减少前后端之间的状态转换,让一个实时页面更接近“服务器输出页面,浏览器展示页面”的模型。
这里的“无需编写 JavaScript”需要准确理解:业务代码可以不写 JavaScript,但浏览器仍需要一个很小的 WebSocket 客户端运行时来接收消息、触发 DOM 更新。下面使用 htmx 的 WebSocket 扩展作为这个运行时;应用本身只维护 Python、HTML 和模板。
为什么实时页面适合 HTML 推送
在传统 SPA 中,一次状态变化通常要经过几层转换:
- 后端定义 JSON 响应。
- 前端 API 客户端解析 JSON。
- 状态管理层更新数据。
- 组件重新渲染 DOM。
- 还要处理加载、错误、重连和局部更新。
这套链路适合复杂的客户端交互,但对于监控面板、聊天室、任务队列、后台通知等页面,很多变化本质上只是“把一小段新 HTML 放到页面上”。服务器已经拥有业务状态和模板,直接发送最终 HTML 可以减少重复的数据模型和渲染逻辑。
这种架构尤其适合以下场景:
- 页面以表单、列表、表格和通知为主。
- 状态主要由服务器管理。
- 需要服务端主动推送,而不是依赖轮询。
- 团队希望降低前端构建、状态同步和类型映射的复杂度。
它不等于所有应用都应该放弃前端框架。高度依赖离线能力、复杂拖拽、WebGL 或大量本地计算的产品,仍然更适合把更多状态放在浏览器。
连接传输的是界面片段
可以把一次推送想象成一个很小的服务器端事件。服务器不发送:
{"type":"job_updated","id":42,"status":"done"}
而是发送已经能够被页面使用的 HTML:
<li id="job-42" hx-swap-oob="true">
<strong>备份任务</strong>
<span>已完成</span>
</li>
客户端运行时只负责接收消息,并依据声明式属性把片段放到目标位置。业务代码不需要为每一种消息再写一个 JSON 类型、解析器和渲染函数。
但这也带来一个重要边界:HTML 是表现层协议,不是稳定的数据协议。客户端和服务器必须同时理解元素 id、交换策略以及模板结构。若多个版本的客户端会长期并存,需要通过消息版本、兼容模板或明确的升级策略控制风险。
一个可运行的最小示例
下面的示例使用 Python websockets 提供一个页面和 WebSocket 服务,使用 htmx WebSocket 扩展处理连接。应用代码没有自定义 JavaScript;页面加载扩展脚本后,会自动把服务端推送的片段追加到事件列表。
先安装依赖:
python -m venv .venv
. .venv/bin/activate
pip install websockets
python server.py
将下面内容保存为 server.py:
import asyncio
from datetime import datetime, timezone
from http import HTTPStatus
from pathlib import Path
import websockets
HTML = """<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>实时任务</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://unpkg.com/htmx-ext-ws@2.0.2/ws.js"></script>
</head>
<body>
<h1>任务事件</h1>
<ul id="events" hx-ext="ws" ws-connect="/events"></ul>
</body>
</html>"""
async def page(connection):
request = await connection.recv()
if request.startswith("GET / "):
body = HTML.encode()
response = (
f"HTTP/1.1 {HTTPStatus.OK.value} OK\r\n"
"Content-Type: text/html; charset=utf-8\r\n"
f"Content-Length: {len(body)}\r\n"
"Connection: close\r\n\r\n""
).encode() + body
await connection.send(response)
async def events(websocket):
for number in range(1, 4):
timestamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
fragment = (
f"<li hx-swap-oob=\"beforeend:#events\">"
f"{timestamp} - 任务 {number} 已完成</li>"
)
await websocket.send(fragment)
await asyncio.sleep(1)
await websocket.close()
async def handler(websocket):
request = await websocket.recv()
if request.startswith("GET /events "):
await events(websocket)
elif request.startswith("GET / "):
body = HTML.encode()
await websocket.send(
b"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n"
+ f"Content-Length: {len(body)}\r\nConnection: close\r\n\r\n".encode()
+ body
)
async def main():
async with websockets.serve(handler, "127.0.0.1", 8765):
print("Open http://127.0.0.1:8765")
await asyncio.Future()
asyncio.run(main())
这个示例刻意保持简单,重点是消息形态:服务器生成 <li>,并通过 hx-swap-oob 声明它应该被追加到 #events。生产环境应使用成熟的 HTTP/WebSocket 集成方式,例如 ASGI 框架,而不是在同一个低层连接处理器里手写 HTTP 协议。
更接近生产代码的发送逻辑可以独立出来:
def event_html(label: str, status: str) -> str:
# 实际项目中应使用模板引擎进行 HTML 转义。
return (
'<li hx-swap-oob="beforeend:#events">'
f'<strong>{label}</strong>:{status}'
'</li>'
)
await websocket.send(event_html("导入订单", "已完成"))
这里的注释非常重要:不要把未经转义的用户输入直接插入 HTML。HTML 推送把 XSS 防护责任更明确地放到了服务端模板层,但并不会自动消除风险。
连接管理比渲染更难
实时页面的核心难题通常不是生成一段 <div>,而是连接生命周期和消息投递。至少要处理以下问题:
重连与重复消息
移动网络、代理和浏览器休眠都可能中断 WebSocket。重连后,客户端可能漏掉消息,也可能因为服务端重复发送而看到同一条消息两次。可以给每个事件分配递增 id,并让客户端重连时携带最后处理的 id;服务端据此补发未确认的片段。
多实例广播
单进程中保存一个连接集合很容易,但扩容到多个实例后,产生事件的实例未必持有目标用户的连接。通常需要 Redis Pub/Sub、消息队列或专门的实时网关,把事件广播给正确的 WebSocket 节点。
授权与租户隔离
WebSocket 建立后会持续存在,不能只在初始页面渲染时做一次粗粒度判断。服务端发送每一条 HTML 之前,都应确认连接对应的用户、租户和资源权限。推送错误的 HTML 既是数据泄露,也可能导致页面状态污染。
消息大小与更新范围
推送整个表格会让网络、模板渲染和 DOM 更新成本一起上升。应优先设计稳定的局部目标,例如 #job-42、#notification-count 和 #activity-list,让一条事件只更新必要的节点。
采用前的检查清单
HTML over WebSockets 值得采用时,通常具备几个条件:服务器是状态权威,页面交互以标准 HTML 控件为主,实时变化可以拆成局部片段,而且团队愿意把模板当作前后端共同维护的接口。
落地时可以按下面的顺序验证:
- 先用普通 HTTP 页面和一个 WebSocket 频道验证消息格式。
- 为每个可更新区域定义稳定 id 和明确的交换策略。
- 为消息增加事件 id、重连策略和幂等规则。
- 在反向代理、负载均衡和多实例环境中测试连接超时与广播。
- 对模板输出做 HTML 转义、权限检查和内容安全策略验证。
- 记录连接数、重连次数、发送延迟、消息大小和渲染失败。
这套方式的价值不在于“完全没有 JavaScript”这句口号,而在于把应用逻辑集中在服务器,并让实时更新直接使用最终的界面表示。对于以服务器状态和表单工作流为中心的应用,它能显著减少状态映射;对于高度客户端化的产品,则应把它和传统 SPA 视为两种可组合的架构,而不是互相替代的教条。