后端把总结生成出来只算完成了一半,另一半是让用户在浏览器里看着结果一个字一个字蹦出来。第一次实现我直接用了 EventSource,连上就收数据,省心。可一接入真实接口就卡住了:总结接口必须 POST 视频 id,还要在请求头里带 Bearer token 做鉴权,而浏览器原生 EventSource 只支持 GET、无法自定义请求头,这条路根本走不通。折腾了一下午,我换成 fetch + ReadableStream 手动解析 SSE 协议,才把”边下载边展示总结”跑通。下面是这个解析方案从协议格式到断线重连的完整实现,每一步都标注了踩过的坑。
一、为什么用 fetch + ReadableStream
浏览器原生 EventSource 只支持 GET,且不能设自定义 Header。AI 总结要 POST + Bearer token,必须用 fetch + ReadableStream。
async function streamSummary(videoId) {
const response = await fetch('/api/summary/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ video_id: videoId })
})
const reader = response.body.getReader()
const decoder = new TextDecoder()
// 持续读 chunk
while (true) {
const { done, value } = await reader.read()
if (done) break
// 处理 chunk
}
}
这段代码解决的是”带鉴权的流式请求”问题。fetch 的灵活性换来两个能力:请求方法不受限,想 GET 就 GET,想 POST 就 POST;请求头完全自定义,Authorization、Content-Type 想带什么带什么。response.body 是一条 ReadableStream,用 getReader() 逐块读取,每读到一段字节就处理一段。代价也很直接:EventSource 自动做的协议解析、自动重连全部失效,都得自己写。我们评估过用 GET + query 参数塞 token 的歪路,把 token 放 URL 里既不安全又容易被日志记录,果断放弃。
二、SSE 协议格式
SSE 不是新协议,就是一段段按固定格式拼的文本。后端把事件按下面的样子推过来,前端按格式拆解即可:
event: stage
data: {"stage": "downloading", "percent": 10}
event: token
data: {"text": "视频"}
event: done
data: {"summary_id": 123}
每条消息:
event: <type>—— 事件类型data: <text>—— 数据(单行;多行用多个 data:)- 消息间用
\n\n分隔
我们把事件类型定成 stage、token、done、error 四种。stage 负责阶段进度,token 负责逐字推送,done 收尾,error 报错。这样设计的原因很实际:前端按类型分发处理逻辑,每种事件各干各的,互不干扰。这里踩过一个坑:最早后端把进度和文本混在一条 data 里,前端要写一坨判断拆数据,后来干脆把事件类型拆细,前后端各管各的,反而简单了。多行 data 用多个 data: 行拼起来,这是协议规范允许的,前端拼字符串时要注意顺序。
三、核心解析函数
解析逻辑收拢成一个 parseSSE 函数,入参是 fetch 的 response 和一组事件处理函数,页面其他代码不用关心协议细节。
async function parseSSE(response, handlers) {
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
// 解码当前 chunk,stream 模式保持跨 chunk 字符
buffer += decoder.decode(value, { stream: true })
// 按 \n\n 切消息(最后一个可能不完整,留给下次)
const messages = buffer.split('\n\n')
buffer = messages.pop() // 最后一段是残的
for (const msg of messages) {
if (!msg.trim()) continue
// 解析 event / data
const lines = msg.split('\n')
let event = 'message'
let data = ''
let id = ''
for (const line of lines) {
if (line.startsWith('event:')) {
event = line.slice(6).trim()
} else if (line.startsWith('data:')) {
data += line.slice(5).trim()
} else if (line.startsWith('id:')) {
id = line.slice(3).trim()
}
}
try {
const payload = data ? JSON.parse(data) : null
if (handlers[event]) {
handlers[event](payload)
}
} catch (e) {
console.error('SSE 解析失败:', data, e)
}
}
}
}
这个函数解决两个典型问题。一是 TCP 拆包:网络层传输数据是流式的,一个 SSE 消息可能被拆进两个 chunk,也可能一个 chunk 装下好几条消息,所以用 buffer 攒数据、按 \n\n 切消息,最后一段不完整的留在 buffer 里等下一轮。二是中文乱码:中文是三个字节的 UTF-8 编码,一个字符恰好可能被切在 chunk 边界上,TextDecoder.decode(value, { stream: true }) 会自动缓存不完整的字节序列,等下一块数据来了再补全,这是中文场景下最容易踩的坑,当初我们就是没加 stream: true 导致总结文字随机出现乱码。id 字段顺手解析出来,为断线重连做断点续传预留位置。
四、实际使用
解析函数封装好后,业务侧用起来就是”声明我要处理哪些事件”。
async function startSummary(videoId) {
const response = await fetch('/api/summary/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`
},
body: JSON.stringify({ video_id: videoId })
})
if (!response.ok) {
showError('请求失败')
return
}
let summaryText = ''
await parseSSE(response, {
// 阶段进度
stage: (data) => {
const stages = {
downloading: '下载视频中',
transcribing: '提取字幕中',
summarizing: 'AI 总结中'
}
updateProgress(stages[data.stage], data.percent)
},
// AI token 流式
token: (data) => {
summaryText += data.text
renderSummary(summaryText)
},
// 完成
done: (data) => {
showActions(data.summary_id)
},
// 错误
error: (data) => {
showError(data.message)
}
})
}
调用前先检查 response.ok,接口返回非 2xx 就直接报错,不走流解析。stage 事件把后端的状态机翻译成用户看得懂的中文:下载中、提取字幕中、AI 总结中,配合百分比让用户知道后台在干活。token 事件每来一段就把累计文本重新渲染一次,用户看到的就是逐字生成的效果。done 到来才展示操作按钮,避免生成过程中用户点了复制导出一半的内容。这一层把协议细节全部屏蔽,页面代码读起来跟普通事件监听一样清爽。
五、UI 实时渲染
流式数据到了,页面要跟着刷。渲染函数把 Markdown 文本交给 marked 转成 HTML 填进容器。
function renderSummary(text) {
// 边生成边渲染(marked 解析 Markdown)
const html = marked.parse(text)
document.getElementById('summary').innerHTML = html
}
性能优化(避免每次都解析全部):
let lastRenderLength = 0
function renderSummaryIncremental(text) {
// 只渲染新增部分
if (text.length - lastRenderLength > 50) {
const html = marked.parse(text)
document.getElementById('summary').innerHTML = html
lastRenderLength = text.length
}
}
第一版我每收到一个 token 就全量渲染一次,模型生成快的时候每秒几十次标记解析,页面明显掉帧。后来加了增量节流:累计新增超过 50 个字符才渲染一次,视觉上依然是流畅的逐字输出,渲染频率却降了一个数量级。这一节流的思路跟防抖类似但方向不同,防抖是”停下才执行”,这里是”攒够才执行”。另外 marked.parse 输出是可信的,因为我们自己的总结内容,没有外部输入拼进去,直接 innerHTML 没问题。
六、AbortController 取消
用户看一半不想等了,要点”停止生成”。取消动作要能让 fetch 连接真正断开,而不是只在界面上装死。
let controller = null
function startSummary(videoId) {
controller = new AbortController()
fetch('/api/summary/stream', {
method: 'POST',
signal: controller.signal,
// ...
})
}
function stopSummary() {
controller?.abort()
showMessage('已停止')
}
AbortController 是 fetch 官方的取消机制,abort() 一调,请求立即中断,同时后端会收到连接断开,可以停掉还在烧算力的模型推理。这里有个容易被忽略的点:controller 必须声明在函数外,因为 startSummary 和 stopSummary 要共享同一个信号对象,否则取消的是另一个连接。我们上线后还遇到过一次用户连续点击”开始总结”,上一个连接没关、新的又开了,最后统一在 startSummary 开头先 controller?.abort() 再建新连接,问题解决。
七、错误处理
流式请求的错误分几类,每一类的处理方式不同,不能一锅烩。
try {
const response = await fetch(url, options)
if (!response.ok) {
if (response.status === 401) {
redirectToLogin()
} else if (response.status === 429) {
showQuotaExceeded()
}
return
}
await parseSSE(response, handlers)
} catch (e) {
if (e.name === 'AbortError') {
// 用户主动取消
return
}
showError('网络异常:' + e.message)
}
HTTP 状态码先分流:401 表示 token 失效,直接跳登录页;429 表示今日次数用尽,弹配额提示。流解析过程中抛出的异常进 catch,其中 AbortError 是用户主动取消,静默返回不打扰;其他异常提示网络异常并附错误信息。这样分类是为了给用户正确反馈——把”次数用完了”和”网络断了”混成一句”请求失败”,用户会一头雾水。后端在流中也会推 error 事件(比如视频链接无效),这类业务错误由解析函数里对应 handler 处理,跟传输层错误分开。
八、显示进度
阶段进度条让用户清楚当前卡在哪一步,下载、转字幕、总结三步都有明确的视觉反馈。
<div id="progress" class="progress-bar">
<div id="progressFill" class="bg-blue-500 h-2"
style="width: 0%"></div>
<span id="progressText">准备中...</span>
</div>
JS 脚本段开始
function updateProgress(stage, percent) {
document.getElementById('progressFill').style.width = `${percent}%`
document.getElementById('progressText').textContent =
`${stage} ${percent}%`
}
JS 脚本段结束
进度条用两层结构实现:外层是容器,内层 progressFill 的宽度表示百分比,文本单独一个 span。样式里写死 width: 0% 作为初始状态,避免页面加载时出现一条满格进度条。这里的坑在于 stage 文本和 percent 要一起更新,否则会出现”下载中 95%”和”提取字幕中 95%”互相错位的尴尬画面,所以 updateProgress 同时接收 stage 和 percent,一步到位。
九、思维导图渐入
总结完成后,把 Markdown 大纲渲染成思维导图,让用户一眼扫到视频核心结构。渲染函数挂在 done 事件之后调用。
function renderMindmap(mindmapMarkdown) {
const { root } = transformer.transform(mindmapMarkdown)
const container = document.getElementById('mindmap')
container.innerHTML = ''
const mm = markmap.create(container)
mm.setData(root)
mm.fit()
}
调用时机比函数本身更重要。之前我把 renderMindmap 挂在流式数据的第一段到达时就执行,结果容器还没显示、宽高为 0,画出来一张空图。改成等 done 事件后再渲染,所有数据齐了,容器也已展示,一次成型。container.innerHTML = '' 先清空,防止用户多次生成时旧图残留。
十、自动滚动到底部
总结一长,页面要跟着内容走,让用户始终看到新写出来的部分。
function appendToken(text) {
summaryText += text
renderSummary(summaryText)
// 自动滚动
const container = document.getElementById('summary')
window.scrollTo({
top: container.offsetTop + container.offsetHeight,
behavior: 'smooth'
})
}
滚动目标算的是”容器顶部位置 + 容器自身高度”,也就是容器底部边缘,保证最后几行文字可见。behavior: 'smooth' 让滚动平滑过渡,不至于每来一个字就跳一下。这里有个取舍:如果用户手动往上翻看前面的内容,自动滚动会把他拽回去,体验很糟。我们加了个判断,只有滚动位置贴近底部时才触发自动滚动,用户翻回去看历史内容就不再打扰。
十一、React/Vue 集成
平台是原生 JS,但 SSE 模式也适用于框架。同样的解析函数,在 React 和 Vue 里各有一层适配。
// React
function SummaryView() {
const [text, setText] = useState('')
useEffect(() => {
const controller = new AbortController()
fetch('/api/summary/stream', {
method: 'POST',
signal: controller.signal,
body: JSON.stringify({ video_id })
}).then(async response => {
await parseSSE(response, {
token: data => setText(t => t + data.text)
})
})
return () => controller.abort()
}, [])
return <div dangerouslySetInnerHTML={{ __html: marked(text) }} />
}
<!-- Vue 3 -->
Vue 模板逻辑段
import { ref, onUnmounted } from 'vue'
const text = ref('')
let controller = null
onMounted(async () => {
controller = new AbortController()
const response = await fetch(url, {
method: 'POST',
signal: controller.signal
})
await parseSSE(response, {
token: data => text.value += data.text
})
})
onUnmounted(() => controller?.abort())
JS 脚本段结束
框架版本的核心套路一样:组件挂载时建连接、订阅 token 事件更新状态,卸载时 abort() 释放连接。区别只在状态更新方式——React 用 setText 函数式更新,Vue 直接改 ref 的值,二者都天然兼容这种累加式渲染。parseSSE 这段纯逻辑不依赖任何框架,直接复用,这也是当初把它写成独立函数而不是页面内联代码的原因。如果团队后来迁到框架,这层代码一行不用动。
十二、断线重连
网络一抖,连接就断,总结可能只生成了一半。原生 EventSource 自带自动重连,fetch 方案得自己写,重连还要注意退避,不能连不上就一直猛冲。
async function streamWithRetry(url, options, handlers, maxRetry = 3) {
let attempt = 0
while (attempt < maxRetry) {
try {
const response = await fetch(url, options)
await parseSSE(response, handlers)
return // 成功
} catch (e) {
if (e.name === 'AbortError') return
attempt++
if (attempt < maxRetry) {
showMessage(`网络异常,${attempt * 2} 秒后重试...`)
await new Promise(r => setTimeout(r, 2000 * attempt))
}
}
}
showError('网络异常,请稍后再试')
}
重试逻辑带线性退避:第一次失败等 2 秒,第二次等 4 秒,最多重试 3 次,之后放弃并提示。用户主动取消(AbortError)直接返回,不参与重试。真正能断点续传的做法是用 Last-Event-ID 让后端从断点续推,我们后端支持这个头,但前端还在持续优化,当前方案是失败后从流开头重新拉,配合用户手动重试按钮兜底。重连风暴也要防——网络抖动时如果几十个客户端同时重连,会把网关打爆,退避就是干这个的。
十三、复制与导出
生成完的总结要能带走。复制用剪贴板 API,导出生成 Markdown 文件下载。
function copySummary() {
navigator.clipboard.writeText(summaryText)
.then(() => showMessage('已复制'))
}
function exportMarkdown() {
const blob = new Blob([summaryText], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `summary-${Date.now()}.md`
a.click()
URL.revokeObjectURL(url)
}
剪贴板 API 要求页面处于聚焦状态,用户点击按钮时天然满足。导出走 Blob + 临时链接下载,文件名带时间戳避免覆盖。这里踩过一个坑:下载链接用完必须 revokeObjectURL 释放,否则内存里的 Blob 一直占着,页面长时间挂着会慢慢变大。还有环境差异——剪贴板 API 需要 HTTPS 或 localhost,本地 http 调试会静默失败,现在开发环境统一配了 HTTPS 代理才消停。
十四、踩过的坑
SSE 相关的坑密集且隐蔽,大多发生在数据流边界和代理层。按踩到的先后列:
- chunk 边界字符被切:中文字符多字节,跨 chunk 时被切断。
TextDecoder.decode(value, { stream: true })自动处理。 - 多行 data:SSE 规范允许多个
data:行,平台用data += line拼接。 - SSE 注释:以
:开头的行是注释,heartbeat等。过滤掉。 - HTTP/1.1 6 并发限制:浏览器同域 6 个并发 HTTP/1.1。AI 总结单连接够用。
- 流式响应 CORS:需要 CORS 允许
text/event-stream。 - 流式响应被代理缓冲:Nginx 要
proxy_buffering off。 - Nginx SSE 立即 flush:
proxy_buffering off+gzip off(避免 chunk 边界混淆)。 - 大 body 上传:视频链接本身很小,但传大文件时要加超时。
- 重连风暴:网络抖动时频繁重连。退避 + 限流。
chunk 边界乱码排第一,因为它随机出现、难以复现,排查花了两天。Nginx 缓冲那个坑也折磨人:本地开发一切正常,一上线总结就”整段整段蹦”而不是逐字输出,查了一圈才发现是 Nginx 默认缓冲把流攒起来一次性转发,proxy_buffering off 一开立刻恢复。gzip 压缩流式响应会导致字节被压缩器重新编排,加上 gzip off 才稳妥。这几个代理层的坑,浏览器端代码再怎么写都绕不开。
十五、调试
流式接口调试比普通接口麻烦,数据一直在动,断点断不住。我们留了一个调试模式,把原始事件原样打到控制台。
// 调试模式
function parseSSEDebug(response, handlers) {
// 原始事件回调
const debugHandlers = {
...handlers,
_raw: (event, data) => {
console.debug('[SSE]', event, data)
}
}
// 包装原 handler
// ...
}
Network 面板可以看到”EventStream”类型的事件流。
调试时主要看两样东西。一是 Network 面板里的 EventStream 请求,点开能实时看到每一条事件推送的时间线,先确认后端有没有按预期推。二是控制台里 _raw 打出的原始事件,确认 event 类型和 data 内容是否匹配前端预期。踩过最典型的调试坑是:后端报错后连接直接断开,但浏览器侧只看到连接结束,没有任何错误信息,后来在后端把错误写进 SSE 的 error 事件再推给前端,问题才暴露出来。给前端调试留个后门,排障时间能省一半。
十六、性能监控
流式体验好不好,两个数字说了算:首字延迟和总时长。这两个指标埋点上报,用来衡量生成体验。
let firstByteTime = null
let totalTime = null
async function startStream() {
const start = performance.now()
const response = await fetch(url, options)
const reader = response.body.getReader()
const { value: firstChunk } = await reader.read()
firstByteTime = performance.now() - start
console.log(`首字延迟: ${firstByteTime.toFixed(0)}ms`)
// 继续读
// ...
totalTime = performance.now() - start
metrics.track('sse_complete', { duration: totalTime })
}
首字延迟是用户感知的起点:发出请求到第一个字节回来,中间隔着网络往返、后端排大队、模型首 token 生成,这个值超过 3 秒用户就开始不耐烦。总时长是完整生成一遍的时间,和总结长度、模型速度正相关。这两个指标分开埋点,能帮我们定位瓶颈到底在网络、后端还是模型侧。当初上线第一周发现首字延迟平均 2.8 秒,一查是后端把视频下载排在了总结之前,调整成”下载完成先发 stage 事件”后,用户至少能立刻看到进度,体感好了不少。
十七、与 EventSource 对比
选型时最纠结的就是这个对比。下面从工程角度把两条路摆开:
| 维度 | EventSource | fetch + ReadableStream |
|---|---|---|
| Method | GET only | 任意 |
| 自定义 Header | 部分浏览器受限 | 完全支持 |
| Body | 仅 URL query | JSON / FormData |
| 断线重连 | 自动 | 手动 |
| 协议 | SSE | 任意流 |
平台需要 POST + Bearer token,必须用 fetch + ReadableStream。这张表决定方案的其实就前两行:我们的接口既要 POST 又要带 token,EventSource 两项都满足不了,后面的自动重连优势再大也白搭。反过来,如果你的接口恰好是 GET、不需要鉴权、又想省事,EventSource 的自动重连和自动解析确实香。我们还特意确认过 fetch 方案的浏览器兼容性,现代浏览器对 ReadableStream 的支持已经很完整,我们的目标用户群没有历史包袱。
十八、最佳实践清单
整套方案跑通后,把容易出错的地方沉淀成一份清单,新同学照着做就能少踩坑:
- 用
TextDecoder(stream: true); - 用 buffer 攒消息,按
\n\n切; - 残消息留 buffer 下次再处理;
- AbortController 支持取消;
- 错误分类(网络/超时/取消);
- 自动滚动 + 实时渲染;
- 复制/导出辅助功能。
清单里每条都对应一次真实的事故。第 1、2、3 条解决数据边界问题,是解析正确性的地基;第 4 条解决用户控制权问题;第 5 条解决反馈准确性问题;第 6、7 条是体验层的加分项。按这份清单从头写一遍解析器,绕开所有已知的坑,半天能跑通。到这里,SSE 流式解析从协议到工程化的落地链路就完整了。
常见问题(FAQ)
Q1:为什么不用 EventSource?
POST + Header 限制,EventSource 用不了。
Q2:SSE 怎么取消?
AbortController.abort()。
Q3:SSE 自动重连?
原生 EventSource 自动重连;fetch 要手动实现。