想象你正收听一场足球赛的电台直播。主播不会等比赛全部结束才把结果念给你,而是对着话筒一句一句地说:“球传到前场……射门!”你的收音机一边接收一边播放,播到哪你就听到哪。而传统的 API 响应呢?像一盘录好的比赛磁带——服务器要把整场比赛录完,才把磁带塞给你,打开得等好几分钟。SSE(Server-Sent Events)就是 Web 世界里的“电台直播”:服务器是主播,浏览器是收音机,一次通话里声音源源不断。打字机效果,则是速记员在直播现场把主播的话一个字一个字敲上屏幕,制造出“边听边写”的现场感。这篇文章会顺着这趟电波,讲清频道如何建立、句子如何断句、文字如何上屏,以及信号断了之后如何自动重拨——帮你完整看懂 AI 对话窗口背后那条看不见的声音之河。

岗位关联

对 AI 从业者来说,这份技能把“等模型生成完再展示”的慢体验,变成“边说边展示”的高端体验:首字延迟压进 1 秒、输出可中断可续传。掌握它,是打造下一代有生命感 AI 界面的入场券。

目录
  1. 可视化演示
  2. SSE 原理入门:一场单向的电台直播
  3. 事件流报文格式:主播的断句艺术
  4. EventSource 客户端:自动搜台的收音机
  5. 分块解析与增量渲染:速记员的笔
  6. Fetch 流式进阶:自己动手做对讲机
  7. 中断与重连:挂断、重拨与续播
  8. 工程化实践:电台的日常运营
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示SSE连接建立、分块解析data前缀、增量打字机逐字渲染及中断重连指数退避机制,让大模型对话界面流畅可控,含流动波形背景和发光动画。

本节配有交互动效(进入视口后自动加载,约 3KB)。

SSE 原理入门:一场单向的电台直播

还是那场足球赛。传统做法是你每隔 30 秒给主播打一次电话问“比分多少了?”——这叫轮询。更笨的是等全场结束,主播才把完整战报寄给你——这叫一次性响应。而 SSE 做的是:你在开场时拨通一次电话就不再挂断,主播嘴巴不停、你耳朵一直听,但你不需要说话。这是一条“单向实时通道”,数据永远只从服务器流向浏览器。

在 Web 世界里,这个“永不挂断的电话”是一条基于普通 HTTP 的长连接。浏览器用 EventSource 发起请求,服务器把 Content-Type 设为 text/event-stream,然后就能源源不断地往下写消息。连接是单向的——数据只从服务器流向浏览器,这恰好像极了“大模型在思考、浏览器负责展示”的 AI 对话场景。

相比 WebSocket 的双向全双工,SSE 牺牲了“浏览器向服务器说话”的能力,却换来了三个实惠:一是协议极简,就是普通 HTTP 文本流,天然支持跨域(CORS)和代理;二是内置自动重连;三是能拿到极低的首字延迟。对大模型对话这种“服务器单口输出”的场合,这是性价比最高的方案。

一句话记住 SSE:它是“只听不说”的收音机,适合所有“服务器单方面滔滔不绝”的场景。选型时先问自己“需要双向吗”,不需要,SSE 就是那个最省力的频道。

TTFB=tfirst−trequest\displaystyle \text{TTFB} = t_{\text{first}} - t_{\text{request}}
TTFB(Time To First Byte,首字节时间)是流式体验的灵魂指标:从点击“发送”到屏幕上蹦出第一个字,这个值越小,用户越觉得 AI“反应快”。
  1. 记录请求时刻点击发送那一刻,用高精度时钟记下 $t_{\text{request}}$,作为计时起点。
    trequest=performance.now()t_{\text{request}} = \text{performance.now()}
  2. 记录首个分块时刻浏览器收到第一个数据字节时记下 $t_{\text{first}}$,标志“声音开始传来”。
    tfirst=首次触发 onmessage 的时间t_{\text{first}} = \text{首次触发 onmessage 的时间}
  3. 二者相减$t_{\text{first}} - t_{\text{request}}$ 就是首字延迟,目标应小于 500ms。
    TTFB<500 ms⇒体验优\text{TTFB} < 500\,\text{ms} \Rightarrow \text{体验优}
  4. 超过阈值预警TTFB 超过 2s 时用户会觉得明显卡顿,需要排查模型推理慢或链路过长。
    TTFB>2000 ms⇒需要优化\text{TTFB} > 2000\,\text{ms} \Rightarrow \text{需要优化}

