☰
原生 Fetch 流式改造:SSE、半包粘包与 TextDecoder 实战指南
2026/10/20 4:02:48 网站建设 项目流程

最近又在折腾一个老的聊天项目,产品同学跑过来说体验太差,点完发送要等两秒多才看到回复,问我能不能把接口改成流式输出,做成打字机那种一个字一个字蹦出来的效果。我打开代码一看,接口是普通的JSON返回,fetch发出去之后等整个响应体下载完,再把完整内容一次性渲染到页面上。把这种原生请求改成流式请求,表面上看就是把"一次拿完"换成"边来边拿",可真到了动手阶段,你会发现自己要面对的不只是几行代码,而是一堆关于数据边界、字节解码、连接存活的细节问题。

这篇文章是这次改造记录里的第6篇,内容完全可以单独看。重点讲清楚一件事:怎么把一段原生代码改成流式请求,以及改完之后的坑都在哪里。不管你是前端、后端,还是一个人包全栈,读完应该能拿到一套能直接落地、不会一上线就翻车的最小改造方案。

1. 普通请求改成流式请求,到底在改什么

1.1 两种模式下用户体验的差异

普通JSON请求的完整链路是这样的:前端发请求,服务端闷头处理,处理完之后把整个响应体一次返回,前端拿到完整JSON再解析、渲染。用户能看到内容的时间点,等于"服务端处理时间 + 完整数据传输时间"。如果这个接口内部要调用大模型或者做一堆汇总计算,那这个过程可能长达两三秒,用户就对着空白页面干等。

流式请求则完全不同。服务端从开始处理后就边生成边推送,前端收到第一个数据块就能立刻渲染。比如一个接口整体处理完需要3000毫秒,但第一个小块内容可能在300毫秒就到了,用户感受到的是"它开始回答了",而不是"页面卡住了"。这就是首字时间(TTFB)和整体完成时间拉开差距带来的直观体验提升,也是聊天机器人、日志实时输出、任务进度推送这类场景必须用流式的原因。

1.2 技术链路上的三个变化点

从代码层面看,普通请求改成流式请求,本质上是三个东西变了。

第一,响应体的形态变了。以前你处理的是一个完整的字符串或者一个JSON对象,现在你面对的是一个可分段读取的字节流(ReadableStream),拿到的数据是一块一块的Uint8Array。

第二,数据格式变了。普通接口通常自定义一套JSON结构,比如{ code, data, message };流式接口一般用SSE(Server-Sent Events)这类逐事件推送的文本格式,每条消息以空行分隔。

第三,渲染时机变了。原来是一次性拿到完整数据后统一更新UI,改成流式之后,每解析出一条业务消息就要立刻追加到页面上,同时还得考虑流没结束前的过渡状态,比如"正在输入"的光标动画。

1.3 不是所有接口都适合改流式

先说句实话:流式不是银弹,别为了改而改。我自己见过一个项目,把普通的列表查询接口也改成流式,结果前端处理半包粘包、维护增量渲染状态,复杂度翻了好几倍,收益却几乎为零。

适合改流式的场景,一般是这几类:

  • AI对话、智能问答这类生成型接口,用户希望看到逐字输出。
  • 大文件导出、批量任务执行这类耗时操作,需要实时反馈进度。
  • 日志流、监控数据流、实时通知这类持续推送的服务。

不适合的也很明显:数据量很小的增删改查接口、返回后还需要依赖完整结果才能计算后续逻辑的接口、以及对网络环境要求很苛刻的弱网场景。普通JSON查询可能几十毫秒就返回了,硬上流式纯属给自己挖坑。

2. 先拆底层:HTTP分块传输与SSE消息格式

2.1 定长响应和分块响应

要理解流式请求,得先知道HTTP响应体是怎么在网络上传输的。

传统接口走的是定长响应。服务端处理完请求后,在响应头里带上Content-Length,标明响应体总字节数,然后一次性把所有数据发给客户端。客户端知道总长度,就等着收齐就完事了。

流式接口走的是分块传输,响应头里没有Content-Length,取而代之的是Transfer-Encoding: chunked。服务端每生成一段数据就封装成一个chunk,每个chunk前面标注这一块的长度,发完一个再发下一个,直到最后发一个长度为0的chunk表示结束。客户端不需要知道总长度,只需要一块一块地读。

