浏览器端 HTTP 流式通信详解:从 SSE 到 EventSource、fetch 与 ReadableStream

浏览器端 HTTP 流式通信详解:从 SSE 到 EventSource、fetch 与 ReadableStream

  • 前言
  • [1. 先建立整体认识:这到底属于哪一类知识](#1. 先建立整体认识:这到底属于哪一类知识)
    • [1.1 一类知识,几个不同层次](#1.1 一类知识,几个不同层次)
    • [1.2 它要解决什么问题](#1.2 它要解决什么问题)
  • [2. HTTP 基础:浏览器和服务器究竟在交换什么](#2. HTTP 基础:浏览器和服务器究竟在交换什么)
    • [2.1 请求和响应的基本结构](#2.1 请求和响应的基本结构)
    • [2.2 GET 和 POST 为什么会影响 API 选择](#2.2 GET 和 POST 为什么会影响 API 选择)
    • [2.3 请求头、请求体与响应头](#2.3 请求头、请求体与响应头)
    • [2.4 HTTP 响应不是一定要一次性返回](#2.4 HTTP 响应不是一定要一次性返回)
  • [3. SSE:在 HTTP 之上规定事件格式](#3. SSE:在 HTTP 之上规定事件格式)
    • [3.1 SSE 是什么](#3.1 SSE 是什么)
    • [3.2 SSE 的基本数据格式](#3.2 SSE 的基本数据格式)
    • [3.3 为什么大模型流式响应看起来像 JSON](#3.3 为什么大模型流式响应看起来像 JSON)
    • [3.4 SSE 是单向通信](#3.4 SSE 是单向通信)
  • [4. EventSource:浏览器提供的 SSE 专用 API](#4. EventSource:浏览器提供的 SSE 专用 API)
    • [4.1 最小使用方式](#4.1 最小使用方式)
    • [4.2 EventSource 自动做了什么](#4.2 EventSource 自动做了什么)
    • [4.3 EventSource 的限制](#4.3 EventSource 的限制)
  • [5. fetch:更通用的 HTTP 请求 API](#5. fetch:更通用的 HTTP 请求 API)
    • [5.1 fetch() 的职责](#5.1 fetch() 的职责)
    • [5.2 为什么 fetch 能满足大模型请求](#5.2 为什么 fetch 能满足大模型请求)
  • [6. ReadableStream:fetch 如何把持续响应交给 JavaScript](#6. ReadableStream:fetch 如何把持续响应交给 JavaScript)
    • [6.1 Response.body 是什么](#6.1 Response.body 是什么)
    • [6.2 getReader() 和 read()](#6.2 getReader() 和 read())
    • [6.3 为什么要关注 Uint8Array](#6.3 为什么要关注 Uint8Array)
    • [6.4 chunk 不等于消息](#6.4 chunk 不等于消息)
  • [7. 用一个最小服务器观察 SSE](#7. 用一个最小服务器观察 SSE)
  • [8. 使用 EventSource 接收 SSE](#8. 使用 EventSource 接收 SSE)
  • [9. 使用 fetch 手动读取 SSE](#9. 使用 fetch 手动读取 SSE)
    • [9.1 为什么 fetch 需要自己解析](#9.1 为什么 fetch 需要自己解析)
    • [9.2 一个可复用的 SSE 读取器](#9.2 一个可复用的 SSE 读取器)
    • [9.3 从大模型响应中提取增量](#9.3 从大模型响应中提取增量)
  • [10. 为什么大模型聊天通常选择 fetch,而不是 EventSource](#10. 为什么大模型聊天通常选择 fetch,而不是 EventSource)
    • [10.1 大模型请求的完整要求](#10.1 大模型请求的完整要求)
    • [10.2 对比表](#10.2 对比表)
  • [11. 鉴权、CORS 与安全边界](#11. 鉴权、CORS 与安全边界)
    • [11.1 为什么不能把 API Key 放进前端](#11.1 为什么不能把 API Key 放进前端)
    • [11.2 CORS 属于哪一部分](#11.2 CORS 属于哪一部分)
  • [12. 常见错误与排查思路](#12. 常见错误与排查思路)
    • [12.1 把一次 read() 当成一条完整消息](#12.1 把一次 read() 当成一条完整消息)
    • [12.2 每个 chunk 都重新创建 TextDecoder](#12.2 每个 chunk 都重新创建 TextDecoder)
    • [12.3 看到 data: 就立即 JSON.parse()](#12.3 看到 data: 就立即 JSON.parse())
    • [12.4 只判断 fetch 是否抛异常](#12.4 只判断 fetch 是否抛异常)
    • [12.5 连接关闭后定时器仍在运行](#12.5 连接关闭后定时器仍在运行)
  • [13. 从用户点击到页面更新的完整流程](#13. 从用户点击到页面更新的完整流程)
  • 总结

前言

打开一个聊天页面,服务端可能需要几秒钟甚至更久才能生成完整回答。如果浏览器必须等到最后一个字生成完,才把全部内容交给页面,用户看到的就是一段漫长的空白等待。

流式通信解决的不是"让模型更快生成",而是让服务端生成一小段内容后就立即发送,浏览器收到一小段就立即显示。用户因此能很快看到第一个字,并且可以观察答案逐步形成。

这类功能经常同时出现 SSEEventSourcefetch()ReadableStreamTextDecoder 等名词。它们确实有关联,但并不处在同一层:SSE 是通信格式,EventSource 是浏览器提供的专用 API,fetch() 是通用 HTTP 请求 API,ReadableStream 是读取响应正文的流式接口。

本文把它们放进同一个知识框架,从 HTTP 和字节流开始,逐步讲清楚:

  • 为什么这属于"浏览器端 HTTP 流式通信"这一类知识;
  • SSE 的数据到底长什么样,空行和 data: 分别有什么意义;
  • EventSource 为什么简单,但不适合直接发送大模型聊天请求;
  • fetch() 如何读取 Response.body,以及为什么需要手动解析 SSE;
  • ReadableStreamTextDecoder 和网络分包如何协同工作;
  • 如何处理中文跨字节、JSON 跨 chunk、错误响应、鉴权和跨域;
  • 大模型流式输出为什么通常选择 fetch() + ReadableStream

读完后,你不仅能记住几个 API,还能从网络协议、浏览器实现和业务需求三个角度判断:某个实时功能到底该使用 EventSourcefetch(),还是另一种通信方案。

1. 先建立整体认识:这到底属于哪一类知识

1.1 一类知识,几个不同层次

可以把主题命名为:

浏览器端 HTTP 流式通信:浏览器通过 HTTP 接收服务端持续产生的数据,并在数据到达时逐步处理和渲染。

它不是一个只有一个 API 的知识点,而是一条由多个层次组成的链路:

text 复制代码
浏览器端 HTTP 流式通信
├── HTTP 基础
│   ├── GET / POST
│   ├── 请求头、请求体、状态码
│   └── 响应正文可以持续到达
├── SSE 协议
│   ├── text/event-stream
│   ├── data: 字段
│   └── 空行划分事件
├── 浏览器 API
│   ├── EventSource:专门接收 SSE
│   └── fetch:通用 HTTP 请求
├── Streams API
│   ├── ReadableStream
│   ├── getReader()
│   └── read()
├── 文本处理
│   ├── Uint8Array
│   └── TextDecoder
└── 工程配套
    ├── Authorization
    ├── CORS
    └── 取消、重连和错误处理

因此,学习时可以把它作为一个完整章节,但阅读每个 API 时要知道它属于哪一层。这样遇到新工具时,你不会把"协议"和"API"混为一谈。

1.2 它要解决什么问题

普通请求常常是这样的:

text 复制代码
浏览器发请求 -> 服务端处理全部任务 -> 服务端一次性返回 -> 浏览器显示结果

流式请求则是:

text 复制代码
浏览器发请求 -> 服务端持续生成 -> 每生成一段就发送 -> 浏览器持续读取并显示

两种方式的差异如下:

对比项 一次性响应 流式响应
响应结束前是否能看到内容 不能
浏览器是否需要等待完整正文 需要 不需要
用户首次看到内容的时间 通常较晚 通常较早
客户端处理难度 低,常用 response.json() 高,需要处理字节、分包和事件
适合场景 普通查询、短 JSON 聊天、日志、进度、长文本生成

流式响应优化的是 TTFB(Time To First Byte,首字节时间) 或大模型场景下的 TTFT(Time To First Token,首 Token 时间) 的感受,而不是保证总生成时间缩短。

2. HTTP 基础:浏览器和服务器究竟在交换什么

2.1 请求和响应的基本结构

一次 HTTP 通信至少包含请求和响应两部分。请求由客户端发出,响应由服务器返回:

text 复制代码
请求:
POST /api/chat HTTP/1.1
Content-Type: application/json
Authorization: Bearer <token>

{"message":"你好","stream":true}

响应:
HTTP/1.1 200 OK
Content-Type: text/event-stream; charset=utf-8

data: {"text":"你"}

data: {"text":"好"}

请求和响应都可以看成四个部分:

部分 请求中的例子 作用
方法 POST 说明这次请求要做什么
地址 /api/chat 指定服务端资源
头部 Content-TypeAuthorization 描述数据格式、身份和缓存策略
正文 JSON 字符串 携带具体业务数据

响应也有类似结构:状态码表示处理结果,响应头描述正文类型,响应体携带业务数据。

2.2 GET 和 POST 为什么会影响 API 选择

GET 通常用于读取资源,请求参数多放在 URL 的查询字符串中;POST 通常用于提交数据,请求参数放在请求体中。

方法 常见用途 是否适合发送大段 JSON 是否适合聊天请求
GET 查询、读取页面或事件流 不适合 上下文复杂时不方便
POST 提交表单、创建任务、调用模型 适合 适合发送 messages 等结构化数据

大模型聊天请求通常需要传递模型名、系统提示词、历史消息、工具配置和 stream 开关。把这些内容编码到 URL 中既不直观,也容易遇到长度限制,所以通常使用 POST

2.3 请求头、请求体与响应头

请求头和请求体分别表达不同信息:

javascript 复制代码
const response = await fetch('/api/chat', {
  method: 'POST',
  headers: {
    // 告诉服务端 body 是 JSON 文本。
    'Content-Type': 'application/json',
    // Bearer 表示使用令牌进行身份认证。
    Authorization: `Bearer ${token}`,
  },
  // JavaScript 对象必须先序列化成 JSON 字符串。
  body: JSON.stringify({
    message: '你好',
    stream: true,
  }),
});

Content-Type 描述正文格式,不会自动帮我们把对象转成 JSON;因此 headersJSON.stringify() 需要一起使用。

响应头中的 Content-Type 则描述服务器返回的内容格式:

响应头值 含义 常见读取方式
application/json 一次性 JSON 响应 response.json()
text/plain 普通文本 response.text()
text/event-stream SSE 事件流 EventSource 或手动读取 response.body

2.4 HTTP 响应不是一定要一次性返回

HTTP 响应体本质上是一串字节。服务器可以把这些字节一次性写完,也可以先发送一部分,过一会儿再发送下一部分。只要连接仍然保持,客户端就能继续读取后续内容。

这解释了为什么流式响应成立:HTTP 负责承载持续到达的字节,SSE 负责规定这些字节如何表示事件,浏览器 API 负责把数据交给 JavaScript。

3. SSE:在 HTTP 之上规定事件格式

3.1 SSE 是什么

SSE 的全称是 Server-Sent Events。它是一种基于 HTTP 的服务端推送格式,方向是服务器向浏览器发送事件。

SSE 不是一种新的传输层协议,而是一套约定好的文本事件格式。服务器保持 HTTP 连接,并不断写入事件;浏览器按照事件边界读取和分发消息。

服务器通常把响应头设置为:

http 复制代码
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache
Connection: keep-alive

三个响应头的作用分别是:

  • text/event-stream:告诉客户端正文是 SSE 格式,而不是普通 JSON。
  • no-cache:避免中间缓存把多条事件攒起来后一次性返回。
  • keep-alive:希望连接在事件之间保持打开。

3.2 SSE 的基本数据格式

一条最简单的 SSE 事件可以写成:

text 复制代码
data: hello

注意 hello 后面有一个空行。SSE 使用空行表示当前事件结束,多个事件连续排列:

text 复制代码
data: first event

data: second event

data: third event

常见字段如下:

字段 示例 作用
data data: hello 事件的主要数据,可以出现多次
event event: progress 自定义事件名称
id id: 42 事件编号,便于断线恢复
retry retry: 5000 建议客户端断线后等待多少毫秒重连
空行 \n\n 提交当前事件

如果一个事件包含多行 data

text 复制代码
data: first line
data: second line

客户端应当把两行数据按换行连接成 first line\nsecond line,然后再交给业务代码。

3.3 为什么大模型流式响应看起来像 JSON

SSE 规定的是外层事件格式,并没有规定 data 内部必须是什么。大模型服务通常选择把 JSON 放进 data 字段:

text 复制代码
data: {"choices":[{"delta":{"content":"你"}}]}

data: {"choices":[{"delta":{"content":"好"}}]}

data: [DONE]

这里有三层内容:

层次 内容 负责解决的问题
字节层 UTF-8 字节 网络传输
SSE 层 data: 和空行 事件分帧
业务层 JSON、delta.content 表达模型生成内容

[DONE] 不是 JSON,因此不能直接对它执行 JSON.parse()。客户端必须先判断它,再解析其他 data 内容。

3.4 SSE 是单向通信

SSE 的数据方向是:

text 复制代码
服务器  ----持续事件---->  浏览器

浏览器可以通过另外的普通 HTTP 请求向服务器提交问题,但这个提交请求和 SSE 事件连接是两个方向、两个请求。SSE 适合日志推送、任务进度、通知和模型输出;如果需要浏览器和服务器双向持续发送消息,应当考虑 WebSocket。

4. EventSource:浏览器提供的 SSE 专用 API

4.1 最小使用方式

服务器已经提供 SSE 接口时,最简单的客户端代码是:

javascript 复制代码
const source = new EventSource('/events');

source.onopen = () => {
  console.log('SSE 连接已建立');
};

source.onmessage = (event) => {
  // event.data 是服务端 data: 后面的文本。
  console.log(event.data);
};

source.onerror = (error) => {
  console.error('SSE 连接异常', error);
};

new EventSource(url, options?) 的参数如下:

参数 类型 作用
url string SSE 接口地址
options.withCredentials boolean,默认 false 跨域时是否携带 Cookie 等凭据

创建后常用属性和方法如下:

API 说明
source.onopen 连接建立时触发
source.onmessage 收到没有自定义 event 名称的消息时触发
source.onerror 连接错误或断开时触发
source.readyState 当前状态:0 连接中、1 已打开、2 已关闭
source.close() 主动关闭连接,停止自动重连

event 还可以通过 addEventListener() 监听自定义事件:

javascript 复制代码
source.addEventListener('progress', (event) => {
  console.log('进度事件:', event.data);
});

服务器发送:

text 复制代码
event: progress
data: {"percent":50}

浏览器就会触发名为 progress 的监听器,而不是普通的 onmessage

4.2 EventSource 自动做了什么

EventSource 的便利之处在于,它替客户端处理了不少协议细节:

  • 连接到服务器指定的 SSE 地址;
  • 解析 data:event:id 等字段;
  • 收到完整事件后触发 message 或自定义事件监听器;
  • 连接意外断开时按约定尝试重连;
  • 必要时通过 Last-Event-ID 帮助服务器恢复事件位置。

这意味着使用 EventSource 时,开发者通常不需要直接处理 Uint8ArrayTextDecoderReadableStream

4.3 EventSource 的限制

EventSource 的简单来自它的限制。原生 API 主要面向一个固定的 SSE GET 请求:

javascript 复制代码
const source = new EventSource('/events');

它不能像 fetch() 那样自由配置请求:

需求 EventSource 支持情况
使用 GET 接收事件 支持
自定义 Authorization 请求头 原生 API 不提供通用配置方式
发送 JSON 请求体 不支持
选择 POST 不支持
自动解析 SSE 支持
自动断线重连 支持

withCredentials 只控制是否携带浏览器凭据,不能把它当成"设置任意请求头"的入口。实际项目如果必须使用 EventSource,常见鉴权方式是同源 Cookie;把长期 API Key 放进 URL 查询参数虽然能让服务器读到,但会出现在历史记录、代理日志和监控系统中,不适合保存秘密。

5. fetch:更通用的 HTTP 请求 API

5.1 fetch() 的职责

fetch() 不是 SSE 专用 API。它可以发起各种 HTTP 请求,返回一个 Promise;Promise 解析后得到 Response

javascript 复制代码
const response = await fetch('/api/chat', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    message: '请介绍 SSE',
    stream: true,
  }),
});

fetch(input, init?) 的参数可以这样理解:

参数 类型 作用
input URL 字符串或 Request 对象 指定请求地址和基础请求信息
init.method string HTTP 方法,例如 GETPOST
init.headers Headers、对象或键值数组 设置请求头
init.body 字符串、Blob、FormData 等 设置请求体
init.signal AbortSignal 连接 AbortController,实现取消

fetch() 只会在网络错误、请求被取消等情况下拒绝 Promise。收到 401、404、500 时,它通常仍然返回 Response,所以必须主动检查:

javascript 复制代码
if (!response.ok) {
  const message = await response.text();
  throw new Error(`HTTP ${response.status}: ${message}`);
}

常用 Response 属性和方法:

API 作用
response.ok 状态码为 200 到 299 时为 true
response.status 读取 HTTP 数字状态码
response.headers 读取响应头
response.json() 完整读取正文并解析 JSON
response.text() 完整读取正文并转换为字符串
response.body 取得可读响应流,适合增量消费
response.bodyUsed 判断响应体是否已经被消费

一个响应体通常只能消费一次。调用过 response.json() 后,再调用 response.body.getReader() 往往会失败,因为正文已经被读取。

5.2 为什么 fetch 能满足大模型请求

大模型聊天请求常见结构如下:

javascript 复制代码
const response = await fetch('/api/chat', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${token}`,
  },
  body: JSON.stringify({
    model: 'model-name',
    messages: [
      { role: 'user', content: '你好' },
    ],
    stream: true,
  }),
});

这里有三个 EventSource 不方便完成的要求:

  • 使用 POST,因为需要提交复杂的消息数组;
  • 使用 Authorization 请求头完成 Bearer Token 鉴权;
  • 在请求体中发送 JSON,并通过 stream: true 选择流式返回。

因此,fetch() 的选择不是因为它"比 EventSource 更像 SSE",而是因为它先满足了请求方法、请求头和请求体的需求,然后再通过 Response.body 手动消费 SSE 响应。

6. ReadableStream:fetch 如何把持续响应交给 JavaScript

6.1 Response.body 是什么

当服务器返回 text/event-stream 时,正文可能还没有结束。此时:

javascript 复制代码
const stream = response.body;

stream 的类型是 ReadableStream<Uint8Array> | null。它表示一个未来会持续提供数据的来源。

ReadableStream 不等于完整字符串,也不等于一条完整 SSE 消息。浏览器只承诺每次提供一批可读取的数据,不承诺这一批刚好对应一个业务边界。

6.2 getReader() 和 read()

javascript 复制代码
const reader = response.body.getReader();

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

  if (done) break;

  console.log(value); // 通常是 Uint8Array
}

reader.releaseLock();

相关 API 的参数和返回值:

API 参数 返回值 作用
stream.getReader() 本例无参数 Reader 对象 锁定流并取得读取器
reader.read() Promise<{ value, done }> 等待下一批数据
reader.cancel(reason?) 可选取消原因 Promise 放弃后续数据
reader.releaseLock() undefined 释放读取器对流的锁

read() 返回对象中的 done 和 SSE 的 [DONE] 不是同一件事:

标记 所属层次 含义
done: true ReadableStream / 传输层 HTTP 响应流已经关闭
data: [DONE] SSE / 业务层 服务端声明模型结果已经生成完毕

服务端可能先发送 [DONE],之后才关闭连接;也可能因为网络异常没有发送 [DONE],但 done 仍然变成 true。客户端应同时处理两种结束条件。

6.3 为什么要关注 Uint8Array

流读取到的是字节。JavaScript 字符串不是网络传输的底层形式,中文也不是一个字节就能表达:

javascript 复制代码
const encoder = new TextEncoder();
const bytes = encoder.encode('你好');
console.log(bytes); // Uint8Array,元素范围是 0 到 255

TextEncoder.encode(input) 把字符串按 UTF-8 编码为 Uint8Array。反方向需要 TextDecoder

javascript 复制代码
const decoder = new TextDecoder('utf-8');
const text = decoder.decode(bytes);
console.log(text); // 你好

如果一个中文字符的 UTF-8 字节刚好被拆到两个网络 chunk,逐块独立解码可能得到 。连续读取时要告诉解码器"后面还有输入":

javascript 复制代码
let textBuffer = '';
const decoder = new TextDecoder('utf-8');

// 中间块保留不完整的 UTF-8 字节,等待下一块补齐。
textBuffer += decoder.decode(value, { stream: true });

// 传输结束后冲刷解码器内部剩余字节。
textBuffer += decoder.decode();

TextDecoder.decode(input?, options?) 的关键参数是:

参数 说明
input 本次要解码的字节;结束时可以省略
options.stream 中间块设为 true,结束冲刷时省略或设为 false

6.4 chunk 不等于消息

网络分包可能产生这样的情况:

text 复制代码
第一个 chunk:data: {"text":"你
第二个 chunk:好"}

也可能一次 chunk 中包含多条事件:

text 复制代码
data: {"text":"你"}\n\ndata: {"text":"好"}\n\n

所以不能直接写出这样的假设:

javascript 复制代码
const { value } = await reader.read();
const data = JSON.parse(decoder.decode(value));

这段代码只有在"每次读取恰好得到一个完整 JSON,并且字符没有跨块"的理想情况下才成立。真实程序必须先缓冲文本,再按 SSE 的事件边界解析。

7. 用一个最小服务器观察 SSE

为了理解浏览器拿到的内容,先写一个不依赖第三方库的 Node.js SSE 服务。它每隔 500 毫秒发送一条事件:

javascript 复制代码
// sse-server.mjs
import http from 'node:http';

const server = http.createServer((req, res) => {
  if (req.method !== 'GET' || req.url !== '/events') {
    res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
    res.end('Not Found');
    return;
  }

  res.writeHead(200, {
    // SSE 必须声明事件流类型,浏览器才知道如何解释响应。
    'Content-Type': 'text/event-stream; charset=utf-8',
    // 禁止代理把多条事件缓存后再一次性返回。
    'Cache-Control': 'no-cache',
    // 让连接保持打开,等待后续事件。
    Connection: 'keep-alive',
  });

  const messages = ['你', '好', ',这是 SSE。'];
  let index = 0;

  const timer = setInterval(() => {
    if (index === messages.length) {
      // [DONE] 是业务层约定,不是 JSON,所以客户端要先判断它。
      res.write('data: [DONE]\n\n');
      clearInterval(timer);
      res.end();
      return;
    }

    res.write(`data: ${JSON.stringify({ text: messages[index] })}\n\n`);
    index += 1;
  }, 500);

  // 浏览器刷新或主动关闭页面时,停止无意义的定时器。
  req.on('close', () => clearInterval(timer));
});

server.listen(3000, () => {
  console.log('SSE server: http://localhost:3000/events');
});

代码中的 Node API 作用如下:

API 参数 作用
http.createServer(handler) 每次请求执行的回调 创建 HTTP 服务器
res.writeHead(statusCode, headers) 状态码和响应头对象 发送响应状态与头部
res.write(chunk) 字符串或 Buffer 向仍然打开的响应写入一部分内容
res.end(data?) 可选最后一段数据 结束响应连接
server.listen(port, callback?) 端口和启动回调 开始监听请求
setInterval(callback, delay) 回调与毫秒间隔 定时模拟持续生成
clearInterval(timer) 定时器句柄 停止定时任务
req.on('close', handler) 事件名和回调 监听客户端连接关闭

启动服务器:

bash 复制代码
node sse-server.mjs

这里使用 Node 内置模块,不需要安装依赖。服务端每次 res.write() 写入的内容都遵循 data: 内容\n\n 格式,最后使用 [DONE] 告诉客户端业务结果结束。

8. 使用 EventSource 接收 SSE

有了上面的服务端,浏览器可以用很少的代码接收事件:

javascript 复制代码
const source = new EventSource('http://localhost:3000/events');
let content = '';

source.onmessage = (event) => {
  if (event.data === '[DONE]') {
    source.close();
    return;
  }

  const data = JSON.parse(event.data);
  content += data.text;
  document.querySelector('#output').textContent = content;
};

source.onerror = (error) => {
  console.error('SSE 连接失败', error);
  source.close();
};

执行过程是:

  1. EventSource 发起对 /eventsGET 请求。
  2. 服务端响应头声明 text/event-stream
  3. 浏览器读取事件,删除 data: 前缀,并把数据放到 event.data
  4. 客户端先判断 [DONE],普通数据再执行 JSON.parse()
  5. 每次得到 data.text,就追加到 content 并更新 DOM。

在浏览器中直接打开页面时,如果页面与服务器不在同一个源,还需要服务器正确配置 CORS。例如允许指定前端源:

javascript 复制代码
res.writeHead(200, {
  'Content-Type': 'text/event-stream; charset=utf-8',
  'Access-Control-Allow-Origin': 'http://localhost:5173',
});

不要在生产环境随意使用 Access-Control-Allow-Origin: * 配合凭据请求。跨域、Cookie 和 withCredentials 必须根据实际信任边界配置。

9. 使用 fetch 手动读取 SSE

9.1 为什么 fetch 需要自己解析

fetch() 只知道响应正文是一条可读流,并不会因为响应头是 text/event-stream 就自动触发 onmessage。因此需要自己完成:

text 复制代码
fetch()
  -> Response
  -> response.body
  -> reader.read()
  -> Uint8Array
  -> TextDecoder
  -> 文本行
  -> SSE data 事件
  -> JSON

这是 fetch() 的代价,也是它的自由度:可以发送 POST,可以设置请求头和 JSON 请求体,也可以在每一段数据到达时自行决定如何处理。

9.2 一个可复用的 SSE 读取器

下面的函数不依赖 Vue。它只负责解析 SSE,并在得到一个完整事件后调用 onData,因此可以复用在 Vue、React、原生 DOM 或 Node 客户端中。

javascript 复制代码
async function consumeSSE(response, onData) {
  if (!response.body) {
    throw new Error('响应体不可读');
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder('utf-8');
  let textBuffer = '';
  let dataLines = [];
  let finished = false;

  const dispatchEvent = () => {
    if (dataLines.length === 0) return;

    // SSE 允许一个事件拥有多条 data 行,按换行恢复事件正文。
    const payload = dataLines.join('\n');
    dataLines = [];

    if (payload === '[DONE]') {
      finished = true;
      return;
    }

    // data 的正文只是文本;模型接口约定它里面是 JSON。
    onData(JSON.parse(payload));
  };

  const processLine = (line) => {
    if (line === '') {
      // 空行提交一个完整 SSE 事件。
      dispatchEvent();
      return;
    }

    // 冒号开头是 SSE 注释或心跳,不参与业务数据。
    if (line.startsWith(':')) return;

    if (line.startsWith('data:')) {
      // 冒号后最多有一个协议空格,这里只移除左侧空白。
      dataLines.push(line.slice(5).trimStart());
    }
  };

  try {
    while (!finished) {
      const { value, done } = await reader.read();

      if (done) {
        // 没有 input 的 decode() 会冲刷解码器内部缓存。
        textBuffer += decoder.decode();
      } else {
        // stream: true 表示后面还会有字节,避免破坏跨 chunk 的中文字符。
        textBuffer += decoder.decode(value, { stream: true });
      }

      const lines = textBuffer.split(/\r?\n/);
      // 最后一项可能是半行,必须保留到下一轮。
      textBuffer = lines.pop() ?? '';

      for (const line of lines) {
        processLine(line);
        if (finished) break;
      }

      if (done) break;
    }

    // 服务端关闭连接时,最后一行可能没有换行符。
    if (!finished && textBuffer) processLine(textBuffer);
    if (!finished) dispatchEvent();
  } finally {
    // 无论正常结束还是解析异常,都释放流的资源。
    await reader.cancel().catch(() => {});
    reader.releaseLock();
  }
}

这个函数里最重要的不是循环本身,而是两个缓冲区:

缓冲区 保存内容 解决的问题
TextDecoder 内部状态 不完整 UTF-8 字节 中文字符跨 chunk
textBuffer 没有遇到换行的半行文本 data: 或 JSON 被拆开
dataLines 当前 SSE 事件的多行数据 空行之前的事件组装

split(/\r?\n/) 同时兼容常见的 Unix 换行和 Windows 换行。lines.pop() 取出的尾项不能立即解析,因为它可能只是下一条完整行的前半部分。

9.3 从大模型响应中提取增量

假设模型服务返回的是兼容 Chat Completions 的数据:

text 复制代码
data: {"choices":[{"delta":{"content":"你"}}]}

data: {"choices":[{"delta":{"content":"好"}}]}

data: [DONE]

调用解析器时只需要取出业务字段:

javascript 复制代码
let answer = '';

await consumeSSE(response, (data) => {
  // choices 是候选结果数组,示例取第一个候选。
  const delta = data.choices?.[0]?.delta?.content ?? '';
  answer += delta;
  render(answer);
});

这里使用可选链和空值合并,是因为某些事件可能只携带角色、结束原因或其他元数据,不一定每次都有 delta.content

10. 为什么大模型聊天通常选择 fetch,而不是 EventSource

10.1 大模型请求的完整要求

一个典型的聊天请求需要同时满足:

javascript 复制代码
const response = await fetch('/api/chat', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${accessToken}`,
  },
  body: JSON.stringify({
    model: 'model-name',
    messages: [
      { role: 'system', content: '你是一个清晰的助手。' },
      { role: 'user', content: question },
    ],
    stream: true,
  }),
});

请求体字段的意义:

字段 类型 作用
model string 指定服务端支持的模型
messages Array 按顺序提供对话上下文
messages[].role string 区分 systemuserassistant 等角色
messages[].content string 当前消息的文本
stream boolean true 表示请求增量事件,false 表示等待完整 JSON

EventSource 对这种请求的主要障碍是:它是面向 GET 的专用接口,不提供传 JSON 请求体和自由设置 Authorization 头的配置方式。fetch() 恰好可以完成这些工作。

10.2 对比表

能力 EventSource fetch + ReadableStream
主要用途 接收 SSE 发起任意 HTTP 请求并读取正文
请求方法 主要是 GET GETPOST 等均可
自定义请求头 原生能力有限 支持 headers
JSON 请求体 不支持 支持 JSON.stringify()
SSE 自动解析 支持 需要自行解析
断线重连 内置 需要自己实现
读取二进制响应流 不直接暴露 response.body 可读取
大模型聊天请求 简单场景不合适 更适合

选择可以概括为:

  • 只有一个公开的 SSE GET 地址,需要自动重连 :优先考虑 EventSource
  • 需要 POST、JSON、鉴权头或自定义流处理 :使用 fetch()
  • 需要双向持续通信:评估 WebSocket,而不是强行使用 SSE。

11. 鉴权、CORS 与安全边界

11.1 为什么不能把 API Key 放进前端

前端代码运行在用户浏览器中。只要一个值被前端 JavaScript 使用,它就不能再被视为真正的秘密。即使把 Key 写在 .env.local,经过 Vite 构建后,使用 VITE_ 前缀的变量仍可能进入 JavaScript 包或请求头。

错误架构:

text 复制代码
浏览器 -> 直接携带模型 API Key -> 模型服务

推荐架构:

text 复制代码
浏览器 -> 自己的后端 /api/chat -> 后端读取服务器环境变量中的 Key
                                  -> 后端请求模型服务
                                  -> 后端把流转发给浏览器

后端代理可以完成鉴权、限流、用户权限、日志和费用控制。浏览器只知道自己的业务接口,不知道上游模型的长期密钥。

11.2 CORS 属于哪一部分

CORS(Cross-Origin Resource Sharing)是浏览器对跨源 HTTP 请求施加的安全规则。所谓"跨源",通常指协议、域名或端口不同。例如:

text 复制代码
前端:http://localhost:5173
接口:http://localhost:3000/events

这两个地址端口不同,浏览器会按跨源请求处理。服务器需要返回允许前端源的响应头:

http 复制代码
Access-Control-Allow-Origin: http://localhost:5173

如果请求使用 Authorization 等非简单请求头,浏览器可能先发送 OPTIONS 预检请求。服务器需要正确响应允许的方法和请求头:

http 复制代码
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization

CORS 不是服务器防火墙,也不是 API 鉴权。它只是浏览器是否允许前端 JavaScript 读取跨源响应的规则,真正的身份验证和权限控制仍需要服务器完成。

12. 常见错误与排查思路

12.1 把一次 read() 当成一条完整消息

错误写法:

javascript 复制代码
const { value } = await reader.read();
const object = JSON.parse(new TextDecoder().decode(value));

问题在于网络 chunk 没有业务边界。修正方式是保留半行,遇到完整换行后再解析。

12.2 每个 chunk 都重新创建 TextDecoder

错误写法:

javascript 复制代码
const text = new TextDecoder().decode(value);

如果每次都把 chunk 当成独立文本,跨 chunk 的 UTF-8 字符可能损坏。应该复用同一个解码器,并对中间块使用 { stream: true }

12.3 看到 data: 就立即 JSON.parse()

data: [DONE] 不是 JSON;某些事件也可能没有 delta.content。正确顺序是:

text 复制代码
去掉 data: 前缀
-> 判断是否为 [DONE]
-> 再 JSON.parse()
-> 安全读取业务字段

12.4 只判断 fetch 是否抛异常

HTTP 401、429、500 通常不会让 fetch() 自动抛异常。必须显式检查 response.ok,然后再根据 response.status 和错误正文提示用户。

12.5 连接关闭后定时器仍在运行

服务端使用 setInterval() 模拟持续输出时,如果浏览器刷新页面而没有监听 req.close,服务器仍可能继续执行定时器。长时间运行的服务会因此累积无用任务。连接关闭时应清理定时器、数据库游标或上游模型请求。

13. 从用户点击到页面更新的完整流程

以"用户在聊天框提交问题"为例,完整链路如下:

  1. 用户在输入框中填写问题,JavaScript 保存问题文本。
  2. 页面调用 fetch(),使用 POST 发送模型名、消息数组、stream: true 和必要的鉴权信息。
  3. 服务端验证权限后,请求上游模型,并把响应类型设置为 text/event-stream
  4. 模型生成第一个增量,服务端将其包装为 data: {...}\n\n 写入响应。
  5. 浏览器的 fetch() 收到 Response,但此时响应正文可能还在继续传输。
  6. 客户端从 response.body 取得 Reader,调用 read() 等待第一批 Uint8Array
  7. TextDecoder 把连续字节转换成文本,textBuffer 保留尚未完成的行。
  8. 解析器遇到空行,确认一个完整 SSE 事件,取出 data 的正文。
  9. 如果正文是 [DONE],结束业务;否则执行 JSON.parse()
  10. choices[0].delta.content 取出新增文本,并追加到页面状态。
  11. Vue 或其他 UI 框架检测到状态变化,重新渲染答案区域。
  12. 服务器继续生成,步骤 4 到步骤 11 重复,直到收到 [DONE] 或传输流结束。
  13. 客户端释放 Reader,服务端清理定时器和上游请求,整个会话结束。

把这条流程压缩成一条数据转换链,就是:

text 复制代码
用户问题
  -> POST JSON
  -> HTTP Response
  -> ReadableStream
  -> Uint8Array
  -> UTF-8 文本
  -> SSE 事件
  -> JSON 对象
  -> delta 文本
  -> 响应式状态
  -> 页面内容

总结

"SSE、EventSource、fetch 和 ReadableStream"可以作为一个整体学习,名称就是浏览器端 HTTP 流式通信。但在理解时必须记住它们的分工:

  • SSE 是基于 HTTP 的服务端事件格式,规定 data:、事件字段和空行边界;
  • EventSource 是接收 SSE 的专用浏览器 API,自动解析和重连,但主要适合 GET
  • fetch() 是通用 HTTP API,支持 POST、请求头和 JSON 请求体;
  • ReadableStreamfetch() 可以在响应结束前逐块读取正文;
  • TextDecoderUint8Array 按 UTF-8 转成文本,并负责跨 chunk 字符;
  • Authorization 和 CORS 属于请求安全与跨源访问的配套知识。

大模型聊天之所以常用 fetch() + ReadableStream,不是因为 SSE 只能用 fetch,而是因为聊天请求通常需要 POST、自定义鉴权头和 JSON 请求体。fetch() 负责灵活发请求,SSE 负责承载增量事件,ReadableStream 负责读取响应,业务代码再从 JSON 中提取 delta.content

真正可靠的流式实现,关键不在于把文字"打印出来",而在于尊重每一层的边界:不要把 chunk 当成消息,不要把 [DONE] 当成 JSON,不要把前端环境变量当成秘密,也不要把 HTTP 状态码检查留到解析失败之后。

相关推荐
数据知道6 小时前
SDN 与软件定义网络的安全边界在哪里?
网络·安全·网络安全
REDcker6 小时前
用 eBPF 观测 SSL/TLS 明文:原理、钩点与边界
网络·网络协议·ssl
数据知道6 小时前
VLAN 与安全隔离:从配置错误到 VLAN Hopping 攻击
网络·安全·网络安全·智能路由器
Tsuki_tl6 小时前
UDP传输协议
网络·网络协议·udp·传输层·udp校验和·tcp与udp区别·面向数据报
Sagittarius_A*6 小时前
Open HTTP Redirect 开放重定向漏洞:URL 跳转校验缺陷与绕过技术
网络·安全·web安全·http·dvwa·burp suite
信仰8747 小时前
HCIA-华为数通基础理论与实践08
网络·笔记·华为
代码的小搬运工10 小时前
网络请求(NSURL、NSURLRequest、NSURLSessionDataTask、协议回调与 JSON 数据的基本流向)
网络·数据库·json
蓝胖的四次元口袋15 小时前
服务器网络与系统基础-面试题
服务器·网络
程序员JerrySUN16 小时前
Jetson 刷机深度解析:flash.sh vs l4t_initrd_flash.sh(含安全与磁盘加密对比)
linux·网络·arm开发·安全·系统安全