事件流报文格式:主播的断句艺术

主播说话不会变成一串没有边界的噪音。他一句一顿,每说完一句停半拍,你才知道“这句完了”。SSE 协议同样靠“停顿”断句:每条消息写完,加一个空行(两个连续换行符)作为结束。收音机(EventSource)听到空行,就知道一句完整的话到了,可以交给前端处理。

每条消息由若干“字段行”组成,格式是“字段名:值”。最常见的字段是 data,值就是要推给前端的正文,通常是一段 JSON,比如 data: {"delta":"你"} 加一个空行。看到空行,浏览器就把 data 的内容完整交给你的回调函数。这就像速记员在本子上记:一行一行地记,空一行表示“这句结束了,翻页”。

协议还提供了几个辅助旋钮:id 给消息编号,用于断线续传时告诉服务器“我听到哪了”;retry 告诉浏览器“如果断了,隔多少毫秒重新搜台”;event 给消息起名字,前端用 addEventListener 分类处理;以冒号开头的行是注释,常被用作“心跳包”。如果一条消息里有多个 data 行,浏览器会把它们用换行拼接成一条数据。字段可以缺失,但“空行收尾”是硬规矩。

这段“断句”看起来又土又啰嗦,但它让协议极其透明:你自己造一个 HTTP 服务,手动敲几个 data: 行加空行,就是一个合法的 SSE 接口。这正是它至今活跃在 AI 流式传输一线的底气——简单到无法出错。

frame=( fieldi:valuei )++\n\n\displaystyle \text{frame} = (\,\text{field}_i : \text{value}_i\,)^{+} + \backslash n \backslash n
这条“消息骨架”是说:一条 SSE 消息 = 至少一个“字段:值”行 + 一个空行结尾。空行是消息的句号。
  1. 字段行集合$\text{field}_i$ 只能取 data、event、id、retry 等协议字段名,冒号后跟值。
    fieldi∈{data, event, id, retry}\text{field}_i \in \{\text{data},\ \text{event},\ \text{id},\ \text{retry}\}
  2. 至少一行上标 $^{+}$ 表示字段行数量必须大于等于 1,空消息没有意义。
    ( fieldi:valuei )+≥1 行(\,\text{field}_i : \text{value}_i\,)^{+} \geq 1\ \text{行}
  3. 空行收尾最后必须紧跟一个空行 $\backslash n\backslash n$,浏览器才认为消息完整。
    frame 结束标志=\n\n\text{frame 结束标志} = \backslash n \backslash n
  4. 多行拼接多个 data 字段在客户端会被拼成一行文本,值为各段用换行连接。
    value=v1\nv2\text{value} = v_1 \backslash n v_2

EventSource 客户端:自动搜台的收音机

用收音机听球赛,你只需要做两件事:拧到正确的频率,然后听。浏览器内置的 EventSource 就是这台无需安装的收音机——new EventSource('/api/chat/stream') 一行代码,信号开始流动。这是所有方案里接入成本最低的,连“拧旋钮”的力气都省了。

收音机面板上有三盏指示灯:onopen 信号接通、onmessage 收到一句话、onerror 信号异常。日常开发 90% 的代码都写在 onmessage 里——拿到 data 字符串,解析成 JSON、拼入缓冲区、推进渲染。剩下的两盏灯,一般只负责打日志和触发重连观察。

EventSource 最省心的是“自动搜台”:连接意外断开时,它会根据服务端 retry 字段(默认约 3 秒)自动重连,不需要你写一行重试代码。它还支持 withCredentials 携带 Cookie 完成鉴权。代价是它只能发 GET 请求、不能自定义请求头——当需要 POST 正文、Bearer Token 或精细控制取消时,就要请出下一节的“高级收音机”了。日常 AI 应用里 Token 常放在 Cookie 或网关层,EventSource 往往绰绰有余。