这里有一个特别容易混淆的点:网络层的"chunk"和业务层的"消息"完全是两码事。一个chunk里可能只包含半条业务消息,也可能包含好几条完整消息。这也是后面"半包"和"粘包"问题产生的根源。

2.2 SSE协议长什么样

SSE(Server-Sent Events)是建立在HTTP分块传输之上的一套轻量文本协议,特点是单向:服务端推给客户端,客户端不能通过同一条连接往服务端发消息。前端用EventSource就能原生消费SSE,不过我们这次改造用的是手写fetch,因为很多场景下还需要在请求头里带鉴权信息,而EventSource不支持自定义请求头,手写反而更灵活。

SSE的报文格式很直白。每条消息由若干字段行组成,字段行是字段名: 值的格式,常见字段包括:

  • data:消息内容,多行data字段合并成一条消息时用换行拼接。
  • event:事件类型,默认是message。
  • id:事件ID,重连时可以用来续传。
  • retry:重连间隔毫秒数。

一条消息结束的标志是一个空行(即连续两个换行符)。举个例子,服务端推送一条正常数据和一条错误事件,报文长这样:

data: {"text":"你好"} event: error data: {"message":"服务繁忙"}

除此之外,还有一种注释行,以冒号开头,比如: ping,它不会触发任何业务事件,作用是让连接持续活跃,防止中间设备把空闲连接断开。这个在下面讲心跳的时候还要回到它。

2.3 后端要配合做的事

前端改了协议,后端不配合等于白改。服务端做SSE有几个硬性要求。

  • 响应头必须设置Content-Type: text/event-stream,以及Cache-Control: no-cache。
  • 不能开缓冲。很多Web框架默认会缓冲响应内容,攒够一定量才发出去,这会导致前端迟迟收不到数据。在开发时,如果你看到前端真的是"流式"的,但一块数据要等好几秒才出来,很大概率就是服务端缓冲的问题。
  • 每写完一块数据要主动flush。Java里是response.getWriter().flush(),Node里是res.write()之后不用特殊处理,Python Flask里要配合stream_with_context或Response生成器才能产生流式效果。

用一个Node Express的极简示例做参考,方便本地验证:

app.get('/api/chat', (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.write(`data: ${JSON.stringify({ text: '你好' })}\n\n`); const timer = setInterval(() => { res.write(`data: ${JSON.stringify({ text: '继续输出' })}\n\n`); }, 500); req.on('close', () => { clearInterval(timer); res.end(); }); });

这里顺便提醒一句:req.on('close')不仅要清定时器,还要考虑是否要做资源释放。客户端随时可能断连,服务端如果还继续往响应里写数据,轻则报错,重则内存泄漏。

2.4 前端原生API里谁在管流式

fetch返回的response对象里,response.body是一个ReadableStream。这个流就是服务端源源不断推送过来的原始字节流。通过response.body.getReader()拿到一个读取器,然后不断调用await reader.read(),每次返回一个对象{ value, done }。value是一个Uint8Array字节数组,done为true表示流已经结束。

这套机制是浏览器原生提供的,不需要任何第三方库,所以叫"原生代码改流式"是完全成立的。实际改造时,绝大多数工作就是在reader.read()返回的字节数据上做处理。

3. 最小改动示范:fetch + ReadableStream的逐字输出

3.1 改造前的代码

先看一段很典型的改造前代码,一个简单的聊天接口调用:

const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: '你好' }) }); const data = await res.json(); renderMessage(data.message);

这段代码的逻辑很清晰:发请求、等JSON、渲染。问题就是太"整"了,一切都要等res.json()把整个响应体读完之后才继续往下走。

3.2 改造后的核心循环

改成流式后,核心变成一段循环读取的代码。先把能跑通的最小版本贴出来:

const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: '你好' }) }); if (!res.ok) { throw new Error(`请求失败: ${res.status}`); } const reader = res.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const events = buffer.split('\n\n'); buffer = events.pop(); for (const event of events) { handleSSE(event); } }

这段代码是整个流式改造的地基,我逐条说下为什么是这个写法。

第一,res.ok的判断很重要。如果服务端返回了400或500,响应体可能仍然是一个普通JSON错误信息,而不是SSE流,这时候强行getReader()读流,解析逻辑会直接崩掉。所以先判断状态码,非2xx就走独立的错误处理分支。

第二,reader.read()是异步读取,每次返回的value是字节数组。这些字节数据是被传输层切过的,和业务消息的边界没有任何关系,所以必须靠decoder转成字符串后,用分隔符自己去切。

