浏览器端 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. 从用户点击到页面更新的完整流程)
- 总结
前言
打开一个聊天页面,服务端可能需要几秒钟甚至更久才能生成完整回答。如果浏览器必须等到最后一个字生成完,才把全部内容交给页面,用户看到的就是一段漫长的空白等待。
流式通信解决的不是"让模型更快生成",而是让服务端生成一小段内容后就立即发送,浏览器收到一小段就立即显示。用户因此能很快看到第一个字,并且可以观察答案逐步形成。
这类功能经常同时出现 SSE、EventSource、fetch()、ReadableStream、TextDecoder 等名词。它们确实有关联,但并不处在同一层:SSE 是通信格式,EventSource 是浏览器提供的专用 API,fetch() 是通用 HTTP 请求 API,ReadableStream 是读取响应正文的流式接口。
本文把它们放进同一个知识框架,从 HTTP 和字节流开始,逐步讲清楚:
- 为什么这属于"浏览器端 HTTP 流式通信"这一类知识;
- SSE 的数据到底长什么样,空行和
data:分别有什么意义; EventSource为什么简单,但不适合直接发送大模型聊天请求;fetch()如何读取Response.body,以及为什么需要手动解析 SSE;ReadableStream、TextDecoder和网络分包如何协同工作;- 如何处理中文跨字节、JSON 跨 chunk、错误响应、鉴权和跨域;
- 大模型流式输出为什么通常选择
fetch() + ReadableStream。
读完后,你不仅能记住几个 API,还能从网络协议、浏览器实现和业务需求三个角度判断:某个实时功能到底该使用 EventSource、fetch(),还是另一种通信方案。
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-Type、Authorization |
描述数据格式、身份和缓存策略 |
| 正文 | 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;因此 headers 和 JSON.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 时,开发者通常不需要直接处理 Uint8Array、TextDecoder 和 ReadableStream。
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 方法,例如 GET、POST |
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();
};
执行过程是:
EventSource发起对/events的GET请求。- 服务端响应头声明
text/event-stream。 - 浏览器读取事件,删除
data:前缀,并把数据放到event.data。 - 客户端先判断
[DONE],普通数据再执行JSON.parse()。 - 每次得到
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 |
区分 system、user、assistant 等角色 |
messages[].content |
string |
当前消息的文本 |
stream |
boolean |
true 表示请求增量事件,false 表示等待完整 JSON |
EventSource 对这种请求的主要障碍是:它是面向 GET 的专用接口,不提供传 JSON 请求体和自由设置 Authorization 头的配置方式。fetch() 恰好可以完成这些工作。
10.2 对比表
| 能力 | EventSource | fetch + ReadableStream |
|---|---|---|
| 主要用途 | 接收 SSE | 发起任意 HTTP 请求并读取正文 |
| 请求方法 | 主要是 GET |
GET、POST 等均可 |
| 自定义请求头 | 原生能力有限 | 支持 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. 从用户点击到页面更新的完整流程
以"用户在聊天框提交问题"为例,完整链路如下:
- 用户在输入框中填写问题,JavaScript 保存问题文本。
- 页面调用
fetch(),使用POST发送模型名、消息数组、stream: true和必要的鉴权信息。 - 服务端验证权限后,请求上游模型,并把响应类型设置为
text/event-stream。 - 模型生成第一个增量,服务端将其包装为
data: {...}\n\n写入响应。 - 浏览器的
fetch()收到Response,但此时响应正文可能还在继续传输。 - 客户端从
response.body取得 Reader,调用read()等待第一批Uint8Array。 TextDecoder把连续字节转换成文本,textBuffer保留尚未完成的行。- 解析器遇到空行,确认一个完整 SSE 事件,取出
data的正文。 - 如果正文是
[DONE],结束业务;否则执行JSON.parse()。 - 从
choices[0].delta.content取出新增文本,并追加到页面状态。 - Vue 或其他 UI 框架检测到状态变化,重新渲染答案区域。
- 服务器继续生成,步骤 4 到步骤 11 重复,直到收到
[DONE]或传输流结束。 - 客户端释放 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 请求体;ReadableStream让fetch()可以在响应结束前逐块读取正文;TextDecoder把Uint8Array按 UTF-8 转成文本,并负责跨 chunk 字符;Authorization和 CORS 属于请求安全与跨源访问的配套知识。
大模型聊天之所以常用 fetch() + ReadableStream,不是因为 SSE 只能用 fetch,而是因为聊天请求通常需要 POST、自定义鉴权头和 JSON 请求体。fetch() 负责灵活发请求,SSE 负责承载增量事件,ReadableStream 负责读取响应,业务代码再从 JSON 中提取 delta.content。
真正可靠的流式实现,关键不在于把文字"打印出来",而在于尊重每一层的边界:不要把 chunk 当成消息,不要把 [DONE] 当成 JSON,不要把前端环境变量当成秘密,也不要把 HTTP 状态码检查留到解析失败之后。