收音机的三盏灯对应了前后端的一个约定:前端只管接线、监听到货,重连的脏活交给协议。真正要操心的是“声音本身”——断线时丢掉的半句话怎么办?答案在后面的“断点续传”里。

tk+1=min⁡(2×tk,  tmax⁡)\displaystyle t_{k+1} = \min\left(2 \times t_k,\; t_{\max}\right)
这是工程上推荐的“指数退避”重连节奏:第一次断开等 1 秒,第二次 2 秒,第三次 4 秒……翻倍上涨,但封顶 30 秒,避免服务器被打爆。
  1. 基础等待$t_0 = 1\,\text{s}$,第一次重连按 1 秒起步,给网络一点恢复时间。
    t0=1 st_0 = 1\,\text{s}
  2. 翻倍退避每次失败等待翻倍,$t_{k+1} = 2 \times t_k$。
    t1=2 s,t2=4 s,t3=8 st_1 = 2\,\text{s},\quad t_2 = 4\,\text{s},\quad t_3 = 8\,\text{s}
  3. 封顶保护$t_{\max} = 30\,\text{s}$,防止长时间断线后重连间隔无限膨胀。
    tk≤30 st_k \leq 30\,\text{s}
  4. 恢复复位连接成功后 $t_k$ 重置回 $t_0$,避免久断后无法及时回连。
    tk←t0 (连接成功时)t_k \leftarrow t_0 \ (\text{连接成功时})

分块解析与增量渲染:速记员的笔

主播语速很快,但观众看的是字幕——字幕不是等整条新闻播完才出现的,而是速记员一边听一边把文字敲上屏幕。这就是“分块解析 + 增量渲染”的分工:网络层负责源源不断地进货(chunk),渲染层负责把一个一个字摆上去。

网络层收到的是字节流,不是结构化消息。前端要维护一个“待处理的半句话”缓冲区:新 chunk 到达后先 buffer += chunk,再检查缓冲区里有没有以空行结尾的完整消息。有,就切出来解析;没有——消息还没说完——就继续等。这就像速记员先在小本子上记,凑满一句再誊到黑板上。网络字节块经常把一句话切成两半,缓冲区就是那个“兜住半句话”的本子。

打字机效果本质是一种“渲染节流”:数据其实早就全躺在缓冲区里了,但 UI 每帧只往前推进若干字符,造成“边说边写”的假象。实现时用 requestAnimationFrame 定时把“已渲染长度”向前加,再通过 textContent 一次性替换整段文本。注意是替换整段,而不是每收一个字符就 append 一个 DOM 节点——后者会让浏览器卡成 PPT。光标则是一个跟随文本长度的闪烁条,渲染完成时熄灭。

记住这个分工:到达是网络的事,显示是 UI 的事。打字机效果不是“慢的网络”,而是“快的网络 + 故意放慢的笔”,它把等待变成注视,让用户觉得 AI 正在“思考着回答”——这是产品体验层面的一记妙手。

shownk=min⁡(arrivedk,  shownk−1+Δ),Δ=max⁡(1,  ⌊r⋅Δt⌋)\displaystyle \text{shown}_{k} = \min\left(\text{arrived}_{k},\; \text{shown}_{k-1} + \Delta\right),\quad \Delta = \max\left(1,\; \lfloor r \cdot \Delta t \rfloor\right)
渲染推进公式:屏幕上已显示的字符数永远取“已到达量”和“上一帧显示量 + 本帧步长”中较小的一个,保证既不超前也不断更。
  1. 已到达量$\text{arrived}_k$ 是本帧网络层已解析出的字符总数,是渲染的天花板。
    arrivedk=缓冲区已解析字符数\text{arrived}_k = \text{缓冲区已解析字符数}
  2. 前帧基准$\text{shown}_{k-1}$ 是上一帧已经显示的字数,渲染只能前进不能后退。
    shownk−1=上一帧的渲染位置\text{shown}_{k-1} = \text{上一帧的渲染位置}
  3. 步长计算$\Delta = \max(1, \lfloor r \cdot \Delta t \rfloor)$ 按目标打字速度 $r$(字/秒)乘以帧间隔得出每帧推进量,至少为 1 字。
    Δ=max⁡(1,  ⌊r⋅Δt⌋)\Delta = \max\left(1,\; \lfloor r \cdot \Delta t \rfloor\right)
  4. 取小约束min 兜底,防止渲染超越到达数据而出现“空白字”。
    shownk≤arrivedk\text{shown}_k \le \text{arrived}_k

