SSE 自定义事件解析与断线续传:高级面试题

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 决定事件类型,没有该字段时默认是 message
  • data 是事件数据,并且一个事件可以有多个 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 事件 → 事件分发 → 断线恢复"的完整处理链路。

这道题真正的分水岭可以浓缩成一句话:

初级开发会监听事件;高级开发知道事件从字节流里是怎么被可靠地"还原"出来的;更高级的工程师还要解决断线、重复、重连、幂等和异常。

相关推荐
骑着蜗牛撵大象3272 天前
Agent 打字机是怎么来的:SSE 与 WebSocket 打通实时响应与中间状态
网络·websocket·网络协议·agent·sse·实时通信·流式输出
蓝胖的四次元口袋2 天前
SSE知识梳理(1)
sse
Java后端的Ai之路8 天前
SSE 接口设计 vs Agent UI:四个开源项目,把「模型吐词」和「界面更新」拆开后,我看懂了差距
ui·开源·sse·agui·agentui
扉伟庆1 个月前
API 网关真流式改造实战:长文翻译首字延迟从 15 秒级降到亚秒级
性能优化·sse·api网关·deepseek·流式输出
thesky1234561 个月前
27届大模型面试准备(七十二):大模型流式推理服务与长连接工程——SSE、背压与首包优化
大模型·sse·长连接·背压·流式推理·首包优化·ttft
名字还没想好☜2 个月前
Next.js Route Handler 做 SSE 服务端推送:实时进度条、自动重连与什么时候别用 WebSocket
开发语言·javascript·websocket·react·sse·next.js
罗小爬EX2 个月前
SSE 流式响应多行文本编码方案
ai·sse
weixin_431600442 个月前
前端对接 SSE 的两种常见方式
前端·后端·学习·ai·sse·nest.js