前端对接 SSE 的两种常见方式

前端对接 SSE 的两种常见方式

LLM 流式输出、进度推送、长任务状态更新,后端经常会用 SSE(Server-Sent Events):一条 HTTP 长连接,服务端持续往下推事件,客户端边收边渲染。

协议本身不复杂,事件大致长这样:

text 复制代码
data: {"event":"content","data":"你好"}

data: {"event":"end","traceId":"abc"}

关键点:

  • 响应头是 Content-Type: text/event-stream
  • 一条事件通常以空行 \n\n 结束
  • 业务数据多放在 data: 后面(常见再包一层 JSON)

前端真正要选的,是怎么连上这条流。常规有两种:

  1. EventSource(浏览器原生)
  2. fetch + ReadableStream(手动读流)

一句话对比

EventSource fetch + ReadableStream
怎么连 new EventSource(url) fetch(url) 后读 response.body
HTTP 方法 基本只有 GET GET / POST / PUT... 都行
自定义请求头 基本不行(难带 Authorization 随便带
请求体 没有 可以发 JSON body
自动重连 浏览器自带 要自己写
解析成本 低,浏览器帮你拆事件 要自己按行/按段解析
适合场景 公开订阅、简单通知 要登录、要 POST、要精细控制

业务 API 往往需要 Bearer Token + POST body,所以第二种更常见;监控面板、公开进度页,第一种更省事。


方式一:EventSource

基本用法

typescript 复制代码
const es = new EventSource('/api/notifications/stream');

es.onmessage = (event) => {
  // event.data 就是 data: 后面的字符串
  const payload = JSON.parse(event.data);
  console.log(payload);
};

es.onerror = () => {
  // 默认会自动重连;不需要时可 es.close()
  console.error('SSE error');
};

// 主动断开
// es.close();

如果服务端用了命名事件(event: progress),可以这样听:

typescript 复制代码
es.addEventListener('progress', (event) => {
  const payload = JSON.parse((event as MessageEvent).data);
  console.log(payload);
});

优点

  • API 短,上手快
  • 断线自动重连(对「订阅型」推送很友好)
  • 不用自己处理字节流和粘包

限制(也是很多人最终换掉它的原因)

  1. 基本只能 GET

    复杂任务参数不好塞进 URL(还可能暴露在日志/代理里)。

  2. 很难带自定义 Header

    标准 EventSource 不能方便地加:

    http 复制代码
    Authorization: Bearer <token>

    于是常见歪招是把 token 塞进 query:?access_token=...,既丑也不安全。

  3. 错误与状态不好细控

    HTTP 401/403、业务 error 事件、主动取消,都不如 fetch 直观。

什么时候用它

  • 不需要登录,或鉴权已靠 Cookie(同源自动带上)
  • 接口本身就是 GET 订阅
  • 你需要浏览器自带的断线重连

方式二:fetch + ReadableStream

思路:

  1. fetch 发起请求(可 POST、可带 Header)
  2. response.body.getReader() 读二进制块
  3. TextDecoder 转成文本
  4. 按 SSE 规则拆出 data:
  5. JSON.parse 后分发给业务回调

发起带鉴权的 SSE 请求

typescript 复制代码
async function requestAuthorizedSse(
  url: string,
  init: { method: string; body?: string; signal?: AbortSignal },
  onDataLine: (dataLine: string) => void
) {
  const token = localStorage.getItem('token') || '';

  const response = await fetch(url, {
    method: init.method,
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token}`,
    },
    body: init.body,
    signal: init.signal,
  });

  if (!response.ok) {
    const errorBody = await response.text().catch(() => '');
    throw new Error(errorBody || `SSE request failed: ${response.statusText}`);
  }

  await readSseSegments(response, onDataLine, init.signal);
}

按「空行分段」解析(推荐)

SSE 一条事件以 \n\n 结束,按段切最稳:

typescript 复制代码
async function readSseSegments(
  response: Response,
  onDataLine: (dataLine: string) => void,
  signal?: AbortSignal
) {
  if (!response.body) {
    throw new Error('SSE response has no body');
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  try {
    while (true) {
      if (signal?.aborted) {
        throw new DOMException('Aborted', 'AbortError');
      }

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

      buffer += decoder.decode(value, { stream: true });
      const segments = buffer.split('\n\n');
      buffer = segments.pop() || '';

      for (const segment of segments) {
        const trimmed = segment.trim();
        if (!trimmed.startsWith('data:')) continue;

        const dataPart = trimmed.replace(/^data:\s*/, '');
        if (dataPart) onDataLine(dataPart);
      }
    }
  } finally {
    reader.releaseLock?.();
  }
}

业务侧:把 data 解析成事件

typescript 复制代码
await requestAuthorizedSse(
  '/api/generate/draft',
  {
    method: 'POST',
    body: JSON.stringify({ projectId, task }),
    signal: abortController.signal,
  },
  (dataLine) => {
    try {
      const data = JSON.parse(dataLine);
      switch (data.event) {
        case 'start':
          console.log('开始', data.traceId);
          break;
        case 'content':
          appendText(data.data); // 打字机效果
          break;
        case 'end':
          finish(data);
          break;
        case 'error':
          showError(data.data);
          break;
      }
    } catch {
      // 忽略半包/脏行
    }
  }
);

取消流:AbortController

typescript 复制代码
const abortController = new AbortController();

// 用户点「停止生成」
abortController.abort();

signal 传给 fetch,并在 reader.read() 循环里检查 signal.aborted,就能干净停掉。

优点

  • 支持 POST + JSON body(复杂生成任务很常见)
  • 能带 Authorization 等自定义头
  • 取消、超时、非 2xx 错误处理都更可控
  • 和现有 API Client 风格容易统一

代价

  • 要自己处理粘包、半包、解码(下一节展开)
  • 没有浏览器那种「断了自动重连」,需要的话得自己补

粘包、半包、解码到底怎么处理?

reader.read() 每次给你的不是「一条完整 SSE 事件」,而是一块块 字节(Uint8Array)

网络怎么切包,你控制不了,所以会出现三种情况。

1)解码:字节 → 文本

TCP/HTTP 流里先是二进制。中文等多字节字符还可能被拆到两次 read() 中间。

typescript 复制代码
const decoder = new TextDecoder();

// stream: true 很重要:告诉解码器「后面可能还有字节」
// 遇到半个汉字时先缓存,等下次凑齐再吐出完整字符
buffer += decoder.decode(value, { stream: true });

如果写成 decoder.decode(value)(默认 stream: false),半个 UTF-8 字符可能直接变成 `` 或乱码。

2)半包:一次 read() 不够一条事件

服务端本意推送:

text 复制代码
data: {"event":"content","data":"你好"}\n\n

但第一次可能只收到:

text 复制代码
data: {"event":"content","da

第二次才收到:

text 复制代码
ta":"你好"}\n\n

如果每次 read() 立刻 JSON.parse,第一次必炸。

做法:先塞进 buffer,只处理已经完整的部分。

3)粘包:一次 read() 塞了多条事件

也可能一次就收到:

text 复制代码
data: {"event":"start"}\n\n
data: {"event":"content","data":"你"}\n\n
data: {"event":"content","data":"好"}\n\n

如果只当一条处理,会漏事件或解析失败。

做法:用分隔符切开,循环处理每一段。

4)标准解法:缓冲区 + 分隔符

SSE 一条事件以空行 \n\n 结束,所以:

text 复制代码
每次 read 到一块字节
  → decode 成文本,追加到 buffer
  → 用 '\n\n' split
  → 最后一段多半是「还没收完的半包」,塞回 buffer
  → 前面那些完整段,再提取 data: 交给业务

对应代码核心就三行:

typescript 复制代码
buffer += decoder.decode(value, { stream: true });
const segments = buffer.split('\n\n');
buffer = segments.pop() || ''; // 半包留下,完整段拿去处理

图示:

text 复制代码
buffer 当前内容:
┌─────────────────────────────────────────────┐
│ data: {"event":"start"}\n\n                 │  ← 完整,可处理
│ data: {"event":"content","data":"你"}\n\n   │  ← 完整,可处理
│ data: {"event":"cont                        │  ← 半包,留在 buffer
└─────────────────────────────────────────────┘
                     ↑
              segments.pop() 留着等下次

业务层 JSON.parse 再包一层 try/catch,是为了兜住脏数据;

真正防半包的,是上面的 buffer,不是 catch。


服务端要配合什么?

无论前端用哪种连法,服务端都要先把响应变成 SSE:

typescript 复制代码
res.writeHead(200, {
  'Content-Type': 'text/event-stream',
  'Cache-Control': 'no-cache',
  Connection: 'keep-alive',
  'X-Accel-Buffering': 'no', // 避免 Nginx 把流缓冲住
});

// 可选:先写一行注释心跳,帮部分代理保持连接
res.write(': keep-alive\n\n');

// 推一条业务事件
res.write(`data: ${JSON.stringify({ event: 'content', data: '你好' })}\n\n`);

// 结束
res.end();

客户端断开时记得停掉后续写入:

typescript 复制代码
req.on('close', () => {
  aborted = true;
});

为什么常说「拿原生 res 自己写,别走普通 JSON 拦截器」?

普通接口的返回路径通常是:

text 复制代码
Controller return { foo: 1 }
  → 拦截器 / 管道再包一层
  → 变成 { code: 0, msg: 'success', data: { foo: 1 } }
  → 框架一次性 JSON.stringify 后发给前端

SSE 要的是另一条路:

text 复制代码
先写响应头 Content-Type: text/event-stream
  → 每隔一会儿 res.write('data: ...\n\n')
  → 连接一直开着,最后再 res.end()

如果 SSE 也走「普通 JSON 拦截器」,常见会坏在三处:

  1. 格式被包坏

    你本想推:

    text 复制代码
    data: {"event":"content","data":"你好"}\n\n

    拦截器却可能变成一整段:

    json 复制代码
    { "code": 0, "msg": "success", "data": "......流内容或对象......" }

    前端按 SSE 去拆 data: 行,全对不上。

  2. 时机不对

    JSON 接口是「算完再一次性返回」。

    SSE 是「边算边推」。拦截器等你 return 才包装,流式体验没了。

  3. Content-Type 不对

    普通接口默认 application/json;SSE 必须是 text/event-stream

    头设错了,浏览器/客户端不会按事件流处理。

所以 Nest 里常见写法是:

typescript 复制代码
@Post('optimize/plan')
async optimizePlan(@Req() req, @Res() res) {
  // @Res():接管原生响应,框架不再替你自动 JSON.stringify
  res.writeHead(200, { 'Content-Type': 'text/event-stream', /* ... */ });
  res.write(`data: ${JSON.stringify({ event: 'start' })}\n\n`);
  // ... 持续 write
  res.end();
}

如果项目有全局响应拦截器,还要对 SSE 跳过包装 ,例如看到已经是 text/event-stream 就原样放过:

typescript 复制代码
// 伪代码:全局拦截器里
if (contentType.includes('text/event-stream')) {
  return next.handle(); // 不要 map 成 { code, msg, data }
}
return next.handle().pipe(
  map((data) => ({ code: 0, msg: 'success', data }))
);

一句话:

普通接口:框架帮你打包成 JSON 信封。

SSE:你自己按事件协议往响应里「一点一点写」,别让信封逻辑插手。


怎么选?

text 复制代码
需要 POST body?或需要 Authorization Header?
  ├─ 是 → fetch + ReadableStream
  └─ 否
       ├─ 需要自动重连的简单订阅 → EventSource
       └─ 仍想统一客户端封装 → 也可以一律用 fetch

实战经验:

  • 聊天/写作/长任务生成:几乎都是第二种
  • 公告、公开看板、简单通知:第一种够用
  • 团队若已有鉴权 API Client,优先第二种,少维护两套连接哲学

小结

  • SSE 是服务端推事件的 HTTP 长连接,核心格式是 data: ...\n\n
  • 方式一 EventSource:简单、能自动重连,但基本限于 GET,难带自定义头
  • 方式二 fetch + ReadableStream:可 POST、可带 Token、可 Abort,解析要自己写
  • 粘包/半包靠 buffer + \n\n 分隔 ;解码用 TextDecoder({ stream: true })
  • SSE 不要走普通 JSON 信封拦截器:自己 writeHead + 持续 write
  • 选哪种,看你的接口要不要鉴权请求体,大多数业务流式接口会选第二种
相关推荐
Mh1 小时前
如何使用GSAP实现一个 `pinned` 滚动楼层叙事?
前端·javascript·css
Larcher7 小时前
React Router 不只是页面跳转:从 SPA 路由到权限守卫的完整实践
javascript·后端
Larcher7 小时前
大模型为什么每次回答都不一样?一文搞懂 Temperature、Top K 与 Top P
javascript·后端
LucianaiB9 小时前
毕业老学长给我留的最后一句话是:“AI 写的,别挂我名。” 我直接 神(Seed Evolving) 来!助我!
后端
IvanCodes9 小时前
RAG 实战教程(一):RAG 工作原理与完整流程——分片、索引、召回、重排和生成
人工智能·后端·agent
用户9385156350710 小时前
从零在浏览器里跑 DeepSeek-R1:WebGPU + Transformer.js 全链路实战
前端·设计模式·typescript
CodeSheep10 小时前
稚晖君公司人事大变动,来了!
前端·后端·程序员
小满zs10 小时前
Go语言第八章(函数)
后端·go
鱼樱前端11 小时前
用 AI 做内容变收入
前端·人工智能·ai编程