第三,buffer += decoder.decode(value, { stream: true })这句里的stream: true,不是传给fetch的,是传给TextDecoder的。作用是让解码器保留未完成的多字节字符,等下一次decode时拼接完整。这个下面会展开说。

第四,buffer.split('\n\n')是SSE的天然分隔方式。每次拿到的数据可能不完整,所以先把当前块追加到buffer尾巴上,再按空行分割出若干条完整事件,每条事件塞给handleSSE,而最后剩下的一段没有以空行结尾的残包,要放回buffer里继续等下一块。这就是半包处理的基本策略。

3.3 为什么TextDecoder必须开stream模式

先说结论:如果是处理流式数据,TextDecoder一定要用{ stream: true }这个选项,否则大概率会出现乱码。

原因和编码机制有关。以中文为例,一个汉字在UTF-8编码下占3个字节。网络传输可不管你是3字节一个单位,它可能在某次read()里只给了这个汉字的第一个字节,剩下两个字节在下一块数据里。TextDecoder如果不开启stream模式,遇到不完整的字节序列时,会直接把它替换成一个替换字符(通常会显示成"�"),等下一块里剩下的字节来的时候,它不知道前面发生过什么,又会把这半个字符重新解析,结果就是永远乱码。

开启stream模式后,解码器内部会缓存这些不完整的字节序列,等到下一块数据来了,凑齐了再一起解码成完整字符。用一个不太严谨但很好理解的类比:对方把一页纸撕成三截寄给你,stream模式就是让你在手上攒着前两截,等第三截到了再拼起来读;不开启的话,你收到第一截就先读一遍,第二截来了又读一遍,拼起来完全对不上。

3.4 事件解析函数怎么写

handleSSE需要把一条以空行结尾的原始文本解析成业务数据。最简版本是这样:

