前言
在构建实时 Web 应用时,服务端推送技术是绕不开的话题。Server-Sent Events(SSE)作为浏览器原生支持的轻量级推送方案,凭借其简单、可靠、自动重连的特性,在 AI 流式对话、实时通知、日志监控等场景中频繁出现。
本文将从协议规范的底层出发,深入剖析 SSE 的字段定义、事件边界机制,并结合 fetch 流式读取的讲解,帮助大家更多了解 SSE 的核心原理与实战技巧。
一、SSE 简介与定位
1.1 什么是 SSE?
SSE(Server-Sent Events,服务器发送事件)是 HTML5 规范中的一部分,它允许服务器通过普通的 HTTP 连接向客户端单向推送 数据。客户端发起一个 HTTP 请求后,服务器保持连接打开,并以特定格式不断发送数据。客户端使用浏览器内置的 EventSource(或Fetch)接口接收数据,无需额外库或复杂的心跳逻辑。
1.2 核心特点
- 单向推送:数据只从服务器流向客户端。
- 基于标准 HTTP 协议 :无需协议升级(如 WebSocket 的
101 Switching Protocols),穿透防火墙和代理服务器的兼容性极好。 - 自动重连:浏览器原生支持断线重连,结合事件 ID 可实现断点续传。
- 文本格式:仅支持 UTF-8 文本传输,适合结构化数据推送。
1.3 与 WebSocket 的对比
| 维度 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 服务器 → 客户端(单向) | 双向全双工 |
| 协议基础 | HTTP/1.1 或 HTTP/2 | WebSocket 协议(需 HTTP 升级) |
| API | EventSource、Fetch |
WebSocket |
| 重连机制 | EventSource: 自动重连 + 事件 ID 恢复 Fetch: 需手动实现 |
需手动实现 |
| 自定义请求头 | EventSource: 不支持 Fetch: 支持 |
支持 |
| 请求方式 | EventSource: 仅GET Fetch: 任意 |
GET(握手后切换协议) |
| 消息格式 | 文本(text/event-stream) |
文本或二进制帧 |
| 适用场景 | 实时推送、通知、AI 流式输出、进度更新 | 在线聊天、协作编辑、实时游戏 |
选择建议:当你只需要服务器单方面推送数据,且希望享受浏览器原生重连、事件 ID 等便利时,SSE 是最简单的选择。如果需要双向频繁交互,或需要传输二进制数据,则选择 WebSocket。
二、Node.js 后端 SSE 服务实现
2.1 基本步骤
-
设置响应头:
Content-Type: text/event-streamCache-Control: no-cacheConnection: keep-alive- 可选
X-Accel-Buffering: no禁用 Nginx 缓冲
-
使用
res.write()按data: ...\n\n格式发送事件。 -
结束时发送
data: [DONE]\n\n并res.end()。 -
监听客户端关闭事件
req.on('close')清理资源。
2.2 完整示例
js
// server.js
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors());
app.get('/api/time-stream', (req, res) => {
// 1. 设置 SSE 响应头
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // 禁止 Nginx 缓冲
// 2. 发送初始注释(可选,用于保持连接)
res.write(':ok\n\n');
let count = 0;
const maxCount = 10; // 发送 10 次后结束
const timer = setInterval(() => {
count++;
const data = {
time: new Date().toISOString(),
count
};
res.write(`data: ${JSON.stringify(data)}\n\n`);
if (count >= maxCount) {
clearInterval(timer);
res.write('data: [DONE]\n\n');
res.end();
}
}, 1000);
// 3. 客户端断开连接时清理
req.on('close', () => {
clearInterval(timer);
res.end();
});
});
app.listen(3000, () => console.log(`SSE server at http://localhost:3000`));
三、前端接收方式
有两种主流方式:EventSource(原生) 和 fetch 流式读取。
方式一:使用 EventSource(简单,仅支持 GET)
js
<template>
<div>
<button @click="startSSE">开始推送</button>
<p v-for="(item, idx) in events" :key="idx">
{{ item.time }} - 第 {{ item.count }} 次
</p>
</div>
</template>
<script setup>
import { ref } from 'vue';
const events = ref([]);
const startSSE = () => {
const es = new EventSource('http://localhost:3001/api/time-stream');
es.onmessage = (e) => {
if (e.data === '[DONE]') {
es.close();
return;
}
try {
const data = JSON.parse(e.data);
events.value.push(data);
} catch (err) {
console.error('解析失败', err);
}
};
es.onerror = (err) => {
console.error('SSE 错误', err);
es.close();
};
};
</script>
缺点 :无法携带自定义请求头(如 Authorization),不支持 POST 请求,无法传递消息体。
方式二:使用 fetch 读取流(推荐,支持 POST + 自定义头)
js
<template>
<div id="app">
<h2>SSE 时间推送示例</h2>
<button @click="startStream" :disabled="loading">开始获取时间</button>
<ul>
<li v-for="(item, idx) in events" :key="idx">
{{ item.time }} - 第 {{ item.count }} 次
</li>
</ul>
<p v-if="loading">接收中...</p>
</div>
</template>
<script setup>
import { ref } from 'vue';
const events = ref([]);
const loading = ref(false);
const startStream = async () => {
loading.value = true;
events.value = [];
try {
const res = await fetch('http://localhost:3001/api/time-stream');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const payload = line.slice(6);
if (payload === '[DONE]') {
loading.value = false;
return;
}
try {
events.value.push(JSON.parse(payload));
} catch (e) {
console.error('解析错误', e);
}
}
}
}
} catch (err) {
console.error('请求失败', err);
} finally {
loading.value = false;
}
};
</script>
四、SSE 协议格式全解
SSE 的本质是一个持续不断的 HTTP 响应,服务端按照特定格式一行行发送文本,浏览器解析后逐条派发事件。
4.1 基本结构
一个 SSE 响应由一系列事件 组成。每个事件包含若干字段 (field),事件之间用空行分隔。
text
field: value\n
field: value\n
\n
field: value\n
\n
- 每个字段占一行,格式为
字段名:字段值(若字段值以空格开头,解析器会默认将其移除),并以\n结尾。 - 一个事件可以包含多个字段,它们必须连续出现。
- 事件的结束标志是空行 ------即连续两个换行符
\n\n(或\r\n\r\n)。
4.2 标准字段
SSE 规范定义了 4 个标准字段名 ,外加注释。任何其他字段名都会被浏览器忽略。
| 字段名 | 描述 |
|---|---|
data |
承载消息内容。这是唯一携带实际数据的字段。 |
event |
自定义事件类型 。不设置时,事件默认为 "message" 类型。 |
id |
事件唯一标识 。用于断点续传,重连时浏览器会带上 Last-Event-ID 头。 |
retry |
自动重连时间(毫秒)。告知浏览器断开后等待多久重新连接。 |
:(冒号) |
注释行。一整行都被忽略,通常用于发送心跳包保持连接。 |
注意 :这些字段名完全是由 W3C 的 SSE 规范严格定义的,你不能自定义字段名。客户端(浏览器或遵循标准的 SSE 解析器)只会识别这几个特定的字段,处理逻辑是硬编码的。
🎯 各字段的角色对比
可以这样形象地理解:
data是"信纸" :你想传达的所有具体信息,都必须写在data字段里。就像信必须写在信纸上一样。event是"收件人" :用来给事件分类。不写收件人,信会被送到默认的"邮件处理中心"(onmessage回调);写上收件人,就会被送到指定的人那里(如addEventListener('custom', ...))。id是"信件编号" :用于追踪和断点续传(Last-Event-ID),方便你从断开的地方继续看信。retry是"收信频率建议" :用来告诉浏览器"如果你没收到我的信,可以过多久再查一次"。
4.2.1 data ------ 数据载体
这是最重要的字段,承载实际要传输的消息内容。
- 若事件中没有
data字段,该事件不会触发任何回调 (可用于单纯更新id或发送retry)。 - 若事件中有多个
data字段行 ,客户端会将它们用换行符\n拼接起来。 - 字段名的写法必须是
data,不能是my-data或payload等。
示例 1:单行数据
text
data: 你好\n
\n
客户端收到的事件对象 event.data === "你好"。
示例 2:多行拼接
text
data: 第一行\n
data: 第二行\n
data: 第二行\n
\n
客户端收到的事件对象 event.data === "第一行\n第二行\n第三行"。
示例 3:JSON 数据(实践中最常见)
text
data: {"username":"alice","score":100}\n
\n
客户端用 JSON.parse(event.data) 即可还原对象。
4.2.2 id ------ 事件标识
- 设置当前事件的唯一 ID。
- 浏览器会记录最后一个收到的
id。当连接断开并自动重连时,浏览器会在请求头Last-Event-ID中携带该 ID,服务器可据此实现断点续传(从断点处继续推送未接收的事件)。 - ID 必须是字符串,不含换行符。
示例
text
id: 42\n
data: 事件42的内容\n
\n
id: 43\n
data: 事件43的内容\n
\n
连接中断后重连,请求头会带有 Last-Event-ID: 43,服务器可以据此发送从 44 开始的数据。
4.2.3 event ------ 事件类型
- 指定事件的名称,用于客户端按类型监听。
- 若不写
event字段 ,客户端默认触发EventSource对象的onmessage回调(事件类型为"message")。 - 若写了
event字段,客户端需要通过addEventListener('自定义事件名', callback)来监昕对应事件。
示例
text
event: userlogin\n
data: {"username":"alice"}\n
\n
event: scoreupdate\n
data: {"score":200}\n
\n
data: 这是一条普通消息
\n
前端监听:
js
const sse = new EventSource('/stream');
sse.addEventListener('userlogin', (e) => {
console.log('用户登录:', JSON.parse(e.data));
});
sse.addEventListener('scoreupdate', (e) => {
console.log('分数更新:', JSON.parse(e.data));
});
sse.onmessage = (e) => {
console.log('普通消息:', e.data); // "这是一条普通消息"
};
4.2.4 retry ------ 重连时间
- 指定浏览器在连接断开后,重新发起连接的等待时间(以毫秒为单位),必须是整数。
- 通常放在流的最开始发送一次,覆盖浏览器默认的 3~5 秒重连间隔。
示例
text
retry: 5000\n
\n
断开后 10 秒重连。
4.2.5 注释行 ------ : 开头
- 以冒号
:开头的行会被完全忽略,不产生任何事件。 - 常用于发送心跳包(keep-alive),防止代理服务器或负载均衡器长时间无数据而关闭连接。
示例
text
: heartbeat\n
\n
data: 真实数据\n
\n
客户端不会收到关于 : heartbeat 的任何回调。注释行通常也以空行结束(保持格式整洁),但规范并不强制。
4.3 完整事件示例
下面是一个 SSE 流的完整 HTTP 响应体示例,展示了多种字段的组合:
http
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
retry: 10000
id: 1
event: update
data: {"time": "2026-05-07T10:20:30Z", "count": 1}
id: 2
data: 这是没有自定义事件类型的普通消息
:这是注释,会被忽略
data: 只有data字段的多行示例
data: 第二行
event: error
data: {"message": "Something went wrong"}
id: 3
data: [DONE]
客户端接收效果(伪代码)
text
事件1:type="update", id=1, data='{"time": "2026-05-07T10:20:30Z", "count": 1}'
事件2:type="message"(默认), id=2, data="这是没有自定义事件类型的普通消息"
(注释行被忽略,不产生事件)
事件3:type="message", data="只有data字段的多行示例\n第二行"
事件4:type="error", data='{"message": "Something went wrong"}'
事件5:type="message", id=3, data="[DONE]"
4.4 字段组合规则
- 一个事件可以由多个字段组成,这些字段必须连续,直到遇到空行结束。
- 如果事件中没有
data字段,该事件被客户端忽略,只用于其他目的(如设置retry或id)。 - 如果事件中包含
id字段,则id会与事件关联(不一定需要data)。 event字段定义事件类型,没有event时默认为"message"。
4.5. 多行数据拼接规则
标准规定:如果事件包含多个 data 字段,客户端会用 LF(\n)连接所有值。
例如:
text
data: a
data: b
连接为 "a\nb"(末尾没有额外的换行)。
4.6. 结束流与关闭连接
SSE 规范没有定义特殊的"结束"信号,通常由服务器直接关闭连接,或发送一个自定义的 data 值(如 [DONE]),客户端根据约定自行关闭 EventSource 或 fetch 流。
对于 EventSource:
js
es.onmessage = (e) => {
if (e.data === '[DONE]') {
es.close();
return;
}
// 处理数据
};
对于 fetch 流:
我们通常解析 data: 行并判断是否为自定义结束符,然后跳出循环。
4.7. 实践中常见约定
- 心跳 :有些服务器会定时发送注释行
:ping\n\n来保持连接,防止代理超时断开。 - JSON 数据 :大多数 API(如 OpenAI、DeepSeek)将数据以 JSON 字符串放在
data:后面,客户端解析 JSON 获得结构化信息。 stream_options:某些 API 支持include_usage等参数,会在流结束时追加一个包含 token 用量的事件。
示例
如果想区分多种类型的数据(例如 AI 的"思考过程"和"最终回复"),应该在 data 中构造结构化数据,比如发送 JSON:
text
data: {"type":"reasoning","content":"嗯..."}\n\n
data: {"type":"content","content":"你好"}\n\n
前端解析 JSON 后,根据里面的 type 段来做分支处理。
五、事件为什么以两个连续换行符 \n\n 结束
SSE 协议规定每个事件必须以一个空行(两个连续的换行符 \n\n)结束 ,这不是随意设计的,而是为了将字段划分为事件。如果少一个换行符,事件永远不会被触发,或者会导致事件粘连、丢失,甚至流解析失败。
5.1 协议原理:为什么必须是两个换行符?
SSE 的流是由文本行组成的,字段像这样一行一行发送:
makefile
field: value\n
那么,如何区分几个连续字段属于同一个事件?答案就是:用空行(两个换行符)作为事件边界。
具体过程:
-
一个换行
\n只是"字段"的分隔符。例如:
makefileevent: update\n data: hello\n此时,这两个字段还在同一个事件块中,尚未结束。
-
再追加一个
\n(形成\n\n),表示上一个事件块已经结束,该事件可以派发了。makefileevent: update\n data: hello\n \n客户端收到第二个
\n后,立即将前面累积的字段封装成一个事件推送给应用。
所以,\n\n 的本质是"事件分隔符" ,就像 HTTP 头以 \r\n\r\n 结束一样。
5.2 三种情况的区别与问题
| 终止符 | 效果 | 导致问题 |
|---|---|---|
\n\n(正确) |
事件立即触发,字段归入当前事件。 | 无。 |
\n(只有一个) |
事件暂不触发,字段会挂起,等待下一个换行或流结束。 | 1. 如果后续还有字段,它们会被合并到同一个事件中,导致数据错乱。 2. 如果流结束前一直没有空行,这个事件可能永远不会触发(取决于浏览器实现)。 |
| 没有换行 | 当前行被视为不完整的数据,缓冲区等待换行;同时事件未结束。 | 1. 解析器会认为这行可能还没写完,可能会一直等待换行符。 2. 后续行可能被误判为同一行的续行,或导致解析失败。 |
5.3 举例说明
正确示例(\n\n 结束):
kotlin
data: 第一条消息\n
\n
data: 第二条消息\n
\n
客户端收到两个独立的事件。
错误1:只有一个 \n
kotlin
data: 第一条消息\n
data: 第二条消息\n
实际上这会被视为一个事件 ,包含两行 data,客户端会拼接两行为 "第一条消息\n第二条消息"。
如果它们本应是两个独立消息,就会混乱。
错误2:缺少结尾 \n
kotlin
data: 第一条消息
解析器可能因为没收到换行而一直等待,如果紧接着发送:
kotlin
data: 第二条消息\n\n
第一行可能永远不被触发,或者行被截断。
5.4 最佳实践
在 Node.js 后端使用 res.write() 时,每次发送事件,一定要确保:
javascript
res.write(`data: ${JSON.stringify(payload)}\n\n`);
末尾的 \n\n 是不可或缺的,它告诉浏览器:"这个事件已经完整,可以立即使用了"。
如果是心跳注释行:
javascript
res.write(`: heartbeat\n\n`);
同样需要双换行,否则心跳行也可能被误认为未结束。
六、前端解析返回的数据时,为什么fetch方式要比EventSource方式要复杂得多?
这是因为它们承担的责任层次不同:EventSource 是浏览器为 SSE 量身定制的"高级自动挡",而 fetch 是通用的"手动挡"底层工具。
6.1 🔧 核心差异:"全自动" vs "全手动"
浏览器知道 SSE 协议的所有规则,所以它把脏活累活都封装进了 EventSource 这个高级 API 里。而你用 fetch 时,这些活就必须由你自己来干。
| 功能 | EventSource (全自动) |
fetch (全手动) |
|---|---|---|
| 连接管理 | 自动建立连接,断开后自动重连。 | 你需要手动实现重试、错误处理和重连逻辑。 |
| 协议解析 | 内置解析器。自动按\n\n分割事件,提取data、event等字段。 |
没有任何内置解析。你需要自己处理字符流、分割数据块和行、判断事件边界。 |
| 事件分派 | 解析后自动触发onmessage或自定义事件。 |
你需要解析出事件后,再手动调用自己的处理函数。 |
| HTTP 方法 | 仅限 GET 请求。 | 支持 POST,可自定义请求头、发送请求体。 |
| 请求头 | 无法自定义(如加 Authorization 鉴权头)。 |
完全自由控制。 |
所以,复杂度的根源在于:EventSource 让你看到的是一个个包装精美的"事件",而 fetch 让你直面的是需要自己切分和理解的"字节流"。
6.2 🚀 为何还要用复杂的 fetch?
尽管复杂,但在像调用 DeepSeek API 这样的现代场景中,fetch 的"手动挡"反而提供了必不可少的控制力,因为 EventSource 的两大硬伤使其无法胜任:
- 强制 GET 限制 :
EventSource只能用 GET 方法,URL 长度有限,更无法携带复杂的请求体。而调用 DeepSeek API 必须使用 POST 方法,并携带一大段包含历史对话的 JSON 体。 - 无法自定义请求头 :出于安全考虑,
EventSource不允许设置自定义请求头,但我们几乎所有的 API 都需要通过Authorization: Bearer <token>来鉴权。
七、fetch流处理代码解析
📖 代码逐行解析
7.1 const reader = response.body.getReader();
-
作用 :获取一个可读流(ReadableStream)的读取器(Reader) 。
-
原理:
response.body是一个ReadableStream对象,它代表了服务器返回的响应体数据流。- 这个流中的数据是分块(chunks) 到达的,就像水流中不断流过的片段。
getReader()方法会返回一个ReadableStreamDefaultReader,它是你操作这个流的"句柄"。通过它,你可以逐块地、有序地读取数据。- 锁机制 :一旦你调用
getReader(),这个流就被"锁定"了,不能再调用getReader()或response.json()等方法,直到你释放这个 reader。
-
为什么需要它?
没有它,你就无法控流。我们想要实现的是服务器推送一条消息,前端就立刻显示一条,而不是等所有数据全部下载完再一次性显示。分块读取是实现"流式输出"的基础。
7.2 const decoder = new TextDecoder();
-
作用 :创建一个文本解码器 ,用来将二进制数据(
Uint8Array)转换成 JavaScript 字符串。 -
原理:
- 流读取得到的
value是Uint8Array类型的二进制字节。 TextDecoder是浏览器内置的 API,默认按 UTF-8 编码将字节流解码为字符串。- 它支持流式解码,可以处理多字节字符跨块到达的情况(见下一条)。
- 流读取得到的
-
为什么需要它?
我们的 SSE 协议是基于文本行的,而网络传输的是字节。必须将字节解码为字符,才能找到
"data: "这样的行前缀。
7.3 const { done, value } = await reader.read();
-
作用 :从流中读取下一个数据块。
-
解构返回值:
done:布尔值。为true时,表示流已经关闭,没有更多数据可读(相当于水龙头拧紧了)。value:Uint8Array|undefined。如果done是false,它就是本次读取到的字节片段;如果done是true,value通常是undefined。
-
await的作用 :
reader.read()返回一个 Promise。当流中有数据可用时,Promise 会 resolve。await让我们可以用同步写法等待异步的数据块,循环读取直到done === true。 -
为什么放在
while(true)里?因为我们需要不断地从流中取数据,直到结束。这个循环就是持续从水龙头接水的过程。
7.4 buffer += decoder.decode(value, { stream: true });
-
作用 :将刚读到的二进制块
value解码为字符串,并追加到一个行缓冲区buffer中。 -
参数
{ stream: true }关键作用:- 处理字符截断:UTF-8 编码中,一个汉字可能由 3 个字节组成,一个 emoji 可能占 4 个字节。一个数据块(chunk)恰好可能在一个多字节字符的中间断开。例如,表示"你好"的某个中间字节。
- 如果
stream为false(默认),解码器会认为这是完整数据,遇到不完整的字节序列会直接输出替换字符\ufffd(即"�") ,造成乱码。 - 当
stream设为true,解码器会"记住"不完整的字节,暂存起来不输出字符,等下一块数据到达时再拼接完整后输出。这样就完美解决了跨块字符截断问题。 - 仅在最后一块或流结束时 ,我们才可能调用一次不带
stream选项或stream: false的decode()来强制输出所有剩余字节(不过通常我们会在流结束时跳出循环,不再处理)。
-
为什么需要
buffer缓冲区?数据块不一定按行对齐。一个数据块里可能包含好几行,也可能一行被切到两个块里。我们把每次解码的文本都追加到缓冲区,这样我们就可以从容地从中切出完整的行。
7.5 const data = line.slice(6);
-
作用 :去掉行首的
"data: "前缀,提取出真正的 JSON 数据字符串。"data: "正好是 6 个字符(包括冒号后的空格),slice(6)就是从索引 6 开始截取,去掉它,剩下的就是服务器传过来的实际载荷,比如:text"data: {"content":"Hello"}" ^ 从这里 slice(6) → "{"content":"Hello"}"
🧩 完整逻辑串联
这些代码组合在一起,完成了一个逐字节接收→流式解码→行缓冲→事件提取的流水线,对应 SSE 的流式推送场景:
- 获取 reader → 控制流
- 循环 read() → 逐块接收
- decoder.decode() (stream: true) → 二进制 → 字符串,安全拼接不完整多字节字符
- buffer 行缓存 → 分割完整行,处理不完整行滞后
- 行解析 (startsWith / slice) → 识别
data:行,提取 JSON 数据 - JSON.parse 与判断 → 最终拿到
content,判停[DONE]
这套流程虽然比 EventSource 底层得多,但它换来了最大的灵活性:可以发 POST,可以带任意请求头,完全不受协议限制。
八、行缓存
这行代码 buffer = lines.pop() || ''; 是处理行分片 的关键,它的作用是:把最后一个可能不完整的行从当前待处理列表中取出,重新放回缓冲区,等待与下一个到达的数据块拼接成完整的行。
为了理解它,我们得先搞清楚 TCP/流式传输的一个特点:数据流没有义务按"行"来切分数据块。 我们完全可以收到半个"data:"消息。
8.1 核心问题:数据块边界 ≠ 行边界
假设服务器连续不停地发送:
css
data: {"content":"Hello"}\n\n
data: {"content":"World"}\n\n
网络可能以任意长度将数据拆成多个块(chunk):
- Chunk 1 :
data: {"con - Chunk 2 :
tent":"Hello"}\n\ndata: {"content":"World"}\n\n
如果用 \n 直接分割,Chunk 1 会变成:
arduino
["data: {"con", ""] // 最后那个空字符串是分号幻觉,实际上 "con 是不完整的
如果我们直接把 "data: {"con 当作完整行去 JSON.parse,会报错。
8.2 缓冲区循环中的行收集逻辑
标准的 SSE 解析循环会这样做:
js
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 把新数据追加到缓冲区
buffer += decoder.decode(value, { stream: true });
// 用换行符分割
const lines = buffer.split('\n');
// 关键处理
buffer = lines.pop() || '';
for (const line of lines) {
// 处理完整的行
if (line.startsWith('data: ')) { ... }
}
}
8.3 buffer = lines.pop() || ''; 到底做了什么?
让我们模拟一下两个数据块到达的过程。
状态1:收到第一个 Chunk
javascript
// buffer 初始为空
buffer += "data: {"con" // 新到数据
// lines = buffer.split('\n')
// lines → ["data: {"con", ""]
// 注意:split 会在末尾空字符串前产生 ["一部分", ""] 这样的数组
buffer = lines.pop() || '';
// pop() 返回 "" (空字符串),buffer 变成了 ""。
// 现在需要处理的 lines → ["data: {"con"]
// 但等等!"data: {"con" 是完整行吗?不是!它后面应该还有内容。
// 所以 lines.pop() 并没有拯救到"跨越 chunk 的半行",
// 而是只拿到 split 产生的尾部空字符串。
这里好像还没完全保护不完整行,因为不完整行可能是 "data: {"con" 这种末行不带 \n 的情况。
关键来了 :如果 Chunk 1 恰好 末尾没有换行符 呢?
arduino
Chunk 1 原始内容:"data: {"con"
这时 buffer 内容是 data: {"con",末尾没有 \n。 buffer.split('\n') 结果:
js
["data: {"con"]
它只有一个元素 ,并没有空字符串。 lines.pop() 就会返回 "data: {"con" ,并把整个数组清空。 buffer 重新赋值为 "data: {"con"。
处理循环 (for (const line of lines))此时没有任何行需要处理,完美避免了把不完整的行送去解析。
状态2:收到第二个 Chunk
js
buffer += "tent":"Hello"}\n\ndata: {"content":"World"}\n\n"
// 此时 buffer 变成:
// "data: {"content":"Hello"}\n\ndata: {"content":"World"}\n\n"
const lines = buffer.split('\n');
// lines → [
// 'data: {"con',
// 'tent":"Hello"}',
// '',
// 'data: {"content":"World"}',
// '',
// ''
// ]
buffer = lines.pop() || '';
// lines.pop() 返回 '',buffer 变成 ''。
// 现在 lines 为前5个元素:
// 0: 'data: {"con'
// 1: 'tent":"Hello"}'
// 2: ''
// 3: 'data: {"content":"World"}'
// 4: ''
注意第0个和第1个索引的奇观: 'data: {"con' 和 'tent":"Hello"}' 原本属于同一个 JSON 行,因为之前被切割了。它们现在仍然是被视为两个独立的行。
那么,它们能正确解析吗? 不能! 第0个会被 startsWith('data: ') 卡住,然后尝试 JSON.parse('{"con'),解析失败被忽略。 第1个不会进入 startsWith('data: ') 被跳过,数据就丢了。
8.4 真正的保护机制:行的完整性问题
上面的分析暴露了一个事实:pop() 只保护 "最后一个元素缺少尾部换行符" 的情况,也就是跨 chunk 的半行,它能把这一整串不完整的文本放回缓冲区。
但它不能解决跨 chunk 的"多行分裂"! 即一个逻辑行被切成两个物理片段,各自都带有换行符?不行,因为一个逻辑行中间通常没有换行符,split 不会把它劈开。唯一的问题是当逻辑行被拆成两段,第一段末尾没有 \n,此时它会被 pop() 捕获到重新拼接。
正确的作用机制是:
- 不完整行 (末尾无
\n)会被pop()捞起放回buffer。 - 下一次 chunk 到达时,新数据会接到
buffer后面,这时完成的行自然带上了\n,可以被完整解析。
8.5 || '' 的作用
lines.pop() 在数组为空或 pop() 返回 undefined 时,提供空字符串作为默认值,保证 buffer 始终是字符串,方便后续 buffer += 拼接。
💎 总结
buffer = lines.pop() || ''; 这一行是流式 SSE 解析的安全网,它的任务就是:
- 把当前 chunk 切分后,最后一个可能由于没收到换行符而不完整的行,重新暂存到
buffer。 - 等到下一个 chunk 到来时,这行内容会被拼接完成,从而得到正确的数据。
没有它,被网络切断在多字节 UTF-8 字符中间或者 JSON 中间的半行数据就会直接进入解析流程,导致反复出现 JSON 解析失败、内容丢失。虽然我们仍然会因为切割导致 JSON 解析失败跳过(如 startsWith 无法保护所有情况),但至少保证了能被正确拼接的行最终都能被完整解析,这就是它存在的根本意义。
九、高级场景:代理 DeepSeek API 流式响应
当你的后端作为"中间层"需要调用第三方流式 API(如 DeepSeek)并转发给前端时,务必在请求中设置 responseType: 'stream',否则 response.data 不会是可读流,而是一个已缓冲的字符串。后端再从那个流中读取数据,重新封装成 SSE 格式写给自己前端的响应。示例如下:
后端代码(nodejs):
js
app.post('/api/chat', async (req, res, next) => {
const { messages, stream = true } = req.body;
const apiUrl = 'https://api.deepseek.com/chat/completions';
try {
const dsBodyData = {
messages,
model: 'deepseek-v4-flash',
thinking: {
type: 'enabled',
},
reasoning_effort: 'high',
max_tokens: 4096,
response_format: {
type: 'text',
},
stop: null,
stream: stream,
stream_options: null,
temperature: 1,
top_p: 1,
tools: null,
tool_choice: 'none',
logprobs: false,
};
if (stream) {
const response = await axios({
method: 'post',
url: apiUrl,
data: JSON.stringify(dsBodyData),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${process.env.DEEPSEEK_API_KEY}`,
},
responseType: 'stream', // 关键!确保拿到可读流
});
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
response.data.on('data', (chunk) => {
const lines = chunk.toString().split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') {
res.write('data: [DONE]\n\n');
res.end();
return;
}
try {
const parsed = JSON.parse(data);
const delta = parsed.choices[0].delta;
// 根据 delta 中的字段发送不同事件
if (delta.reasoning_content) {
res.write(`data: ${JSON.stringify({ reasoning: delta.reasoning_content })}\n\n`);
} else if (delta.content) {
res.write(`data: ${JSON.stringify({ content: delta.content })}\n\n`);
}
} catch (e) {
// 忽略解析错误
// next(e)
}
}
}
});
} else {
const response = await axios({
method: 'post',
url: apiUrl,
data: JSON.stringify(dsBodyData),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${process.env.DEEPSEEK_API_KEY}`,
},
// 非流式不用 stream,默认 json 即可
});
const content = response.data.choices[0].message.content;
res.json({ content });
}
} catch (error) {
next(error);
}
});
前端代码(vue3):
js
<template>
<div class="chat-container">
<div class="messages" ref="msgContainer">
<div v-for="(msg, idx) in messages" :key="idx" :class="['message', msg.role]">
<template v-if="msg.role === 'assistant'">
<div v-if="msg.reasoning" class="reasoning">
<details open>
<summary>思考过程(点击折叠)</summary>
<p>{{ msg.reasoning }}</p>
</details>
</div>
<div class="content">{{ msg.content }}</div>
</template>
<template v-else-if="msg.role === 'user'">
<div class="content">{{ msg.content }}</div>
</template>
</div>
</div>
<div class="input-area">
<input v-model="input" @keyup.enter="send" :disabled="loading" />
<button @click="send" :disabled="loading || !input.trim()">发送</button>
</div>
</div>
</template>
<script setup>
import { ref, reactive, nextTick, watch } from 'vue';
const input = ref('');
const messages = ref([]);
const loading = ref(false);
const msgContainer = ref(null);
watch(() => messages.value.length, () => {
nextTick(() => {
const el = msgContainer.value;
if (el) el.scrollTop = el.scrollHeight;
});
});
const send = async () => {
const text = input.value.trim();
if (!text || loading.value) return;
messages.value.push({ role: 'user', content: text });
input.value = '';
loading.value = true;
const assistant = reactive({ role: 'assistant', content: '', reasoning: '' });
messages.value.push(assistant);
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: messages.value.slice(0, -1).map(m => ({ role: m.role, content: m.content })),
stream: true,
}),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') break;
try {
const parsed = JSON.parse(data);
if (parsed.reasoning) assistant.reasoning += parsed.reasoning;
else if (parsed.content) assistant.content += parsed.content;
} catch (e) {
// 忽略解析错误
}
}
}
} catch (err) {
assistant.content = '网络错误';
} finally {
loading.value = false;
}
};
</script>
9.2 为什么前后端处理流的方式不一样?
后端用 response.data.on('data') 事件监听,前端用 while (true) 循环读取,根本原因在于它们面对的是不同环境下的完全不同的流对象。
简单说:
- 后端 拿到的是 Node.js 的流,是推送模式,数据来了会主动调你。
- 前端 拿到的是浏览器 Fetch API 的流,是拉取模式,你得自己去要数据。
| 区别点 | 后端 (Node.js) | 前端 (浏览器) |
|---|---|---|
| 获取方式 | axios 设置 responseType: 'stream' |
fetch 请求后调用 response.body.getReader() |
| 对象类型 | Node.js Readable Stream |
ReadableStreamDefaultReader |
| 工作模式 | 推送 (Push) :流主动触发 'data' 事件 |
拉取 (Pull) :需手动 await reader.read() |
| 数据处理 | 通过监听器 res.on('data', chunk) 接收 |
通过循环 const { done, value } = await reader.read() 获取 |
| 类比 | 订报纸:邮递员每天把报纸投递到你家邮箱,你只需等着。 | 取快递:快递到了驿站,收到通知后,你得自己去驿站一件一件取回来。 |
9.2.1 后端:Node.js 流 = 事件驱动
在 Node.js 中,response.data 是一个继承自 EventEmitter 的 Readable Stream。一旦你订阅了 'data' 事件,流就会在底层数据到达时自动、连续地将数据块"推"给你 。代码被触发的方式是回调。
js
// 你只是"订阅"了事件,告诉Node.js "有数据就调用我"
// 控制权在流
response.data.on('data', (chunk) => {
// 数据被自动"推送"到这个回调函数里
console.log('收到数据块:', chunk.toString());
});
9.2.2 前端:Fetch 流 = 异步迭代
在浏览器 Fetch API 中,getReader() 返回的是一个遵循 Promise 规范的读取器。没有 'data' 事件可订阅,必须主动调用 reader.read() 方法 来"拉取"下一个数据块。这是一个返回 Promise 的异步操作,所以配合同步写法的 await 或 .then() 使用。
js
// 你需要自己决定何时获取数据,用循环主动"拉取"
// 控制权在你
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read(); // 主动请求下一个数据块
if (done) break;
console.log('收到数据块:', new TextDecoder().decode(value));
}
9.2.3 直观类比
- 后端的
on('data')像:你告诉快递公司,"所有我的快件,直接放我家门口",然后你就可以去干别的,快件到了会自动出现。 - 前端的
reader.read()像:快递公司给你发个通知,告诉你快递到了转运站。你需要自己一趟趟去转运站,凭取件码把每一件快递取回家。
所以,写法上的差异完全是因为 Node.js 和浏览器提供了两种不同工作模式的流处理 API。你在两个环境里都需要处理流式数据,但采用了各自环境中最标准和最直接的方式。