第 12 章拆解 — 实时更新:轮询、SSE、WebSocket 与 Yew 门户
实时更新:轮询、SSE、WebSocket 与 Yew 门户
这一章把前后端的对话从「轮流问答」升级成「实时对话」。 三档技术一档比一档 强、也一档比一档贵:轮询(定时问)、SSE(服务器推)、WebSocket(双向聊)。 前半后端(建端点),后半前端(Rust 绑 Web API 的那些坑),最后是 「什么时候根本不该上实时」的清醒剂。主线例子:一张工单页面的评论区—— 从「刷新才看得到新评论」到「对方一发我这边就冒出来」。
1. 顶层全景
为什么:反馈环太长(刷新页面才知道变了);事件可能根本不归我们发起(汇率、别人的操作)
↓
三档技术
轮询 = 前端 setInterval 定时问(简单;浪费、迟到;客户端独角戏)
SSE = 服务器推、单向(text/event-stream;连接有上限;心跳保活)
WebSocket = 双向全双工(握手升级后想发就发;最贵,别替代 HTTP)
↓
前端接线:web-sys 直绑 Web API(回调 forget 防内存管理冲突、Drop 里清理定时器)
+ Yew portal(通知/弹窗注入宿主,事件还能回流)
↓
反方意见:实时更新只放「用户关心 + 不打扰工作流」的地方
图说:强度递增、成本递增;选对档位比选最强的更重要。2. 为什么值得:反馈环的三个裂缝
应用虽然快(Wasm 打底),骨子里仍是轮流问答:客户端问一句、服务器答一句。 裂缝有三:一是通知住在邮箱里——用户盯着应用时,变化却发生在应用之外的 邮件客户端,出不出弹窗我们管不着;二是大文件上传——把人卡在上传进度条前 不行,藏在后台也不够,得让人随时看到进度;三是事件根本不归我们发起—— 汇率、股价、别处的操作,「什么时候值得查」客户端完全不知道, 守着刷新键的人永远在猜1。
3. 轮询:最小改动,天花板也最低
做法:前端设个定时器,按固定间隔问后端「有更新吗」——拉(pull)模式。 没更新就沉默等下一拍;有更新就随响应返回、立刻呈现。它是技术不是协议2。
两个拧巴: 间隔长 → 收到更新必然迟到(最多迟一个间隔);间隔短 → 白白压垮服务器(大部分询问扑空)。「仔细权衡后,你得到的也只是一个妥协」。 缓解手段:条件请求(带上 ETag 等校验头,没变化就只回「没变」、不重传内容,省带宽——带宽即单位时间能传的数据量)(ETag / If-Modified-Since / Last-Modified 系列头, 没变就不重传体)3。
Rust 侧的定时器(顺手学到的 web-sys 模式): extern "C" 声明
setInterval/clearInterval,wasm_bindgen 胶水;包一个 Interval 结构体
存下 token,并且实现 Drop 在丢弃时 clearInterval——
「否则 Rust 侧对象没了,事件循环里那个闭包还会一直被调」。
回调里发请求、处理响应、有更新就显示,没有就静默4。
适用面:客户端少、延迟不敏感的场景——轮询给的「自动化重试」体验已经够好5。
4. SSE:服务器推,单向
升级点:推(push)——服务器在有事件时随时主动发。 客户端先开连接(开的动作 仍是客户端发起),此后服务器随时可发;连接数有硬上限:每浏览器每域 6 个, 服务器侧典型 100 个——相当低;服务器和客户端都有权关闭6。
代价一:仍要定期发保活消息(这回是服务器往客户端发,客户端收到忽略)7。
实现是 Web 标准,四步即可: ① dedic一个端点;② 响应
Content-Type: text/event-stream;③ 正文每行 data: 开头;④ 事件之间用
两个换行分隔——不分隔的话浏览器会把它们当同一条数据拼接(书里注释:这反而
意味着可以发大二进制,行长度不限)。辅助头:Cache-Control: no-cache
(不缓存、即时处理)、X-Accel-Buffering: no(防 nginx 缓冲)、
Connection: keep-alive;还能 event: 名字 区分事件类型,
单独一个冒号开头的行是注释(当心跳用正好)8。
Axum 已内置支持(加 async-stream/tokio-stream 等依赖):
async fn sse_handler() -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let stream = stream! {
loop {
sleep(Duration::from_secs(1)).await;
if 有事件 { yield Ok(Event::default().data(...)); } // 没事件就不 yield
}
};
Sse::new(stream).keep_alive(KeepAlive::default())
}要点:stream! 宏把含 yield 的循环变成流;典型做法是让流无限存在,
由别处逻辑决定何时 yield;.keep_alive() 保活9。短处长明:
SSE 仍然不支持双向——要对话就得换技术10。
5. WebSocket:双向,最贵
定义:建立一个同时双向的通道——双方随时发、随时关,像真正的对话。 和轮询的本质区别:轮询的对话被页面加载周期绑死,WebSocket 里任何一方 都可以在任意时机发消息,下一条既可以是回应,也可以是完全的新话题11。
握手过程:(握手 = 双方协商升级协议的前置往返)客户端发普通 HTTP 请求(GET,HTTP/2 下是 CONNECT)→ 服务器「升级」通道到 WebSocket 协议 → 双方开始收发。每个消息触发各自的事件, 双方注册处理器;任一方关闭,连接即失,要用得重连12。
什么时候用:要维持双向、持续消息流时——聊天、工单评论实时互见 (正在看这张票的人最想要实时,也最可能给别人发实时)。缺点:开发和复杂度 三档中最重;不需要双向就别上——「把前后端全部通信都搬到 WebSocket 上」 等于重造 HTTP 的请求/响应/协商/确认,「不会更快,只会更累」13。
Axum 的用法四步: route("/ws", any(handler)) → handler 收
WebSocketUpgrade 提取器 → ws.on_upgrade(handle_socket) →
handle_socket 里 while let Some(msg) = socket.recv().await 循环收发。
每个连接一个专属状态机,框架白送;要广播就挂一个
broadcast::channel::<String>(16) 进 State,State(sender) 注入 handler——
直接 send 只达发起者,broadcast 达全员;JSON 负载里带 ticket id 之类的
受众提示,就能给「合法订阅者」定向发14。
两条安全红线(红字注释): ① 客户端信息只在升级瞬间可得(WebSocket 协议没有头),错过就只能让客户端发消息自报家门;② 绝不在客户端过滤消息定 权限——会泄露数据,服务器必须保证只把消息发给该收的人(先关联用户与 连接,发送前做检查)15。
6. 前端:Rust 绑 Web API 的姿势与坑
总况(书里很坦诚): web-sys 薄到不能再薄——「函数结构直接反映底层 C API」, 不像 Rust、很别扭;但这些 API 是身经百战的,Rust 只是借来用, 将来才会长出更地道的封装16。
SSE 客户端(EventSource)三件套: set_onmessage / set_onerror / set_onopen
挂上 Rust 闭包(Closure::<dyn FnMut(_)>::new(...),经
.as_ref().unchecked_ref() 转成 JS 回调)。关键坑:.forget()——
把闭包内存管理交给 JS GC;不 forget,Rust 会回收它,回调就成了野指针。
(若平台支持弱引用,forget 后 JS 侧释放时 Rust 侧也能回收;不支持就是泄漏——
长寿命监听用它,短寿命的要想别的办法)17。
WebSocket 客户端(WebSocket 结构体): 同上换名字,外加能发:
send_with_str / send_with_u8_array(二进制先 set_binary_type);
在回调里发要先 clone 一份 WebSocket(回调夺走了所有权);
收二进制:dyn_into::<ArrayBuffer>() 或 dyn_into::<Blob>()(blob 要走
FileReader 回调读出,或直接换 gloo-file 库省事)18。
进度条贴士: ProgressEvent 带总大小和已读量,但先查 lengthComputable——
信息不一定一开始就有;可为 true 时就能算百分比画进度条19。
Yew 的 portal(通知/弹窗的正规军): portal 把内容注入不由本组件托管的
DOM 宿主——先 get_element_by_id("modal_host") 找宿主,create_portal
把 children 打进去。好处有二:换肤/布局统一不重复;portal 里触发的事件会
回流到创建它的组件,反应链路完整20。
7. 反方意见:什么时候别上实时
四条刹车(书里逐条写的):21
- 不停变化的界面让人分心甚至烦躁——只在「实时是核心价值」的场景上 (监控、行情);「只在它增加价值、改善可用性时才加」。
- 区分「用户动作触发」与「后端主动触发」:滚到底自动加载更多内容, 用户自己有预期;后端发起的更新绝不能打断用户手上的活——除非是必须 处理的错误。
- 别把实时撒得到处都是;彼此无关的实时信息,分页放比混在一屏强。
- 扛不住的实时不如没有:任一端处理不了负载,不响应的 UI 比不自动 更新的 UI 更糟。「无响应的界面对用户来说,比不自动更新的界面更差」—— 这句值得贴在显示器边上。
8. 主线例子走一遍:评论区的三级跳
起点:轮询版 —— Interval::new(5_000, 问一次 /tickets/42/comments)
│ ① 多数时候扑空;偶尔迟到最多 5 秒;Drop 组件时 clearInterval
▼
升级:SSE 版 —— EventSource("/tickets/42/events"),set_onmessage 收事件
│ ② 对方一发评论,服务器 yield,这边秒显;单浏览器 6 连接上限内规划好
▼
顶配:WebSocket 版 —— ws.on_upgrade;发评论也走这条通道
│ ③ 双向:发与收同一个连接;服务器按消息里的 ticket id 只发给订阅者
▼
呈现:新评论经 portal 弹一条 toast,不打断正在打字的人
——档位选哪级,取决于「要不要双向」和「扛不扛得住」两个问题的答案。
图说:三级跳每一级都解决上一级的硬伤,也带来新一级的账单。
(端点名为演示编的;连接上限、forget、clearInterval 出自原文。)9. 作者的判断与证据(分开标)
有出处的: SSE 连接上限(6/域、典型 100)、双向换 HTTP 是重造轮子的论证、 「无响应的 UI 更差」、forget 的泄漏条件,均为原书原文6132117。
作者自己的评估:
- 「SSE 比轮询高一档、WebSocket 最贵」的档位观;
- 「将来会有更地道的 Rust 封装」的预测(正在应验);
- 「权限过滤必须在服务器」是安全常识,书里以红字执行15。
10. 边界与局限
- 连接管理与重连退避没展开: 断线重连的指数退避、心跳超时判定,生产必须自己补;
- 背压与流控: 生产者的 yield 速率没人管,消费者慢会积压——书里未涉及;
- 横向扩展下的广播: 多实例 WebSocket 广播要外部总线(Redis pub/sub), 书里单机示例为止;
- HTTP/2 与 SSE 的关系(HTTP/2 下 SSE 多路复用能绕开 6 连接限制) 只在脚注露了一面22。
11. 可带走的
主线例子一行走完: 轮询(5 秒 Interval)→ SSE(EventSource + onmessage)→ WebSocket(双向、按订阅者定向)→ portal toast 呈现,不打断输入。
- 三档记牢:轮询(拉)、SSE(单向推)、WebSocket(双向推)。
- 轮询的两个拧巴:间隔长迟到、间隔短压垮; 条件请求(ETag 等)能省带宽。
- SSE 四步:端点、text/event-stream、data: 行、双换行分事件; 辅助头防缓存防缓冲;心跳用注释行。
- SSE 连接有上限(浏览器 6/域、服务器典型 100)——规划页面时就要算。
- WebSocket 握手是 HTTP 升级; 升级瞬间之后就没有「头」了。
- 双向不是免费的: 全线替换 HTTP = 重造请求/响应/协商。
- 安全在服务器侧收口: 按用户关联连接,发送前检查;客户端过滤=泄露。
- web-sys 回调三件套: Closure 包 Rust 闭包、unchecked_ref 转 JS、 forget 交权(长寿命监听);定时器要在 Drop 里 clearInterval。
- Yew portal 注入宿主、事件回流——通知/弹窗的标准做法。
- 实时更新只在「用户关心且不打扰工作流」处上;扛不住的实时不如没有。
12. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 反馈环的三个裂缝 | Live Updates | text/83-fm-introduction.txt:61(搜「beyond the domain」)、:64(搜「exchange rates」) |
| 轮询定义与拧巴 | 同上 | text/83-fm-introduction.txt:85(搜「pull-like」)、:88(搜「compromise」) |
| 条件请求贴士 | 同上 | text/83-fm-introduction.txt:98(搜「ETag」) |
| Interval 与 Drop 清理 | 同上 | text/83-fm-introduction.txt:124(搜「setInterval」)、:256(搜「no longer in the memory」) |
| SSE 定义与连接上限 | 同上 | text/83-fm-introduction.txt:268(搜「6 per browser」)、:271(搜「keep the connection alive」) |
| SSE 四步与辅助头 | 同上 | text/83-fm-introduction.txt:284(搜「text/event-stream」)、:313(搜「no-cache」)、:346(搜「comment」) |
| Axum SSE 处理器 | 同上 | text/83-fm-introduction.txt:371(搜「sse_handler」)、:414(搜「keep_alive」) |
| WebSocket 握手与对话 | 同上 | text/83-fm-introduction.txt:473(搜「two-way」)、:501(搜「switch channels」) |
| 「重造 HTTP 轮子」 | 同上 | text/83-fm-introduction.txt:485(搜「reinventing the wheel」) |
| Axum WS 四步与广播 | 同上 | text/83-fm-introduction.txt:548(搜「any(handler)」)、:562(搜「on_upgrade」)、:647(搜「broadcast::Sender」) |
| 升级瞬间取信息 | 同上 | text/83-fm-introduction.txt:624(搜「only available during the upgrade」) |
| 服务器侧安全过滤 | 同上 | text/83-fm-introduction.txt:681(搜「data leakage」) |
| EventSource 三件套与 forget | 同上 | text/83-fm-introduction.txt:711(搜「set_onmessage」)、:874(搜「forget」) |
| WS 客户端收发二进制 | 同上 | text/83-fm-introduction.txt:891(搜「echo.websocket.events」)、:923(搜「send_with_u8_array」) |
| ProgressEvent 进度条 | 同上 | text/83-fm-introduction.txt:1021(搜「lengthComputable」) |
| Yew portal | 同上 | text/83-fm-introduction.txt:1030(搜「portal」)、:1033(搜「find their way back」) |
| 四条刹车 | 同上 | text/83-fm-introduction.txt:1111(搜「distracting」)、:1114(搜「their own actions」)、:1123(搜「non-responsive」) |
| Points to remember | 同上 | text/83-fm-introduction.txt:1151(搜「client-initiated」) |
| Key terms | 同上 | text/83-fm-introduction.txt:1289(搜「Event: A certain change」) |
Footnotes
-
出处:「Purpose of targeting live updates」(
text/83-fm-introduction.txt:61,搜「beyond the domain of our application」)、:64(搜「currency exchange rates」)。 ↩ -
出处:「Polling」(
text/83-fm-introduction.txt:85,搜「pull-like behavior」)。 ↩ -
出处:「Polling」(
text/83-fm-introduction.txt:88,搜「the right balance」)、:98(搜「ETag」)。 ↩ -
出处:「Implementation」(
text/83-fm-introduction.txt:124,搜「setInterval」)、:256(搜「even if its Rust counterpart is no longer in the memory」)。 ↩ -
出处:「Polling」(
text/83-fm-introduction.txt:106,搜「do not have so many individual clients」)。 ↩ -
出处:「Server-sent events」(
text/83-fm-introduction.txt:268,搜「6 per browser per domain」与「100 per server」)。 ↩ ↩2 -
出处:「Server-sent events」(
text/83-fm-introduction.txt:271,搜「keep the connection alive」)。 ↩ -
出处:「Implementation」(
text/83-fm-introduction.txt:284,搜「text/event-stream」)、:313(搜「Cache-Control: no-cache」)、:325(搜「event:」)、:346(搜「considered as just a comment」)。 ↩ -
出处:「Implementation」(
text/83-fm-introduction.txt:371,搜「sse_handler」)、:424(搜「yield an event only when」)、:414(搜「keep_alive」)。 ↩ -
出处:「Implementation」(
text/83-fm-introduction.txt:467,搜「does not support bidirectional」)。 ↩ -
出处:「WebSockets」(
text/83-fm-introduction.txt:473,搜「simultaneous two-way」)、:476(搜「turn-based」)。 ↩ -
出处:「Implementation」(
text/83-fm-introduction.txt:501,搜「switch channels」)。 ↩ -
出处:「WebSockets」(
text/83-fm-introduction.txt:485,搜「reinventing the wheel」)。 ↩ ↩2 -
出处:「Implementation」(
text/83-fm-introduction.txt:548,搜「any(handler)」)、:562(搜「on_upgrade」)、:635(搜「broadcast」)、:662(搜「with_state」)。 ↩ -
出处:「Implementation」(
text/83-fm-introduction.txt:624,搜「only available during the upgrade」)、:681(搜「data leakage」)。 ↩ ↩2 -
出处:「Preparing the frontend for live updates」(
text/83-fm-introduction.txt:698,搜「as low-level as it gets」)。 ↩ -
出处:「Low-level SSE and WebSocket support」(
text/83-fm-introduction.txt:711,搜「set_onmessage」)、:874(搜「delegate」)。 ↩ ↩2 -
出处:「Low-level SSE and WebSocket support」(
text/83-fm-introduction.txt:891,搜「echo.websocket.events」)、:923(搜「send_with_u8_array」)。 ↩ -
出处:「Low-level SSE and WebSocket support」(
text/83-fm-introduction.txt:1021,搜「lengthComputable」)。 ↩ -
出处:「Yew's support for live updates」(
text/83-fm-introduction.txt:1030,搜「not directly hosted」)、:1033(搜「find their way back」)。 ↩ -
出处:「Disadvantages of using live updates」(
text/83-fm-introduction.txt:1111,搜「distracting」)、:1114(搜「their own actions triggered」)、:1123(搜「non-responsive UI is worse」)。 ↩ ↩2 -
出处:脚注 8(
text/83-fm-introduction.txt:1347,搜「CONNECT for HTTP/2」)。 ↩