Fetch 流式进阶:自己动手做对讲机

成品收音机(EventSource)虽然好用,但有一个死穴:它只能单向“收听”,连“请求单”都递不出去——只能 GET,不能带自定义请求头。真实的大模型接口往往要 POST 一长串对话历史、要带 Authorization: Bearer xxx。这时,我们得自己拆开收音机,用 fetch 造一台能说能听的对讲机。

fetch(url, { method: 'POST', body: JSON.stringify(payload), headers }) 发起请求后,响应对象带一个 response.body,它实现了 Web 的 ReadableStream 接口。通过 response.body.getReader() 拿到读取器,然后 await reader.read() 循环读取:每次拿到一块 Uint8Array 字节,用 TextDecoder 解码成文本。这个循环像拧开水龙头的双手——水一滴滴流出,你随时可以松手关水。

自己解析 SSE 要补三道工序:一是缓冲区,因为一个字节块可能切开一条消息(“你好”被切成“你”和“好”各半),需要用空行分割后把尾部残片留给下次;二是多字段处理,区分 data:、event: 和 [DONE] 结束标记;三是取消,把 AbortController 的 signal 传给 fetch,调用 controller.abort() 即可随时挂断。这三道工序,就是“高级收音机”比“成品收音机”多出来的工作量。

能力越大,责任越大。用 fetch 流式,意味着自动重连、心跳、协议容错全部要自己维护。好在这些都可以封装成一个 createSSEStream(url, options) 工具函数,让业务代码还是只写一个“监听器”——把对讲机的复杂度关进笼子里,外面依旧是那台熟悉的收音机。

f(B)=∣split⁡(B,  \n\n)∣−1\displaystyle f(B) = \left| \operatorname{split}\left(B,\; \backslash n \backslash n\right) \right| - 1
缓冲区完整消息数:把当前缓冲按空行切成若干段,段数减一就是完整消息的条数,最后剩的那段是“半句话”,留在缓冲里继续等。
  1. 取当前缓冲$B$ 是尚未处理完的累计文本,包含刚解码的新数据。
    B=B旧残片+decode(c)B = B_{\text{旧残片}} + \text{decode}(c)
  2. 按空行切分$\operatorname{split}(B, \backslash n\backslash n)$ 把缓冲切成 $k$ 段,每一段是一条候选消息。
    split⁡(B)⇒f1,f2,…,fk\operatorname{split}(B) \Rightarrow f_1, f_2, \dots, f_k
  3. 减去末段$f(B) = k - 1$,前 $k-1$ 段是完整消息,最后一段未闭合。
    f(B)=k−1f(B) = k - 1
  4. 残片保留把最后一段放回缓冲,等待下个 chunk 到达再拼接。
    B新=fkB_{\text{新}} = f_k

中断与重连:挂断、重拨与续播

听球赛时你随时可以“啪”地关掉收音机——用户点“停止生成”按钮,前端调用 controller.abort() 挂断连接,服务端随后收到断开信号,停止大模型的继续生成。注意:只挂断连接不够,服务端还应在检测到连接关闭时同步取消模型推理任务,否则模型会在后台白白烧算力。这就是“全链路停止”。

如果断线不是用户主动挂断,而是信号不好(网络抖动、网关超时),收音机就要自动重拨。EventSource 内置自动重连;fetch 方案则需自己实现“指数退避重拨”,配合心跳包区分“网络抖动”和“服务端已挂”。重连不是越快越好,太快会变成“重连风暴”,把服务器打趴。