function handleSSE(raw) { const lines = raw.split('\n'); let eventType = 'message'; let dataLines = []; for (const line of lines) { if (line.startsWith('event:')) { eventType = line.slice(6).trim(); } else if (line.startsWith('data:')) { dataLines.push(line.slice(5).trim()); } } if (eventType === 'error') { // 业务错误处理 return; } if (dataLines.length > 0) { const payload = JSON.parse(dataLines.join('\n')); renderMessage(payload.text); } }

这里有两个小细节。第一,SSE规范里一条消息可以有多行data,这些行在解析时要先收集起来,最后用换行符拼成完整内容再JSON.parse,不能每行单独parse。第二,JSON.parse建议包一层try/catch,因为流式传输中偶尔会混入异常数据,直接让异常冒泡会把整个读取循环打断,一旦循环断了,后面的流式内容全丢。

4. 最容易被坑的地方:半包、粘包和多字节字符被截断

4.1 网络层的chunk和业务消息不是一回事

第一次做流式改造的人,最容易犯的一个错误是:以为reader.read()每次返回的就是一条完整的SSE事件。实际上完全不是。

我调试时习惯在读取循环里加日志,打印每次拿到的字节长度和内容:

const { value, done } = await reader.read(); console.log('本次收到字节数:', value.length); console.log('内容:', decoder.decode(value, { stream: true }));

实测下来你会发现,拿到的数据块长度非常不稳定。有时是几十字节,有时是几百字节,有时一条很短的消息反而被拆成了两三块才收完。因为传输层的chunk划分依据的是网络缓冲区的可用空间和服务端的写入节奏,跟你的业务消息边界毫无关系。

这种情况在业界有专门的说法:一条业务消息的数据被拆分到多个传输块里到达,叫半包;一个传输块里包含了多条完整业务消息,叫粘包。两种现象在真实网络环境里是混着出现的,所以代码必须同时处理这两种情况。

4.2 半包怎么处理

半包的处理手段就是我上面示例里的buffer变量。

每次收到新数据后,先把解码后的字符串追加到buffer末尾,然后用split('\n\n')去切。split的结果里,最后一个元素一定是"没有以空行结尾的残包",可能就是一条完整消息的前半段,也可能只有半个字段行。把这个残包放回buffer,等下一块数据来了继续拼接,这就是标准的"攒包"策略。

用个例子走一遍:第一次read()返回data: {"text":"你,buffer变成这个残包,切不出任何事件;第二次read()返回好"}\n\n,buffer拼成data: {"text":"你好"}\n\n,split之后就能切出一条完整事件,残包部分为空。

4.3 粘包怎么处理

粘包正好相反。服务端可能在几毫秒内连续写了好几条消息,而网络层把这几条消息打包在一个传输块里推给前端。这时候split('\n\n')会一次性得到好几个元素,循环遍历逐个处理即可。

粘包本身处理起来不复杂,但容易在处理顺序上出错。一定注意:不要break,不要只取第一个元素。所有切出来的事件都要按顺序处理,顺序乱了,UI上的内容就乱了。

4.4 多字节字符被截断的完整演示

关于多字节字符,我举一个具体的错误代码,这是很多教程里会出现的问题版本:

// 错误写法:没有开stream模式 buffer += decoder.decode(value);

假设中文字符"你"的UTF-8编码是E4 BD A0三个字节,服务端在写入时恰好把它分在了两个网络chunk里:第一个chunk结尾是E4,第二个chunk开头是BD A0。不开stream模式的话,第一个chunk到解码器时,E4孤零零的,解码器不认识,直接替换成�;等第二个chunk到了,BD A0也不完整,又替换成一个�。最终页面上就是两个乱码字符。

开了stream模式,解码器发现E4是未完成序列,会默默记下来。第二块来了之后,把BD A0和缓存的E4拼起来,一次性解出完整的"你"字。

这还不算完。就算解码正确了,如果后端的消息里包含多行data,buffer拼接字符串时也要注意:绝不能把buffer的解析建立在"每一行都是完整个体"的假设上。严谨的做法是按SSE的字段规则逐行扫描,而不是简单依赖split。有一个更稳的分割正则,按一个空行(允许\n\n或\r\n\r\n)去切:

const events = buffer.split(/\r?\n\r?\n/);

这样能兼容不同系统的换行风格。至于要不要进一步逐行解析字段,取决于你的消息内容复杂程度。如果data字段本身会包含换行,那就必须逐行读取、按字段头归类,不能盲目按行切完直接parse。

5. 流式改造里的生产级细节:超时、取消、心跳和重连

5.1 用AbortController实现超时和手动停止

流式请求最难受的一点是:连接挂着不结束,但你不知道它是仍在处理还是已经假死。普通JSON请求可以依赖浏览器默认超时机制,流式请求不行,因为连接持续存在本身就是常态。

所以必须自己控制超时和取消。浏览器原生提供的工具是AbortController,它既可以用于超时,也可以用于用户手动点击"停止生成"时主动中断请求。

const controller = new AbortController(); const timeoutTimer = setTimeout(() => controller.abort(), 30000); try { const res = await fetch('/api/chat', { method: 'POST', signal: controller.signal, body: JSON.stringify({ message: '你好' }) }); // 读取循环... } catch (err) { if (err.name === 'AbortError') { // 用户取消或超时 } } finally { clearTimeout(timeoutTimer); }

这里要特别区分两个超时维度:一个是"连接后首块数据迟迟不来",属于服务端处理卡住了;一个是"流已经建立但中途长时间不再推数据",属于链路假死。前者用单个30秒整体超时基本能覆盖,后者需要在读取循环里记录最后收到数据的时间,再启动一个定时器周期性检查,超过阈值就主动abort并重连。

5.2 心跳:让连接活着,也让前端能判断"还活着"

SSE有一种特殊的注释消息,形如: ping\n\n。它不携带业务数据,浏览器端的EventSource会直接忽略,但TCP连接因为这个报文的活跃而被中间的路由器、负载均衡器认为是"存活状态",不会因为长时间空闲被掐断。

手写fetch时,注释消息不会触发任何事件,但可以从原始字节流里看到。前端可以把它当成一个"存活信号":每次收到包含空行的数据块时,无论里面有没有data字段,都刷新一下lastDataTime。

服务端实现心跳很简单,就是在空闲期定时写注释行:

const heartbeat = setInterval(() => { res.write(': ping\n\n'); }, 15000);

这个15秒是个比较常见的经验值,可以根据网络环境调整。如果你们的服务跑在公网,中间可能有各种代理设备,建议心跳间隔短一点;如果是内网,可以放宽到30秒。

5.3 断线重连:什么时候重试,什么时候不重试

流式请求的另一个常见异常是:读到一半,reader.read()返回的done是true,但业务数据明显没有结束。这说明连接被服务端或链路中断了。

这时候要不要重连,判断依据很重要。一个基本原则:只有非用户主动取消的断开才考虑重连。用户点了"停止生成",你abort了请求,这时候如果重连,用户会直接炸毛。

let reconnectCount = 0; async function connect() { const reader = ...; try { while (true) { const { value, done } = await reader.read(); if (done) { // 流结束,准备重连 } } } catch (err) { // AbortError 不重连,其他错误考虑重连 } }

重连策略也不能无脑试。指数退避加随机抖动是标准做法:第一次等1秒,第二次等2秒,第三次4秒,最多到8秒封顶,每次加一点随机值,避免多个客户端同时重连形成惊群效应。同时设置最大重试次数,比如5次,超过就提示用户"网络异常,请刷新重试"。

幂等配套也很重要。如果这个接口有副作用(比如重试可能让服务端重新执行一次生成),重连时应该带上Last-Event-ID或者业务层自定义的消息ID,让服务端知道从哪断的、能不能续传。SSE协议本身支持id:字段,但很多业务接口并没有实现真正的断点续传,前端最好和后端约定:重连时携带请求唯一标识,服务端通过标识判断是否需要重新完整生成。

5.4 业务错误到底应该走哪个通道

普通HTTP请求遇到错误,直接看状态码就行。流式请求不一样:连接可能正常关闭,但业务上已经错了。如果只在服务端拼命返回500状态码,前端根本看不到——因为连接已经关闭了,前端只会得到一个done标记,不知道是成功结束还是出错了。

一条可靠的约定是:业务错误也通过SSE事件推给前端。比如错误统一用event: error,携带错误码和描述;数据用event: message,携带正常内容;结束用单独的event: done表示流式生成已安全结束。

这样一来,前端的读取循环就变成基于语义的判断:

  • 收到error事件,停止渲染,显示错误提示。
  • 收到done事件,说明服务端主动结束且一切正常。
  • 如果没有收到done而连接断开,才走重连或异常提示逻辑。

用这种方式,前端对"正常结束""业务错误""链路异常"三种情况就能做清晰区分,不会糊成一锅粥。

6. 改造后的自查清单与我的实操体会

最后给一张改造自查清单,每一条都是线上环境踩过的坑换来的。

检查项说明
服务端是否关闭了响应缓冲没关缓冲的话,流式看起来是"一会儿一坨",不是连续的
Content-Type是否正确text/event-stream,不是application/json
前端是否检查res.ok非2xx时响应体可能不是SSE格式
TextDecoder是否开启stream: true不开必现乱码,尤其是在中文内容下
是否有buffer缓存残包没有的话半包消息会丢失
是否区分用户取消和异常断开用户取消后绝不能自动重连
是否有心跳或空闲检测没有的话连接假死无法感知
重连是否考虑幂等没有幂等设计,重连会重复触发业务
业务错误是否走SSE事件不用事件下发的错误,前端无法区分正常关闭和异常关闭

说两个我在实际改造中遇到的真实问题,给各位做个参考。

第一个是响应头压缩导致的异常。改造完成自测没问题,上线后偶尔会有用户反馈内容中间出现乱码。排查了很久,最后发现是服务端在反代层开启了Gzip压缩,压缩流跨chunk传输时,把原本按SSE空行分隔的边界给重新切了。虽然协议上解码依然能解出来,但某些压缩实现会在chunk之间插入额外的控制信息,干扰了前端的空行切分。解决办法是SSE接口在反代层明确关闭压缩。

第二个是中间代理缓冲。开发环境前端直连后端一切正常,部署到生产环境后,内容变成几秒钟才蹦出来一次。查了一圈发现是Nginx的proxy_buffering默认开着,把流式响应攒起来统一转发。需要在SSE的location配置里显式关闭缓冲。这类问题有个很实用的验证技巧:先拿命令行工具直接压后端,确认SSE是真正的流式输出,再一层一层加代理,看哪一层把流掐住了。

改造完这一套之后,我现在的习惯是先写个curl -N命令验证服务端,再写前端代码。这个顺序能帮你把"前端写的解析逻辑有问题"和"服务端根本没有流式返回"这两类问题快速隔离开,省去大量联调时间。

最后再补充一点个人体会:流式请求的代码写起来不难,难的是把所有异常路径都想清楚。尤其是涉及到用户主动取消、服务端异常、中间链路断开这几个状态混合在一起的时候,状态机画不清楚,代码就会在各种边界情况里翻车。如果你也在做类似改造,建议先把数据格式和错误语义约定写进接口文档,前后端各守各的边界,流式的体验才能真正稳下来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询