SSE(Server-Sent Events,服务器发送事件):从协议细节到流式处理实战

前言

在构建实时 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 EventSourceFetch WebSocket
重连机制 EventSource: 自动重连 + 事件 ID 恢复 Fetch: 需手动实现 需手动实现
自定义请求头 EventSource: 不支持 Fetch: 支持 支持
请求方式 EventSource: 仅GET Fetch: 任意 GET(握手后切换协议)
消息格式 文本(text/event-stream 文本或二进制帧
适用场景 实时推送、通知、AI 流式输出、进度更新 在线聊天、协作编辑、实时游戏

选择建议:当你只需要服务器单方面推送数据,且希望享受浏览器原生重连、事件 ID 等便利时,SSE 是最简单的选择。如果需要双向频繁交互,或需要传输二进制数据,则选择 WebSocket。

二、Node.js 后端 SSE 服务实现

2.1 基本步骤

  1. 设置响应头:

    • Content-Type: text/event-stream
    • Cache-Control: no-cache
    • Connection: keep-alive
    • 可选 X-Accel-Buffering: no 禁用 Nginx 缓冲
  2. 使用 res.write()data: ...\n\n 格式发送事件。

  3. 结束时发送 data: [DONE]\n\nres.end()

  4. 监听客户端关闭事件 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-datapayload 等。

示例 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 字段,该事件被客户端忽略,只用于其他目的(如设置 retryid)。
  • 如果事件中包含 id 字段,则 id 会与事件关联(不一定需要 data)。
  • event 字段定义事件类型,没有 event 时默认为 "message"

4.5. 多行数据拼接规则

标准规定:如果事件包含多个 data 字段,客户端会用 LF(\n)连接所有值

例如:

text 复制代码
data: a
data: b

连接为 "a\nb"(末尾没有额外的换行)。

4.6. 结束流与关闭连接

SSE 规范没有定义特殊的"结束"信号,通常由服务器直接关闭连接,或发送一个自定义的 data 值(如 [DONE]),客户端根据约定自行关闭 EventSourcefetch 流。

对于 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 只是"字段"的分隔符。

    例如:

    makefile 复制代码
    event: update\n
    data: hello\n

    此时,这两个字段还在同一个事件块中,尚未结束。

  • 再追加一个 \n(形成 \n\n,表示上一个事件块已经结束,该事件可以派发了。

    makefile 复制代码
    event: 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分割事件,提取dataevent等字段。 没有任何内置解析。你需要自己处理字符流、分割数据块和行、判断事件边界。
事件分派 解析后自动触发onmessage或自定义事件。 你需要解析出事件后,再手动调用自己的处理函数。
HTTP 方法 仅限 GET 请求 支持 POST,可自定义请求头、发送请求体。
请求头 无法自定义(如加 Authorization 鉴权头)。 完全自由控制。

所以,复杂度的根源在于:EventSource 让你看到的是一个个包装精美的"事件",而 fetch 让你直面的是需要自己切分和理解的"字节流"。

6.2 🚀 为何还要用复杂的 fetch

尽管复杂,但在像调用 DeepSeek API 这样的现代场景中,fetch 的"手动挡"反而提供了必不可少的控制力,因为 EventSource 的两大硬伤使其无法胜任:

  1. 强制 GET 限制EventSource 只能用 GET 方法,URL 长度有限,更无法携带复杂的请求体。而调用 DeepSeek API 必须使用 POST 方法,并携带一大段包含历史对话的 JSON 体。
  2. 无法自定义请求头 :出于安全考虑,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 字符串。

  • 原理

    • 流读取得到的 valueUint8Array 类型的二进制字节。
    • TextDecoder 是浏览器内置的 API,默认按 UTF-8 编码将字节流解码为字符串。
    • 它支持流式解码,可以处理多字节字符跨块到达的情况(见下一条)。
  • 为什么需要它?

    我们的 SSE 协议是基于文本行的,而网络传输的是字节。必须将字节解码为字符,才能找到 "data: " 这样的行前缀。


7.3 const { done, value } = await reader.read();

  • 作用 :从流中读取下一个数据块

  • 解构返回值

    • done :布尔值。为 true 时,表示流已经关闭,没有更多数据可读(相当于水龙头拧紧了)。
    • valueUint8Array | undefined。如果 donefalse,它就是本次读取到的字节片段;如果 donetruevalue 通常是 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)恰好可能在一个多字节字符的中间断开。例如,表示"你好"的某个中间字节。
    • 如果 streamfalse(默认),解码器会认为这是完整数据,遇到不完整的字节序列会直接输出替换字符 \ufffd(即"�") ,造成乱码。
    • stream 设为 true,解码器会"记住"不完整的字节,暂存起来不输出字符,等下一块数据到达时再拼接完整后输出。这样就完美解决了跨块字符截断问题。
    • 仅在最后一块或流结束时 ,我们才可能调用一次不带 stream 选项或 stream: falsedecode() 来强制输出所有剩余字节(不过通常我们会在流结束时跳出循环,不再处理)。
  • 为什么需要 buffer 缓冲区?

    数据块不一定按行对齐。一个数据块里可能包含好几行,也可能一行被切到两个块里。我们把每次解码的文本都追加到缓冲区,这样我们就可以从容地从中切出完整的行。


7.5 const data = line.slice(6);

  • 作用 :去掉行首的 "data: " 前缀,提取出真正的 JSON 数据字符串。

    "data: " 正好是 6 个字符(包括冒号后的空格),slice(6) 就是从索引 6 开始截取,去掉它,剩下的就是服务器传过来的实际载荷,比如:

    text 复制代码
    "data: {"content":"Hello"}" 
           ^
           从这里 slice(6) → "{"content":"Hello"}"

🧩 完整逻辑串联

这些代码组合在一起,完成了一个逐字节接收→流式解码→行缓冲→事件提取的流水线,对应 SSE 的流式推送场景:

  1. 获取 reader → 控制流
  2. 循环 read() → 逐块接收
  3. decoder.decode() (stream: true) → 二进制 → 字符串,安全拼接不完整多字节字符
  4. buffer 行缓存 → 分割完整行,处理不完整行滞后
  5. 行解析 (startsWith / slice) → 识别 data: 行,提取 JSON 数据
  6. 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 1data: {"con
  • Chunk 2tent":"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",末尾没有 \nbuffer.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 是一个继承自 EventEmitterReadable 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。你在两个环境里都需要处理流式数据,但采用了各自环境中最标准和最直接的方式。

相关推荐
非凡ghost1 小时前
视频下载神器:直播回放、视频链接一键抓取,还能自动监听!
java·前端·javascript·音视频
镜宇秋霖丶2 小时前
常驻大哥24分法,记得看
前端·javascript·vue.js
小赵同学WoW2 小时前
JS 核心之执行上下文详细解释
前端·javascript
心连欣2 小时前
跨越时代的对话:Vue 2 与 Vue 3 的终极对决与环境搭建指南
前端·javascript·vue.js
HYCS2 小时前
用pixijs实现fabricjs(一):FakeCanvasRenderingContext2D
javascript·webgl·canvas
yqcoder2 小时前
JavaScript 内存揭秘:堆(Heap) vs 栈(Stack)
开发语言·javascript·ecmascript
kyriewen113 小时前
你的前端滤镜慢得像PPT?用Rust+WebAssembly,一秒处理4K图
开发语言·前端·javascript·设计模式·rust·ecmascript·powerpoint
yqcoder3 小时前
JavaScript 深拷贝:如何彻底切断引用关联?
开发语言·前端·javascript
镜宇秋霖丶12 小时前
2026.5.6@霖宇博客制作中遇见的问题
前端·javascript·vue.js