断线最痛的是“丢内容”。解决方案是给消息编号:服务端每条消息带 id: 42,浏览器重连时自动带上 Last-Event-ID: 42 请求头,服务端看到后从第 43 条接着推。这就是“续播”——主播问一句“刚才听到哪了”,你说“第 42 句”,他就从那往后讲。为了让续播不重不丢,服务端要按 id 做幂等:缓存最近 N 条消息,从记录点重新发送,而不是重头再来。

一句话总结:用户主动的停叫“中断”,网络突变的修“重连”,数据一致靠“续播”。三者配合,才能让用户在电梯里、地铁上都有一场不重样、不卡壳的实况转播。

tk=min⁡(t0⋅2k−1,  tmax⁡)\displaystyle t_{k} = \min\left(t_0 \cdot 2^{k-1},\; t_{\max}\right)
第 k 次重连尝试的等待时间:以 1 秒为基数,每次失败翻倍,30 秒封顶。加一点随机抖动(如 ±20%)能避免大量客户端同时重连造成的“重连风暴”。
  1. 重连计数$k$ 从 1 开始,每次失败递增一次。
    k=1,2,3,…k = 1, 2, 3, \dots
  2. 指数增长$t_0 \cdot 2^{k-1}$ 使等待时间呈指数膨胀:1s、2s、4s、8s……
    t0⋅2k−1=2k−1 st_0 \cdot 2^{k-1} = 2^{k-1}\,\text{s}
  3. 封顶上限$\min(\cdot, t_{\max})$ 保证最长等待不超过 30 秒。
    tk≤30 st_k \le 30\,\text{s}
  4. 抖动与复位结果上叠加随机抖动防“重连风暴”,连接成功后 $k$ 归 1。
    tk实际=tk×(1+rand(−0.2,0.2))t_k^{\text{实际}} = t_k \times (1 + \text{rand}(-0.2, 0.2))

工程化实践:电台的日常运营

一个正经电台,不会只让主播对着话筒干讲,还有三件看不见的幕后工作:整点报时、应急预案、收听率统计。对应到 SSE 工程化,就是心跳保活、错误分级、监控指标。这三件事决定你的“电台”能不能从 Demo 走向生产。

很多反向代理和网关(Nginx 等)默认对空闲连接有超时(如 60 秒)。为了让长连接不被静默切断,服务端要定期发“心跳”——一条以冒号开头的注释行(: ping)或一个 event: ping 消息,把连接“戳”一下。这就像电台每小时播一次“嘀——”的报时音,证明“我还活着”。心跳间隔通常设为代理超时时间的 1/3,留足余量。

错误要分级处理:网络层错误(连接断开)走重连逻辑;协议层错误(空行缺失、JSON 解析失败)跳过本帧并记录告警;业务层错误(HTTP 429 限流、超时)根据服务端下发的 error 消息提示用户重试。UI 优化上,打字机渲染用 requestAnimationFrame 合并每帧的 DOM 更新,长文本超过一定行数做虚拟化;同时盯两个核心指标:TTFB(首字延迟)和 tokens/s(渲染速度),它们直接决定用户感知的“快”与“跟手”。

流畅不是运气,是设计。一个能扛住生产压力的流式对话系统 = 干净的事件流格式 + 稳的自动重连 + 勤劳的心跳 + 分级的错误处理 + 可观测的指标。把这五件事做踏实,你的“电台”才算正式开播——而不是实验室里的一次性演示。

A=TupTup+Tdown×100%\displaystyle A = \frac{T_{\text{up}}}{T_{\text{up}} + T_{\text{down}}} \times 100\%
服务可用率:流式通道真正能用的时间占总时间的比例。99.9% 的可用率意味着一年断线不超过 8.76 小时——对“电台”来说,每一分钟下线都会让听众流失。
  1. 在线时长$T_{\text{up}}$ 是统计周期内连接可用、消息可正常推送的累计时长。
    Tup=Σ 健康时段T_{\text{up}} = \Sigma\ \text{健康时段}
  2. 离线时长$T_{\text{down}}$ 是重连失败、主动熔断、网关超时等导致的不可用累计时长。
    Tdown=Σ 故障时段T_{\text{down}} = \Sigma\ \text{故障时段}
  3. 计算比例两者相除得到可用率,生产目标通常是 99.9% 以上。
    A≥99.9%A \ge 99.9\%
  4. 监控驱动可用率下降时触发告警,反查是心跳缺失、重连过慢还是服务端超时。
    A↓⇒告警并定位根因A \downarrow \Rightarrow \text{告警并定位根因}

