1. 面试官问:AI 服务端通过流式接口返回自定义事件,前端怎么解析并触发不同回调?
核心思路(一句话)
不要把流式数据当成一个完整字符串处理,而是按照 SSE 协议逐块读取、按行解析、按空行组装完整事件,再根据 event 字段分发到对应回调。
整体流程:
text
服务端
│
│ HTTP Response
▼
ReadableStream
│
│ chunk 可能被任意截断
▼
TextDecoder
│
▼
字符串缓冲区 buffer
│
│ 持续寻找完整的换行符
▼
SSE 字段解析
│
├── event: token
├── data: {"content":"你好"}
├── id: 100
└── retry: 3000
│
│ 空行
▼
组装成一个完整 SSE Event
│
▼
事件分发器
│
├── token → onToken()
├── error → onError()
├── ping → onPing()
└── message → onMessage()
2. SSE 到底是什么?和 WebSocket 有什么区别?
核心思路
SSE 本质上是建立在HTTP 长连接之上的"服务器 → 浏览器"单向事件流协议。
服务端响应通常是:
Cache-Control: no-cache
Connection: keep-alive
Content-Type: text/event-stream
然后持续发送:
text
event: token
id: 101
data: {"content":"你"}
event: token
id: 102
data: {"content":"好"}
event: done
id: 103
data: {}
其中:空行表示一个事件结束。
SSE 和 WebSocket 的核心区别
| 对比 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 服务端 → 客户端 | 双向 |
| 底层 | HTTP | WebSocket |
| 数据格式 | 有明确的 SSE 文本协议 | 消息帧 |
| 浏览器 API | EventSource |
WebSocket |
| 自定义事件 | event: |
自己定义消息协议 |
| 自动重连 | EventSource 原生支持 |
通常自己实现 |
| 断点续传 | Last-Event-ID |
通常自己设计 |
| 适合 | AI 输出、通知、实时日志 | 聊天、游戏、双向实时通信 |
所以面试中不要把:
"流式传输 = WebSocket"
当成默认答案。AI 流式输出非常常见的一种方案就是 SSE。
3. SSE 的事件格式到底是什么?
这是这道题最关键的基础。一个完整 SSE 事件可以包含:
text
event: token
id: 123
retry: 3000
data: {"content":"hello"}
然后通过一个空行结束:
text
event: token
id: 123
data: {"content":"hello"}
event: token
id: 124
data: {"content":" world"}
常见字段
event
指定事件类型:
text
event: token
客户端可以根据它进行事件分发。
data
事件携带的数据:
text
data: {"content":"hello"}
需要注意:一个事件可以有多个 data: 行。
例如:
text
data: hello
data: world
最终事件数据是:
text
hello
world
中间用换行连接。
id
事件 ID:
text
id: 123
用于断线重连后的事件恢复。
retry
告诉客户端建议的重连时间:
text
retry: 3000
单位是毫秒。
4. 为什么不能直接对每个 chunk 解析?
这是这道题真正开始拉开差距的地方。因为:
HTTP 流中的 chunk 边界和 SSE 的事件边界没有任何必然关系。
例如服务端逻辑上发送:
text
event: token
data: hello
网络层可能给你:
text
chunk 1:
event: tok
chunk 2:
en
data: hel
chunk 3:
lo
chunk 4:
甚至:
text
chunk 1:
event: token
data:
chunk 2:
hello
所以:
javascript
reader.read()
返回的 chunk:不是 SSE Event。 这是最重要的认知。
5. 如何解决 chunk 被截断的问题?
核心思路
维护一个持久化 buffer,把每次读取到的数据追加进去,只解析已经完整到达的数据,剩余半截数据继续留在 buffer 中。
结构:
text
chunk
↓
TextDecoder
↓
buffer += chunk
↓
寻找完整事件边界
↓
┌───────────────┐
│ 完整 Event │ → 解析
├───────────────┤
│ 完整 Event │ → 解析
├───────────────┤
│ 半截 Event │ → 留在 buffer
└───────────────┘
6. 为什么要使用 TextDecoder 的 stream: true?
TextDecoder的stream: true是为了保证跨 chunk 的 UTF-8 字符能够被完整、正确地解码。
高级面试官很可能继续追问。不能简单:
javascript
new TextDecoder().decode(chunk)
然后认为每个 chunk 都是完整 UTF-8 字符串。因为:
一个 UTF-8 字符可能跨越两个字节块。
例如一个中文字符可能占多个字节:
text
chunk 1 → UTF-8 字节的一部分
chunk 2 → 剩余字节
所以应该:
javascript
decoder.decode(value, {
stream: true
});
stream: true告诉TextDecoder:这次输入不是整个字节流的结束。如果当前末尾是不完整的 UTF-8 字符,就先保留这些字节,等下一段数据到来后继续解码。
这样 TextDecoder 会保存不完整的 UTF-8 字节序列,等待下一块数据。
7. 完整实现:Fetch + SSE 自定义事件解析器
这是这道题真正可以拿来面试写代码的版本。
javascript
async function consumeSSE(url, handlers = {}) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
if (!response.body) {
throw new Error('当前环境不支持 ReadableStream');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
// 当前正在组装的 SSE 事件
let event = {
event: '',
data: [],
id: '',
retry: undefined
};
// 处理一个完整字段
function processLine(line) {
// 空行:说明一个事件结束
if (line === '') {
dispatchEvent();
return;
}
// 注释行,SSE 中通常用于 heartbeat
if (line.startsWith(':')) {
return;
}
const index = line.indexOf(':');
let field;
let value;
if (index === -1) {
field = line;
value = '';
} else {
field = line.slice(0, index);
value = line.slice(index + 1);
// SSE 规范:
// 如果冒号后面紧跟一个空格,需要去掉
if (value.startsWith(' ')) {
value = value.slice(1);
}
}
switch (field) {
case 'event':
event.event = value;
break;
case 'data':
// data 可以出现多次
event.data.push(value);
break;
case 'id':
event.id = value;
break;
case 'retry':
if (/^\d+$/.test(value)) {
event.retry = Number(value);
}
break;
}
}
function dispatchEvent() {
// 没有 data,也没有 event 等内容时,可以认为没有有效事件
if (
event.data.length === 0 &&
event.event === '' &&
event.id === ''
) {
resetEvent();
return;
}
const type = event.event || 'message';
const data = event.data.join('\n');
const callback = handlers[type];
if (callback) {
callback({
type,
data,
id: event.id,
retry: event.retry
});
} else if (handlers.message) {
// 没有对应处理器时,交给默认处理器
handlers.message({
type,
data,
id: event.id,
retry: event.retry
});
}
resetEvent();
}
function resetEvent() {
event = {
event: '',
data: [],
id: '',
retry: undefined
};
}
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
// stream: true 非常重要
buffer += decoder.decode(value, {
stream: true
});
// SSE 支持 \n,也需要兼容 \r\n
const lines = buffer.split(/\r?\n/);
// 最后一项可能是不完整的一行
buffer = lines.pop();
for (const line of lines) {
processLine(line);
}
}
// flush TextDecoder 中残留的数据
buffer += decoder.decode();
if (buffer) {
const lines = buffer.split(/\r?\n/);
for (const line of lines) {
processLine(line);
}
}
// 注意:
// SSE 规范要求空行结束一个事件。
// 如果连接结束前没有收到空行,
// 一般不要把未完成事件强行当成完整事件。
} finally {
reader.releaseLock();
}
}
调用:
javascript
consumeSSE('/api/chat', {
token(event) {
const data = JSON.parse(event.data);
console.log('收到 token:', data.content);
},
error(event) {
console.error('服务端错误:', event.data);
},
ping(event) {
console.log('心跳:', event.data);
},
done(event) {
console.log('AI 输出完成');
},
message(event) {
console.log('默认事件:', event);
}
});
这样服务端:
text
event: token
id: 101
data: {"content":"你"}
event: token
id: 102
data: {"content":"好"}
event: ping
data: {}
event: done
id: 103
data: {}
前端就会变成:
text
token → token()
token → token()
ping → ping()
done → done()
8. EventSource 能不能直接处理自定义事件?
可以。 EventSource 原生支持 SSE 的自定义 event: 类型。
例如服务端:
text
event: token
data: hello
event: error
data: something wrong
客户端可以:
javascript
const source = new EventSource('/api/chat');
source.addEventListener('token', (event) => {
console.log('token:', event.data);
});
source.addEventListener('error', (event) => {
console.log('error:', event);
});
source.onmessage = (event) => {
console.log('message:', event.data);
};
也就是说:
text
没有 event 字段
↓
message
event: token
↓
token
event: error
↓
error
所以如果面试官问:
SSE 自定义事件怎么处理?
最直接的答案是 addEventListener('事件名', callback)。
9. 那为什么还要自己用 Fetch 解析?
这是非常重要的场景判断。
EventSource
适合:
text
标准 SSE
+
服务端持续推送
+
浏览器原生重连机制
+
不需要自己控制请求
优点:
text
简单
标准
自带重连
自带事件分发
支持 Last-Event-ID
但是它的限制也明显:
text
请求方法主要是 GET
请求头控制能力有限
请求体不好处理
鉴权场景受限
对底层流控制能力较弱
fetch + ReadableStream
适合:
text
AI 流式接口
+
POST
+
复杂请求头
+
Authorization
+
请求体
+
需要自己控制流
+
需要自定义协议
例如:
javascript
fetch('/api/chat', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
message: '你好'
})
});
然后:
javascript
response.body.getReader()
自己解析 SSE。所以真正高级的回答不是:
"SSE 就用 EventSource。"
而是:
标准 SSE、GET、简单订阅场景优先 EventSource;AI 对话这类需要 POST、鉴权、请求体以及更强流控制能力的场景,可以使用 Fetch + ReadableStream 自己解析 SSE。
10. 如果连接断开,怎么断点续传?
这是这道题的第二个核心难点。
SSE 本身提供了:
text
id:
例如:
text
event: token
id: 101
data: 你好
event: token
id: 102
data: 世界
客户端最后成功收到:
text
id: 102
连接突然断开。下一次连接告诉服务端:
http
Last-Event-ID: 102
服务端就可以从:
text
103
继续发送。
11. EventSource 会不会自动处理 Last-Event-ID?
会。 这是原生 EventSource 的重要能力。
服务端:
text
id: 100
data: A
id: 101
data: B
id: 102
data: C
浏览器成功处理:
text
102
之后连接断开。重新连接时,浏览器会携带:
http
Last-Event-ID: 102
服务端可以根据这个 ID 恢复。所以:
text
EventSource
│
├── 自动重连
├── 自动处理 event
├── 自动维护 Last-Event-ID
└── 根据 retry 控制重连时间
使用原生 EventSource 时,浏览器会根据已经处理的 SSE id 自动维护重连时的 Last-Event-ID;如果使用 fetch 自己解析,就需要自己维护并发送这个 ID。
12. Fetch 自己实现时怎么做断点续传?
核心就是:
javascript
let lastEventId = '';
async function connect() {
const response = await fetch('/api/chat', {
headers: {
'Last-Event-ID': lastEventId
}
});
// ...
// 每收到完整事件
lastEventId = event.id;
}
断开后:
text
lastEventId = 102
↓
重新请求
↓
Last-Event-ID: 102
↓
服务端从 103 开始继续
13. retry 到底有什么作用?
服务端可以:
text
retry: 3000
表示:
建议客户端 3000ms 后重连。
它不是:
text
"3 秒后一定重连"
而是 SSE 协议定义的重连时间建议值 。原生 EventSource 会使用这个值。如果自己使用 Fetch:
javascript
let retryDelay = 3000;
解析:
text
retry: 5000
就更新:
javascript
retryDelay = 5000;
然后断线:
javascript
setTimeout(connect, retryDelay);
14. 真正的高级工程实现还要考虑什么?
这时候面试官可能继续追问。
一个完整的生产级 SSE 客户端至少要考虑:
text
SSE Client
│
┌───────────┴───────────┐
│ │
数据解析层 连接管理层
│ │
chunk处理 自动重连
UTF-8处理 retry
行解析 Last-Event-ID
Event组装 最大重试次数
│ 指数退避
▼ │
事件分发 │
│ │
┌────┼────┐ │
▼ ▼ ▼ │
token error done ◄─────────────┘
还需要考虑:
① JSON 解析失败
javascript
try {
const data = JSON.parse(event.data);
} catch {
// 数据格式错误
}
② 服务端发送重复事件
断线重连后,服务端可能重复发送某个事件。因此业务层最好保证:
text
event.id
具有幂等意义。例如:
javascript
if (event.id <= lastProcessedId) {
return;
}
具体是否能够这样比较,要看 ID 是否具有严格顺序语义。
③ 重连风暴
不能:
javascript
while (true) {
connect();
}
生产环境一般使用:
text
指数退避
+
随机抖动
+
最大重试次数
例如:
text
1s
2s
4s
8s
16s
...
避免大量客户端同时重连把服务端打爆。
④ 心跳
SSE 经常通过注释保持连接:
text
: ping
这种数据不是业务事件。客户端应该识别:
javascript
line.startsWith(':')
然后忽略。
15. 这道题的"主要矛盾"和"次要矛盾"
主要矛盾
流式数据没有天然的消息边界。
所以必须解决:
text
chunk 边界
≠
SSE Event 边界
核心技术就是:
text
buffer
+
TextDecoder(stream: true)
+
按行解析
+
空行组装 Event
次要矛盾
在正确解析之后,再解决:
text
事件分发
断线重连
Last-Event-ID
retry
幂等
异常处理
心跳
所以面试的时候不要一上来就说:
"我用 Map 存 callback。"
这不是最核心的问题。
真正核心是:
怎么保证任意 chunk 边界下,都能正确还原出完整 SSE 事件。
16. 面试官如果连续追问,可以这样答
Q1:为什么不能一个 chunk 解析一次?
因为 chunk 没有协议层消息边界,一个 SSE Event 可能跨多个 chunk,所以必须维护 buffer。
Q2:怎么解决?
持续追加 buffer,按换行符解析完整行,遇到空行才认为一个 SSE Event 完整,剩余半截数据留到下一次。
Q3:中文被拆成两个 chunk 怎么办?
TextDecoder.decode(value, { stream: true }),让解码器保存跨 chunk 的不完整 UTF-8 字节。
Q4:SSE 自定义事件怎么处理?
原生 EventSource 可以直接:
javascript
source.addEventListener('token', callback);
如果是 Fetch 自己解析,就读取 event: 字段,然后通过:
javascript
Map<eventName, callback>
进行事件分发。
Q5:连接断了怎么办?
使用 SSE 的 id 做断点标识;原生 EventSource 会自动维护 Last-Event-ID 并重连,Fetch 自己实现则需要自己保存最后成功处理的 ID,并在下一次请求中发送。
Q6:retry 是干什么的?
服务端告诉客户端建议的重连等待时间;原生 EventSource 会处理,Fetch 自己实现则需要自己实现重连策略。
Q7:生产环境还需要什么?
text
指数退避
+
随机抖动
+
最大重试次数
+
事件幂等
+
心跳
+
异常处理
+
取消连接
+
鉴权失效处理
17. 最后给出一个高级前端水平的"满分答案"
核心思路:我会先明确 SSE 的协议边界,再根据场景选择 EventSource 或 Fetch + ReadableStream;如果自己解析,核心是用 TextDecoder + buffer 处理任意 chunk 边界,按 SSE 的字段规则解析,遇到空行组装完整事件,再根据 event 分发回调,同时利用 id、Last-Event-ID 和 retry 做断线续传和重连。
如果是标准 SSE、GET 请求、简单服务端推送,我优先使用 EventSource,因为它原生支持自定义事件、自动重连和 Last-Event-ID。
例如:
javascript
const source = new EventSource('/api/stream');
source.addEventListener('token', event => {
console.log('token:', event.data);
});
source.addEventListener('error', event => {
console.error('error:', event);
});
source.addEventListener('done', event => {
console.log('done');
});
如果是 AI 对话这种场景,需要 POST、请求体、Authorization 或者更强的流控制能力,我会使用 fetch 读取 response.body:
javascript
const response = await fetch('/api/chat', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
message: '你好'
})
});
const reader = response.body.getReader();
这里最关键的问题是:reader.read() 返回的 chunk 不等于一个 SSE Event。
例如一个完整事件:
text
event: token
id: 101
data: hello
可能被拆成:
text
chunk1: event: tok
chunk2: en\ndata: hel
chunk3: lo\n
chunk4: \n
所以不能拿到一个 chunk 就直接解析,而是需要维护 buffer:
text
chunk
↓
TextDecoder
↓
buffer
↓
按换行符解析完整行
↓
按 SSE 字段组装 Event
↓
空行表示 Event 完成
↓
根据 event 分发
同时 TextDecoder 要使用:
javascript
decoder.decode(value, {
stream: true
});
避免一个 UTF-8 字符刚好跨越两个 chunk 时被错误解码。SSE 的核心字段包括:
text
event: token
id: 101
data: {...}
retry: 3000
其中:
event决定事件类型,没有该字段时默认是messagedata是事件数据,并且一个事件可以有多个data字段id用于断线恢复retry表示建议的重连等待时间- 空行表示一个事件结束
解析完成后,可以使用事件名映射回调:
javascript
const handlers = {
token: onToken,
error: onError,
ping: onPing,
done: onDone
};
如果服务端返回:
text
event: token
id: 101
data: {"content":"你好"}
event: done
id: 102
data: {}
就分别调用:
javascript
handlers.token(...)
handlers.done(...)
最后是断线重连。如果使用原生 EventSource,浏览器会根据 SSE 的 id 自动维护最后处理的事件,并在重新连接时通过:
http
Last-Event-ID: 102
告诉服务端从哪里继续。如果使用 Fetch 自己实现,就需要自己记录最后成功处理的 id,重连时主动发送:
http
Last-Event-ID: 102
同时根据服务端的 retry 或自己的重连策略控制重试间隔。生产环境还要考虑指数退避、随机抖动、最大重试次数、重复事件幂等、心跳、鉴权失效和主动取消连接等问题。
所以这道题真正考的不是"会不会调用 EventSource",而是:
能不能理解流式数据没有天然消息边界,并建立一套可靠的"字节流 → SSE 事件 → 事件分发 → 断线恢复"的完整处理链路。
这道题真正的分水岭可以浓缩成一句话:
初级开发会监听事件;高级开发知道事件从字节流里是怎么被可靠地"还原"出来的;更高级的工程师还要解决断线、重复、重连、幂等和异常。