尧图精选

Vue 2.6接入SSE流式输出:从XHR限制到fetch封装完整指南

🕒 发布时间:2026/9/18 22:15:08 📁 来源:尧图网络
先说结论这个问题的锅通常不在Vue本身而在你对SSE的接入方式上。Vue 2.6只是一个视图层框架它不负责网络请求真正决定能不能拿到流式数据的是你选的HTTP客户端。我见过太多人在Vue项目里无脑用axios去接SSE接口结果发现数据死活是一坨一次性返回的完全没“流”起来然后开始怀疑后端、怀疑中间件、怀疑人生。我在实际项目里踩过这个坑后来花了整整一个下午把整个链路拆了个底朝天从网关到nginx再到浏览器最后定位到问题出在三个层面XHR的天然限制、代理层的缓冲策略、以及EventSource的格式要求。这篇就把完整的排查思路、正确的接入方式、以及一套我在生产环境里用的Vue 2.6 SSE工具封装一次性讲清楚。1. 先把问题说清楚你看到的现象到底是什么1.1 典型症状数据一次性返回看不到逐字输出如果你是在做AI聊天对话类的前端你期望的效果是后端每生成一个token就推一次前端逐字渲染像ChatGPT那样一个字一个字蹦出来。但很多人在Vue 2.6里接入SSE后遇到的现象是页面卡顿一段时间然后消息一次性完整显示用axios的话onmessage或者then回调只在请求结束才触发后端日志明明显示在持续输出前端就是没有实时响应偶尔还会报stream disconnected before completion: idle timeout waiting for sse这其实就是典型的“流式返回失效”。数据不是没到而是被某个环节给“攒”住了等全部攒完才一次性交给前端。这个环节绝大多数情况下是XHR。1.2 根因初判axios的底层XHR根本不支持流式读取Vue 2.6时代大家最常用的HTTP客户端是axios而axios底层是XMLHttpRequest。XHR这个老古董的设计目标就是“请求-响应”这样的完整事务它没有暴露一个可读流给你去一帧一帧地消费响应体。当你把SSE的响应交给XHR去处理浏览器会把整个响应体缓冲在内存里直到连接关闭才触发load事件。换句话说axios压根不是用来干这事的。你用axios接SSE等于是拿普通快递去送冷链活物送是能送到但到手里已经不“活”了。所以第一步要接受一个事实在Vue 2.6项目里接入SSE并能流式输出要么用原生EventSource要么用fetch ReadableStream没有第三条特别省事的路。1.3 顺带复习SSE到底是个什么协议格式SSEServer-Sent Events是个很朴素的协议。服务端把Content-Type设为text/event-stream然后以一定的格式持续往连接里写数据。前端不需要发任何请求连接一旦建立服务端就可以不间歇地推数据。它的数据格式长这样id: 1 event: message data: {content: 你} id: 2 event: message data: {content: 好}每条消息以空行分隔。data:是消息内容event:是事件类型id:是消息ID。如果一行写不下可以用多个data:行最终会以换行符拼接。了解了这个格式后面排查问题和写解析器才心里有数。EventSource 可以原生解析这个格式但 fetch 就不行fetch 拿到的是字节流的 ReadableStream需要你自己按这个格式去拆消息。2. vue2.6访问sse的三种接入方式对比2.1 方式一原生EventSource最省心但有局限EventSource 是浏览器内置的SSE客户端用法非常简单。在Vue 2.6组件里大概是这样const source new EventSource(/api/chat-stream) source.onopen () { console.log(连接已建立) } source.onmessage (event) { // 每次收到一条消息这里就会触发一次 // 对于AI对话event.data 是一个JSON字符串 const data JSON.parse(event.data) this.content data.content } source.onerror (err) { console.error(连接异常, err) source.close() }这是最正宗的SSE接入方式天然支持流式来一条打一条。但它有几个限制在Vue 2.6项目里特别坑只能发送GET请求。EventSource不支持自定义请求方法也不支持请求体。如果你的SSE接口需要POST带参数比如把用户问题放进body里那就没法直接用EventSource。没法自定义请求头。想加Authorization token要么把token放到URL的query参数里要么后端允许你用cookie否则你会被卡住。断线重连是内置的但重连逻辑不一定符合你的业务场景。比如遇到401的时候它还傻乎乎地重连就很尴尬。所以在生产环境里EventSource适合接口简单、鉴权靠cookie的团队。如果要对接那些“POST Authorization头”的AI接口基本要改用第二种方式。2.2 方式二fetch ReadableStreamVue 2.6项目里的主力方案有人会问EventSource用不了那还有什么方案答案就是 fetch。fetch 的响应体是真正的Web Stream你可以通过response.body.getReader()拿到一个字节流读取器边读边消费。核心代码如下const response await fetch(/api/chat-stream, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${token} }, body: JSON.stringify({ question: this.input }) }) if (!response.ok) { throw new Error(HTTP error! status: ${response.status}) } const reader response.body.getReader() const decoder new TextDecoder(utf-8) while (true) { const { done, value } await reader.read() if (done) break const chunk decoder.decode(value, { stream: true }) // 处理chunk里面可能是半截消息也可能是多条消息 }这个方案最灵活能带Header、能POST、能控制断开时机就是需要自己处理数据拆分和解析。实际上更大的坑在后面fetch 不会自动按SSE的data:格式帮你分帧你拿到的 chunk 可能在消息中间断开也可能粘着多条消息。你需要自己实现一个缓冲协议解析器。2.3 方式三终止掉那些“拟流式”的hack方案网上还有不少偏门方法比如用axios配responseType: stream或者用某个库去包一层。在浏览器端responseType: stream本质上还是XHR那一套用来接SSE一样拿不到即时事件。还有一些基于WebSocket的方案完全偏离了SSE的语义需要服务端改协议代价太大。我见过有人用“轮询”去模拟流式效果每500毫秒拉一次数据。如果后端接口本来就不是为SSE设计的临时这么干确实能看但这就是饮鸩止渴不仅浪费请求数响应延迟也完全没法看。我的建议是如果确定要用SSE就直接用标准方案别搞旁门左道。3. 流式返回失效的深层原因排查3.1 代理层Nginx缓冲是头号元凶之一很多人前端的代码完全正确EventSource也用上了但在测试环境正常一上生产就变成一次性返回。这种环境差异十有八九是代理层在作祟。Nginx默认会开启proxy_buffering也就是说后端返回的数据会先被Nginx攒在缓冲区里等缓冲区满了或者整个响应结束了才转发给浏览器。对普通接口来说这是优化对SSE来说这是谋杀。你需要在Nginx配置里把该路径的缓冲关掉location /api/chat-stream { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; proxy_set_header Connection ; proxy_http_version 1.1; chunked_transfer_encoding on; }proxy_read_timeout也很重要它控制的是两次读操作之间的最大间隔。如果SSE长时间没有数据Nginx默认60秒就会断开连接前端就会收到一个连接中断的错误。调大这个值能缓解 idle timeout 的问题但真正治本的是后端要发心跳包。3.2 心跳机制解决idle timeout waiting for sse的关键后台服务不是时时刻刻都有数据要推送的比如AI思考了10秒才开始吐字这段时间连接上一帧数据都没有。网关层的“空闲超时”检测就会误以为连接已经晾凉了于是主动掐断。你看到的stream disconnected before completion: idle timeout waiting for sse基本就是这么来的。解决办法是服务端定期往连接里写“注释行”。SSE协议允许发以冒号开头的行这种行是注释会被客户端忽略。它的作用就是告诉中间层连接还活着别掐。: keep-alive注释后面必须跟一个空行。一般建议15到30秒发一次心跳。如果后端是Java的Spring WebFlux可以直接定一个定时任务往SseEmitter里发如果是Node.js直接res.write(: keep-alive\n\n)即可。我在实际项目里踩过一次这个坑前端始终报Error: stream disconnected before completion: idle timeout waiting for sse服务端说是正常推送最后还是抓包发现服务端已经好几分钟没写任何字节了网关直接断开。加了个15秒的心跳注释后问题立刻消失。3.3 缓冲和压缩的其他排查点代理层之外还有两个容易被忽略的缓冲第一是GZip压缩。如果代理层或Web服务器对SSE响应启用了GZip服务端可能会为了更高的压缩率把数据攒起来不吐出来流式效果就又没了。SSE路径建议直接关闭压缩gzip off;第二是浏览器或本地开发服务器的缓冲。如果你前面还套了一层webpack-dev-server的proxy它默认也可能缓冲响应。在Vue 2.6项目里vue.config.js里的devServer代理需要配置devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, compress: false, // 关键关闭代理缓冲 onProxyRes: (proxyRes) { proxyRes.headers[Cache-Control] no-cache } } } }另外开发时如果用了webpack-dev-server还需要把proxy的ws和secure按需求配好。但最关键的还是确保你的代理不缓冲。3.4 响应头校验Content-Type不对一切都是白干后端接口返回的头必须是Content-Type: text/event-stream而且要带Cache-Control: no-cache。如果Content-Type不对EventSource会在浏览器端直接抛错触发onerror。fetch方案对Content-Type的要求没那么严格但如果不对中间层还是有可能把响应当成普通HTTP响应去缓冲。服务端还需要把X-Accel-Buffering: no传回来这是给nginx看的相当于“即使你没关proxy_buffering我看到这个头也会绕过缓冲”。这个头在后端不方便改代理层配置时特别有用。4. 实操在Vue 2.6里封装一个生产可用的SSE客户端4.1 工具类的核心设计因为EventSource没法带Header和POST body我一般会在Vue 2.6项目里封装一个基于fetch的SSE工具类。这个类要解决四件事把fetch的字节流按SSE协议拆成一条条消息支持POST和自定义Header支持流式解析和事件分发内置断线重连和超时处理这里贴一下核心代码基于我自己的项目实践精简过/** * SSE客户端基于fetch * 用法 * const sse new SSEStream(/api/chat-stream, { * method: POST, * headers: { * Content-Type: application/json, * Authorization: Bearer xxx * }, * body: JSON.stringify({ question: 你好 }), * onMessage: (data) { console.log(data) }, * onError: (err) { console.error(err) } * }) * sse.start() */ class SSEStream { constructor(url, options {}) { this.url url this.options options this.reader null this.abortController null this.buffer this._closed false } async start() { this.abortController new AbortController() const { method GET, headers {}, body null } this.options try { const response await fetch(this.url, { method, headers, body, signal: this.abortController.signal }) if (!response.ok) { throw new Error(HTTP error! status: ${response.status}) } // 关键拿到响应体的可读流 const reader response.body.getReader() this.reader reader const decoder new TextDecoder(utf-8) while (true) { const { done, value } await reader.read() if (done) break this.buffer decoder.decode(value, { stream: true }) this.parseBuffer() } } catch (err) { // AbortError是用户主动关闭不触发onError if (err.name ! AbortError) { this.options.onError this.options.onError(err) } } } parseBuffer() { // SSE消息以空行分隔 const parts this.buffer.split(\n\n) // 最后一段是没结束的留在buffer里 this.buffer parts.pop() for (const part of parts) { this.parseMessage(part) } } parseMessage(part) { let data let event message const lines part.split(\n) for (const line of lines) { if (line.startsWith(data:)) { const payload line.slice(5).trim() data payload \n } else if (line.startsWith(event:)) { event line.slice(6).trim() } else if (line.startsWith(:)) { // 注释行忽略 continue } } if (data) { const payload data.slice(0, -1) // 去掉最后的换行 if (this.options.onEvent) { this.options.onEvent(event, payload) } else { this.options.onMessage this.options.onMessage(payload) } } } close() { this._closed true if (this.abortController) { this.abortController.abort() } } } export default SSEStream这个类的关键点在parseBuffer方法。由于网络传输的不可控性一次reader.read()拿到的字节可能只是半条消息也可能包含了好几条消息。如果直接按\n\n切分会把不完整的最后一段丢掉。所以必须把不完整的尾段放回this.buffer里等下一个chunk到了再接起来继续拼。4.2 在Vue 2.6组件里怎么用在Vue 2.6组件的methods里定义一个发送方法组件销毁时记得关闭流否则连接会一直挂着导致内存泄漏。export default { data() { return { sseClient: null, content: , input: } }, methods: { sendMessage() { this.content this.sseClient new SSEStream(/api/chat-stream, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${localStorage.getItem(token)} }, body: JSON.stringify({ question: this.input }), onMessage: (payload) { const data JSON.parse(payload) // 流式追加渲染 this.content data.content }, onError: (err) { console.error(SSE error:, err) } }) this.sseClient.start() }, stopStream() { if (this.sseClient) { this.sseClient.close() } } }, beforeDestroy() { // 组件销毁时一定要关闭连接 this.stopStream() } }小技巧每次收到消息后往this.content里追加内容时Vue的响应式系统都会触发更新。但在渲染大量文本时频繁的更新会有点卡。实测下来AI对话场景每秒几十次更新对Vue 2.6来说完全能扛住但如果你要渲染的是长列表或Markdown重排版建议做节流比如每50ms统一更新一次。4.3 断线重连和心电监护生产环境的网络不可能永远稳断线重连是必备能力。我在工具类里加了一个简单的自动重连逻辑当读流done变为true且不是因为用户主动关闭时自动等待2秒再重启。async start() { // ... 原有逻辑 while (true) { const { done, value } await reader.read() if (done) { // 流被服务端关闭 if (!this._closed) { setTimeout(() { this.start() }, 2000) } break } // ... 处理 } }但要注意重连时如果服务端不认历史会话你重新发起的连接就变成了“新会话”聊天上下文会丢。这个通常需要服务端在URL或Header里带一个会话ID来支持恢复。前端要做的是把会话ID存到一个变量里重连时带上。另外如果你自己的服务端没有做心跳作为前端你也可以在工具类里加一个“看门狗”——超过30秒没收到任何消息就主动断开重连。这个在AI场景里很管用因为AI可能在长时间思考。实现也简单每次收到消息就重置一个定时器超时就abort()。5. 常见问题排查与实操技巧5.1 一张表解决80%的SSE接入问题我在项目里给组员做过一个排查表遇到SSE问题对号入座基本能解决80%的情况现象排查方向处理办法数据一次性返回无流式效果代理层缓冲关闭nginx的proxy_buffering检查devServer代理配置EventSource直接走onerror响应头Content-Type不对或跨域确保后端返回 text/event-stream配置CORS允许连接过一会儿被断开空闲超时缺心跳服务端15~30秒发一次注释心跳调大proxy_read_timeout消息出现残缺、乱码前端解析器没有处理半包用缓冲按\n\n切分的方式解析带不了token和POST参数EventSource自身限制换用fetchReadableStream方案大量消息时界面卡顿渲染频率太高做分片渲染或节流Vue 2.6里60帧内最多渲染一次在开发环境好用生产环境挂网关或Nginx配置差异对比两边的代理配置、缓冲策略、超时时间5.2 “标签返回未完整”的处理思路搜索热词里有“标签返回未完整怎么处理”这个其实说的是AI输出内容的HTML或Markdown标签在流式中途处于“未闭合”状态。比如AI返回一个strong标签流式过程中你收到的可能是stron如果直接渲染浏览器可能解析错乱。常规做法是不要边收边用v-html渲染。先用纯文本模式把流式内容追加到缓冲里等收到结束标记比如[DONE]或者整个流关闭后再把完整的文本一次性去做Markdown或HTML渲染。如果确实要实时渲染就用“增量解析器”一类的库前端有markeddompurify这类组合输入增量片段时尽量渲染但不要破坏结构。最稳妥的办法还是最终态渲染流式过程用纯文本展示结束再格式化。我在自己的聊天组件里就是这么干的流式阶段只塞文本到content等onDone回调触发后用marked把完整内容转成HTML再替换。肉眼看起来会有一个“纯文本变富文本”的轻微跳动但在可接受范围内至少不会出现标签错乱。5.3 一条curl命令快速验证后端有没有问题很多前端同学排查问题半天其实问题根本不在前端。我强烈建议第一步先用curl把后端接口打一遍。如果是POST的有body的接口这样用curl -N -X POST http://localhost:8080/api/chat-stream \ -H Content-Type: application/json \ -d {question:你好} \ --no-buffer重点在-N和--no-buffer意思是关闭curl的缓冲这样curl会边收边打印。如果curl能看到数据逐字蹦出来说明后端和网络链路是通的问题在浏览器端如果curl也是攒到最后才一次性打印那问题多半在后端或者中间代理层跟前端没关系。这一下就能把问题范围缩小一半。5.4 分享两个Vue 2.6场景下的独门技巧最后分享两个我在实际项目里沉淀的小技巧。第一个是关于AceEditor或CodeMirror这类大文本编辑器。如果在Vue 2.6做AI代码生成流式输出时千万别直接往编辑器里塞整段文本。编辑器每次全量重绘都非常伤性能。我一般会把流式文本缓存到一个buffer里每50ms批量刷一次到编辑器或者只用session.replaceRange只替换增量部分。实测同样生成1000行代码整段替换大概耗时600ms而增量插入只有几十ms。第二个是关于浏览器标签页切后台。当用户切到别的标签页时浏览器会节流定时器SSE消息的读取也可能变慢。如果AI对话场景对实时性要求很高可以在visibilitychange事件里做处理切回页面时如果发现连接已经断开立即重连而不是傻等reader.read()返回。document.addEventListener(visibilitychange, () { if (!document.hidden this.sseClient this.abortFlag) { this.reconnect() } })其实这套东西在Vue 3里思路一模一样只是2.6在组件销毁时的写法是beforeDestroy而不是onBeforeUnmount其他原理完全通用。我在实际排查过几十个SSE问题后最大的体会是响应式框架本身永远不是SSE问题的瓶颈瓶颈永远在“连接管理”和“数据解析”这两个被你忽略的边缘环节上。把协议吃透把代理层看清楚把清理动作做规范流式输出在Vue 2.6里完全不是难事。另外这个SSEStream工具类我在好几个项目里复用都很顺手你把它拷到自己的utils目录里微调一下就能用省下的时间够你多研究好几种其他的流式交互了。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →