前端解析 SSE 流式数据方法详解(AI 视频下载总结器的实时响应处理)

后端把总结生成出来只算完成了一半,另一半是让用户在浏览器里看着结果一个字一个字蹦出来。第一次实现我直接用了 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 的支持已经很完整,我们的目标用户群没有历史包袱。

十八、最佳实践清单

整套方案跑通后,把容易出错的地方沉淀成一份清单,新同学照着做就能少踩坑:

  1. 用 TextDecoder(stream: true);
  2. 用 buffer 攒消息,按 \n\n 切;
  3. 残消息留 buffer 下次再处理;
  4. AbortController 支持取消;
  5. 错误分类(网络/超时/取消);
  6. 自动滚动 + 实时渲染;
  7. 复制/导出辅助功能。

清单里每条都对应一次真实的事故。第 1、2、3 条解决数据边界问题,是解析正确性的地基;第 4 条解决用户控制权问题;第 5 条解决反馈准确性问题;第 6、7 条是体验层的加分项。按这份清单从头写一遍解析器,绕开所有已知的坑,半天能跑通。到这里,SSE 流式解析从协议到工程化的落地链路就完整了。

常见问题(FAQ)

Q1:为什么不用 EventSource?

POST + Header 限制,EventSource 用不了。

Q2:SSE 怎么取消?

AbortController.abort()。

Q3:SSE 自动重连?

原生 EventSource 自动重连;fetch 要手动实现。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
其AI的头像其AI普通用户

相关推荐

返回顶部