AI 应用场景

智能客服助手:边生成边回答

阶段:前端交互层 / 流式渲染

解决的问题:传统方案等大模型完整生成后才返回,用户要盯着“正在输入…”等 5-10 秒;流式方案把等待变成阅读,首字 1 秒内出现,用户的耐心焦虑大幅降低,还能随时点“停止”打断错误回答。

具体案例:电商售后客服的对话窗口,用户提问后 AI 逐字打出退款流程说明;点击“停止”后服务端立即终止生成,界面显示“已停止”,全程无白屏卡顿。

大模型网关流式代理

阶段:接入层 / BFF 网关

解决的问题:模型 API 的 Key 不能暴露在浏览器,且上游接口与前端格式需要统一。由网关用 fetch 流式读取上游 SSE 响应,做鉴权、超时、心跳注入后再原样转发给前端,同时记录 token 用量。

具体案例:企业内部的 AI 对话平台,前端统一走 /api/chat 网关,网关负责注入 Authorization、翻译响应格式、统计每轮 token 消耗,前端只负责“打字机”。

RAG 知识库问答的引用流

阶段:检索-生成链路 / 多阶段流式

解决的问题:RAG 响应包含“引用来源 + 回答正文”两块内容,生成时机不同。可先用一条 SSE 消息把检索到的文档引用推给前端(立即渲染引用卡片),再流式推送正文,让用户感觉系统“先找资料再作答”。

具体案例:企业文档问答系统,用户提问后界面先出现“正在检索 3 篇文档…”,随后参考文献逐条浮现,最后回答文字打字机式展开。

Python 演示

python53 行
import jsonimport sysimport timeimport threadingfrom http.server import BaseHTTPRequestHandler, HTTPServerfrom urllib.request import urlopen if hasattr(sys.stdout, "reconfigure"):    sys.stdout.reconfigure(encoding="utf-8") REPLY = "你好!这段话正通过 SSE 流式接口一点一点送达,就像电台广播一样。" class SSEHandler(BaseHTTPRequestHandler):    def do_GET(self):        self.send_response(200)        self.send_header("Content-Type", "text/event-stream")        self.send_header("Cache-Control", "no-cache")        self.end_headers()        for ch in REPLY:            frame = {"delta": ch}            line = "data: " + json.dumps(frame, ensure_ascii=False) + "\n\n"            self.wfile.write(line.encode("utf-8"))            self.wfile.flush()            time.sleep(0.06)        self.wfile.write(b"data: [DONE]\n\n")        self.wfile.flush() def serve():    server = HTTPServer(("127.0.0.1", 8765), SSEHandler)    print("SSE server: http://127.0.0.1:8765")    server.serve_forever() def main():    threading.Thread(target=serve, daemon=True).start()    time.sleep(0.3)    text = ""    resp = urlopen("http://127.0.0.1:8765")    print(">>> 打字机效果演示开始")    for raw in resp:        line = raw.decode("utf-8").strip()        if not line.startswith("data:"):            continue        payload = line[5:].strip()        if payload == "[DONE]":            break        piece = json.loads(payload)["delta"]        text += piece        print(text, flush=True)        time.sleep(0.04)    print(">>> 播放完毕,完整内容: " + text) if __name__ == "__main__":    main()
目的:用 Python 标准库搭建一个迷你 SSE 服务端 + 打字机式客户端,演示“服务端逐字推送、客户端逐字渲染”的完整链路。
  1. self.send_header("Content-Type", "text/event-stream")声明这是 SSE 事件流,客户端才会按流式处理。
  2. line = "data: " + json.dumps(frame, ensure_ascii=False) + "\n\n"按 SSE 协议组帧:字段行 + 空行,空行是消息结束的句号。
  3. self.wfile.flush()强制把缓冲里的字节立刻发出去,否则小数据块会囤在网络缓冲区里,前端迟迟等不到。
  4. resp = urlopen("http://127.0.0.1:8765")客户端用 HTTP 流式读取,for 循环按行迭代,每收到一行就处理一行。
  5. payload = line[5:].strip()去掉开头的 data: 前缀,得到真正的 JSON 载荷。
  6. if payload == "[DONE]":约定结束哨兵,服务端发完正文后通知客户端“播完了”,客户端据此优雅退出。

常见误区

误区总觉得“都是实时推送,SSE 能做的 WebSocket 也能做,所以直接上 WebSocket 更保险,SSE 是过时技术”。

正解两者方向不同:SSE 是“服务器→浏览器”的单向收音机,基于普通 HTTP,协议简单、自动重连、天然跨域;WebSocket 是双向对讲机,适合聊天室、游戏、协同编辑等需要两端高频互动的场景。AI 对话的典型模式恰是“浏览器说一句、服务器滔滔不绝说很多句”,用 SSE 能把复杂度降一个量级。选型先看“需不需要双向”,而不是“谁更高级”。

为何常见:开发者常在“实时”这一个词上做等价,忽略了单向与双向的结构差异——就像把“听收音机”和“打电话”都叫“实时沟通”。

误区“打字机效果就是把每个收到的字符 `document.createElement('span')` 塞进聊天框,字符越多越有真实感。”

正解这是性能炸弹。一个长回答几百上千字,每个字一个节点会导致 DOM 节点暴涨、布局不断重排,页面越聊越卡。正确做法是维护“已显示长度”这一个数字,通过 textContent 一次性替换整段文本;真正逐帧推进的是 requestAnimationFrame 里的计数,而不是 DOM。速记员是在同一块黑板上改写,不是每字拿块新黑板。

为何常见:直观地认为“显示几个字就要有几个元素”,忽略了“一个数字驱动全局”的更优解。

误区“我明明设置了正确的接口地址,为什么 EventSource 一直报错?一定是接口路径的问题,我来改路径。”

正解EventSource 报错最常见的原因是 Content-Type 不是 text/event-stream。只要响应头不对,浏览器会直接触发 onerror 并不断重连,哪怕 body 格式完全正确。这也是 fetch 流式方案的优势之一——自己解析,对响应头不敏感。调 SSE 先看 Network 面板的响应头,比改 URL 更有效。

为何常见:onerror 只给一个笼统的信号,新手容易在路径、跨域上反复猜,而真正的问题往往藏在响应头里。

误区“EventSource 自带重连,断线重连后内容会自动补回来,所以不用管丢消息的问题。”

正解自动重连只重建“收音机的信号”,不负责“补播丢掉的半句话”。要保证不丢,必须服务端给消息编 id,客户端重连时带上 Last-Event-ID,服务端从断点继续推(并做幂等缓存)。如果消息没有 id,重连后只能从当前时刻继续,中间的内容就永久丢失了。

为何常见:把“连接恢复”误当成“数据恢复”。就像收音机重新搜到台,不代表你能听到刚才漏掉的进球。

面试题

Q1. SSE 连接的数据流向是?

单选题 · easy

  • A.浏览器向服务器单向
  • B.服务器向浏览器单向← 正确答案
  • C.浏览器与服务器双向
  • D.服务器向多端广播

答案:B。SSE 全称 Server-Sent Events,语义就是“服务器推送事件”,数据只能从服务器流向浏览器,是一台“只听不说”的收音机。A 方向反了,C 是 WebSocket 的双向全双工,D 与 SSE 的单连接模型不符。

Q2. SSE 响应中,一条完整消息的结束标志是?

单选题 · easy

  • A.单个换行符作为结束
  • B.空行即两个连续换行← 正确答案
  • C.单个分号标记消息结束
  • D.回车换行符作为结束

答案:B。SSE 帧格式规定每条消息以空行(两个连续换行符)结束,相当于主播每句话后的停顿。单个换行只是分隔字段行,回车是换行的一部分,分号不是分隔符。

Q3. 以下哪个状态码表示 SSE 响应成功建立?

单选题 · easy

  • A.200 表示成功建立← 正确答案
  • B.204 表示无响应内容
  • C.301 表示地址已迁移
  • D.404 表示接口不存在

答案:A。SSE 是普通 HTTP 响应,建立成功时状态码为 200,配合 Content-Type: text/event-stream 开启事件流。204 无内容、301 重定向、404 不存在都无法启动事件流传输。

Q4. 原生 EventSource 发起请求时默认使用哪种 HTTP 方法?

单选题 · mid

  • A.只支持 GET 方法← 正确答案
  • B.只支持 POST 方法
  • C.GET 与 POST 共用
  • D.支持任意 HTTP 方法

答案:A。EventSource 只能发 GET 请求,且无法自定义请求头(只能整体设置 withCredentials)。需要 POST 正文、Bearer Token 等场景时,应改用 fetch + ReadableStream 流式方案自己解析 SSE。

Q5. 服务端通过 retry 字段想告诉浏览器什么?

单选题 · mid

  • A.消息重发次数上限
  • B.断开后的重连间隔← 正确答案
  • C.响应内容的总长度
  • D.支持推送的最大条数

答案:B。retry: 5000 告诉 EventSource:若连接断开,隔 5000 毫秒再重连。它控制的是“自动搜台”的频率,与消息重发次数、内容长度、消息条数均无关。

Q6. 以下哪些是 SSE 协议的内置字段?

多选题 · hard

  • A.data 数据字段← 正确答案
  • B.event 消息名称← 正确答案
  • C.retry 重连间隔← 正确答案
  • D.status 状态字段

答案:ABC。SSE 协议内置 data(数据)、event(事件名)、id(消息编号)、retry(重连间隔)四类字段。status 不是协议字段,业务状态通常会打包进 data 的 JSON 中而非单独立行。

Q7. 打字机效果“逐字显示”通常通过什么方式实现?

单选题 · mid

  • A.每字创建独立节点
  • B.定时推进渲染长度← 正确答案
  • C.读取鼠标光标位置
  • D.逐字向服务器请求

答案:B。主流做法是维护一个“已渲染字数”计数器,配合 setInterval/requestAnimationFrame 定时递增,再把整段文本用 textContent 一次性替换。每字建 span 会拖垮性能,逐字请求服务器违背“数据早已到达”的流式本质。

Q8. fetch 流式方案相比 EventSource 的优势包括?

多选题 · mid

  • A.支持 POST 请求体← 正确答案
  • B.可自定义请求头← 正确答案
  • C.内置自动断线重连
  • D.可灵活取消请求← 正确答案

答案:ABD。fetch 流式可以携带任意请求体与请求头,并通过 AbortController 随时中断,这是 EventSource 做不到的。但自动断线重连恰是 EventSource 的内置优势,fetch 方案需要自己实现,因此 C 不是优势而是劣势。

Q9. 断线重连时,客户端携带哪个请求头实现断点续传?

单选题 · extreme

  • A.Last-Event-ID 请求头← 正确答案
  • B.Accept-Encoding 请求头
  • C.Cache-Control 请求头
  • D.X-Requested-With 请求头

答案:A。EventSource 重连时会自动携带 Last-Event-ID 请求头,值是最新收到的消息 id,服务端据此从下一条继续推送,实现“续播”。其余三个头与断点续传无关,X-Requested-With 用于标记 AJAX 请求,Cache-Control 控制缓存,Accept-Encoding 协商压缩。

Q10. 一个生产级 SSE 对话系统通常需要哪些工程措施?

多选题 · hard

  • A.定期发送心跳保活← 正确答案
  • B.指数退避自动重连← 正确答案
  • C.消息编号断点续传← 正确答案
  • D.强制关闭所有缓存

答案:ABC。心跳(注释行或 ping 事件)防代理空闲超时;指数退避重连防止重连风暴;消息编号 + Last-Event-ID 保证断线不丢内容,三者是生产级标配。强制关闭所有缓存既没必要也不是主流做法,正确做法是合理设置 Cache-Control 避免代理缓冲 SSE 流。