从 EventSource 到可复用 SSE Client:我如何实现多实例、双超时与自动重连

从 EventSource 到可复用 SSE Client:我如何实现多实例、双超时与自动重连

在 AI 对话和代码生成场景中,前端面对的并不是一次普通 HTTP 请求,而是一条可能持续几十秒的任务流。

本文记录我如何从直接使用 EventSource,逐步封装出一个支持多实例、连接/响应超时、自动重连、统一错误处理和生命周期回调的 SSE Client。

一、先认识 SSE

SSE 全称是 Server-Sent Events,即服务器发送事件。它允许服务端在建立 HTTP 连接后,不立即结束响应,而是持续向浏览器推送数据。

一次普通 HTTP 请求通常是:

txt 复制代码
客户端发起请求 → 服务端返回完整响应 → 连接结束

SSE 则是:

txt 复制代码
客户端发起请求 → 服务端保持响应 → 持续推送事件 → 主动或被动关闭

服务端返回的响应类型为:

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

事件流由一段段文本组成,一条标准 SSE 消息可以包含:

txt 复制代码
id: 42
event: message
data: {"content":"你好"}

常见字段含义如下:

字段 作用
data 当前事件携带的数据
event 自定义事件名称
id 事件 ID,可用于断线恢复
retry 建议客户端采用的重连间隔

每条事件以空行结束。服务端只要不断写入新的事件,浏览器就可以不断消费数据,而不需要反复轮询接口。

没有声明 event 或使用 event: message 时,可以通过 onmessage 接收;如果服务端返回 event: answerevent: done 等自定义事件,则需要使用 addEventListener("answer", handler) 监听。

二、EventSource 是什么

浏览器提供了原生 EventSource API,用于建立和消费 SSE 连接。

最基础的用法是:

js 复制代码
const source = new EventSource("/api/tasks/123/events");

source.onopen = () => console.log("连接已打开");

source.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log(data);
};

source.onerror = () => console.log("连接异常");

EventSource 内部有三个连接状态:

状态 含义
CONNECTING 0 正在连接或重连
OPEN 1 连接已建立
CLOSED 2 连接已经关闭

它的优点很直接:

  • 基于标准 HTTP,服务端和代理层容易接入;
  • 浏览器原生支持,不需要额外协议库;
  • 天然适合服务端向客户端的单向流式推送;
  • 支持命名事件、事件 ID 和原生重连语义;
  • 文本流与 AI 模型的增量输出天然契合。

它也有明显限制:

  • 原生 EventSource 主要使用 GET;
  • 不能像 fetch 一样自由设置请求头;
  • 通信方向主要是服务端到客户端;
  • onerror 通常拿不到完整 HTTP 状态码和响应体;
  • 如果缺少业务协议,断线重连后可能重复消费消息。

三、为什么 AI 流式输出适合 SSE

AI 对话的典型链路是:

txt 复制代码
用户提交一次问题
  ↓
服务端开始模型推理
  ↓
持续返回正文、推理摘要和工具状态
  ↓
服务端发送任务完成事件

提交问题是一次性的,后续数据主要从服务端流向客户端。这种"低频上行、持续下行"的模式非常适合 SSE。

和其他方案相比:

方案 通信方式 适合场景
短轮询 客户端反复请求 更新频率低、实时性要求不高
长轮询 请求挂起,返回后重新请求 需要兼容旧系统的准实时场景
SSE 服务端单向持续推送 AI 输出、日志、进度和通知
WebSocket 全双工通信 即时聊天、游戏、多人协同

WebSocket 的能力更强,但能力更强不等于更适合。对于一次提交、持续返回的 AI 生成任务,SSE 的通信模型更简单,HTTP 语义也更清晰。

四、直接使用 EventSource 遇到了什么问题

最初,我在业务组件中直接创建 EventSource:

js 复制代码
const source = new EventSource(url);

source.onmessage = (event) => {
  const data = JSON.parse(event.data);
  message.value += data.content;
};

随着业务完善,组件中很快混入了大量非 UI 逻辑:

  • 创建和保存连接;
  • 判断任务是否结束;
  • JSON 消息解析;
  • 连接超时处理;
  • 网络异常重连;
  • 重试次数维护;
  • 页面卸载清理;
  • 用户取消生成;
  • 错误提示和状态恢复。

更麻烦的是,项目不只有一条流。AI 对话、代码生成和构建日志可能同时存在,各自都维护一套 EventSource、定时器和错误逻辑。

这说明问题已经不再是"如何接收一条消息",而是"如何管理一条流式任务的完整生命周期"。

因此,我开始把连接能力从业务组件中抽离出来。

五、我为客户端划定的职责边界

整个流式链路被拆成三层:

txt 复制代码
SSE Client
  负责连接、超时、重连、关闭和错误收敛

协议适配层
  负责把服务端事件解析成正文、推理摘要和工具状态

业务层
  负责更新会话、消息列表和页面状态

SSE Client 不应该知道当前消息属于哪个聊天气泡,也不应该直接修改 Vue 状态。它只负责稳定地接收数据,再通过回调将结果交给业务侧。

最终对外暴露的生命周期包括:

txt 复制代码
onOpen       连接打开
onMessage    收到消息
onRetry      准备重连
onComplete   任务完成
onError      发生错误
onClose      连接最终关闭

客户端内部则维护:

txt 复制代码
idle → connecting → open → retrying → closed

明确职责和状态以后,后续的多实例、超时与重连才不会互相缠绕。

六、多实例:每个任务拥有独立连接

我采用的是任务级 SSE,而不是整个页面永久共用一条连接:

txt 复制代码
一次 AI 生成任务
  ↓
一个 SseClient 实例
  ↓
一条 EventSource 连接
  ↓
接收多个 chunk
  ↓
任务结束后释放

一次任务可能收到几百个 chunk,但只会创建一个客户端实例。下一轮任务会创建新的实例,因此每轮任务的状态、定时器和重试次数相互隔离。

为了统一管理活跃连接,我使用 Map 保存实例:

js 复制代码
const connections = new Map();

function createSseConnection(url, options) {
  const client = new SseClient(url, options);

  connections.set(client.id, client);
  client.connect();

  return client;
}

function closeAllSseConnections() {
  [...connections.values()].forEach((client) => client.close());
}

每个客户端最终关闭时,会将自己从 connections 中移除。这样既可以查询当前活跃连接数,也可以在退出工作台时统一释放全部连接。

多实例不等于无节制地建立连接。它表达的是"不同任务之间相互隔离",业务仍然可以限制同一会话只能运行一个任务。

七、双超时:连接超时和响应超时

只设置一个 timeout 无法准确描述 SSE 的异常状态。我把超时拆成两类。

1. 连接超时

连接超时关注的是:创建 EventSource 后,规定时间内有没有触发 onopen

txt 复制代码
new EventSource
  ↓
等待 onopen
  ↓ 超过 15 秒
connect timeout

它可以发现网络不可用、服务端没有响应或者代理连接失败。

2. 响应空闲超时

响应超时关注的是:连接已经打开,但是否长时间没有收到任何消息。

txt 复制代码
连接已打开
  ↓
收到部分内容
  ↓ 长时间无新消息
idle timeout

它可以发现服务端任务卡住、代理中断或连接假死。

两个定时器的核心逻辑并不复杂:

js 复制代码
startConnectTimer() {
  this.connectTimer = setTimeout(() => {
    if (this.state === "connecting") {
      this.handleError(createTimeoutError("connect"));
    }
  }, this.options.connectTimeout);
}

refreshIdleTimer() {
  clearTimeout(this.idleTimer);

  this.idleTimer = setTimeout(() => {
    this.handleError(createTimeoutError("idle"));
  }, this.options.idleTimeout);
}

连接打开后清除连接定时器并启动空闲定时器;每收到一条消息,就重新计算空闲时间。

如果模型可能长时间没有正文输出,服务端需要发送心跳事件。心跳不需要展示,但应该刷新空闲定时器,证明连接仍然存活。

八、自动重连:先接管,再重建

EventSource 自带重连能力,但项目需要控制最大次数、重试间隔以及哪些错误可以重试,因此我选择在客户端中统一管理重连。

这里有一个容易忽略的问题:如果发生错误后不关闭旧 EventSource,浏览器可能进行原生重连;业务代码又创建一个新实例,就会同时存在两条连接,造成消息重复。

我的处理顺序是:

txt 复制代码
发生连接错误
  ↓
关闭当前 EventSource
  ↓
清理连接与空闲定时器
  ↓
判断错误是否允许重试
  ↓
延迟创建新的 EventSource

重试使用指数退避,而不是固定间隔:

js 复制代码
function getRetryDelay(retryCount, baseDelay, maxDelay) {
  return Math.min(
    baseDelay * 2 ** (retryCount - 1),
    maxDelay,
  );
}

例如基础间隔为一秒时,重试节奏是:

txt 复制代码
第 1 次:1 秒
第 2 次:2 秒
第 3 次:4 秒

实际实现中我还加入了少量随机抖动,避免网络恢复时多个客户端在同一时刻集中重连。

重试策略通过配置控制:

js 复制代码
retry: {
  enabled: true,
  maxRetries: 3,
  baseDelay: 1000,
  maxDelay: 8000,
},
shouldRetry: (error) => error.retryable,

用户取消、任务完成、解析错误和明确的业务错误不会自动重试;连接中断和超时可以按策略恢复。

九、错误不能只分成"成功"和"失败"

SSE 的错误可能来自完全不同的阶段:

类型 示例 默认策略
创建错误 URL 不合法、构造失败 直接结束
连接错误 网络中断、服务端断开 尝试重连
连接超时 长时间没有 onopen 尝试重连
响应超时 连接打开后长期无消息 尝试重连
解析错误 返回内容不是约定格式 上报并结束
业务错误 鉴权失败、参数错误、任务失败 直接结束

我给错误增加了 typeretryable

js 复制代码
class SseClientError extends Error {
  constructor(type, message, retryable = false) {
    super(message);
    this.name = "SseClientError";
    this.type = type;
    this.retryable = retryable;
  }
}

所有错误进入同一个处理入口,但并不执行完全相同的策略:

txt 复制代码
清理当前连接
  ↓
触发 onError
  ↓
是否可重试?
  ├─ 是:触发 onRetry,延迟重连
  └─ 否:关闭客户端,触发 onClose

统一错误入口减少了重复逻辑,而错误分类保留了不同故障的处理差异。

十、生命周期回调如何解耦业务

封装完成后,业务层不再直接操作 EventSource,只声明各阶段需要做什么:

js 复制代码
const client = createSseConnection(`/api/tasks/${taskId}/events`, {
  connectTimeout: 15_000,
  idleTimeout: 90_000,

  onOpen() {
    taskStatus.value = "streaming";
  },

  onMessage(data) {
    if (data.type === "answer") appendAnswer(data.content);
    if (data.type === "reasoning") appendReasoning(data.content);
    if (data.type === "tool_call") updateToolState(data);
  },

  onComplete() {
    taskStatus.value = "done";
  },

  onRetry({ retryCount }) {
    taskStatus.value = `第 ${retryCount} 次重连`;
  },

  onError(error) {
    console.error(error.type, error.message);
  },

  onClose({ reason }) {
    console.log("连接已关闭:", reason);
  },
});

通信层只负责把解析后的数据交出去。业务层根据 type 将数据分发到正文、推理摘要和工具状态,从而避免所有内容都被拼接进同一个字符串。

这种结构还有一个好处:同一个 SSE Client 可以用于不同业务,区别只在 parseMessage、完成条件和生命周期回调。

十一、多轮对话如何更新正确的消息

每次用户发送消息时,业务层先通过普通请求创建任务并取得 taskId,随后创建一条空的 assistant 消息,再建立本轮 SSE:

js 复制代码
function startStream(conversation, taskId) {
  const conversationKey = conversation.key;
  const assistantKey = crypto.randomUUID();

  appendAssistantMessage(conversationKey, assistantKey);

  return createSseConnection(`/api/tasks/${taskId}/events`, {
    onMessage(data) {
      appendContent(
        conversationKey,
        assistantKey,
        data.content,
      );
    },
  });
}

conversationKeyassistantKey 被保存在回调闭包中,因此本轮所有 chunk 都只会更新本轮 assistant 消息。

下一轮对话会重新执行 startStream,生成新的消息 ID 和客户端实例:

txt 复制代码
第 1 轮:conversation-A + assistant-1 + SSE-1
第 2 轮:conversation-A + assistant-2 + SSE-2

固定本轮 assistantKey 不是限制,反而可以防止上一轮迟到的消息被错误地追加到下一轮回复。

如果一条全局 SSE 需要同时承载多个任务,服务端事件就必须携带 conversationIdtaskIdmessageId,前端再根据这些字段动态路由。

十二、关闭连接时要清理什么

SSE Client 的关闭逻辑必须是幂等的。任务完成、业务报错、用户取消和组件卸载可能同时触发关闭,但真正的资源回收只能执行一次。

最终关闭需要处理:

  • 清除连接超时定时器;
  • 清除响应空闲定时器;
  • 清除待执行的重试定时器;
  • 移除 EventSource 事件函数;
  • 调用 eventSource.close()
  • 从活跃连接 Map 中移除实例;
  • 更新客户端状态;
  • 触发一次 onClose
js 复制代码
close(reason = "manual") {
  if (this.closed) return;

  this.closed = true;
  this.clearAllTimers();
  this.destroyEventSource();
  connections.delete(this.id);
  this.options.onClose({ reason });
}

特别需要清理重试定时器。否则用户已经离开页面,之前安排的重连仍可能创建新的 EventSource,形成难以察觉的连接泄漏。

十三、EventSource 在项目中的两个协议问题

1. 不要把长 prompt 直接放进 URL

由于原生 EventSource 主要使用 GET,有些实现会把用户问题放进查询参数:

txt 复制代码
/api/chat/stream?message=很长的用户输入

这会带来 URL 长度、日志暴露和复杂参数编码问题。

更合理的方式是先创建任务,再订阅任务流:

txt 复制代码
POST /api/ai/tasks
  body: { prompt, context }
  response: { taskId }

GET /api/ai/tasks/{taskId}/events
  response: text/event-stream

POST 负责提交复杂数据,SSE 只负责接收任务结果。

2. 重连要考虑消息重复

重新建立连接后,服务端可能重新发送已经消费过的内容。如果直接拼接,会出现重复文本。

更可靠的事件应该包含:

json 复制代码
{
  "taskId": "task-1",
  "messageId": "message-1",
  "seq": 12,
  "type": "answer",
  "content": "新的片段"
}

前端记录最后消费的序号:

js 复制代码
if (data.seq <= lastSeq) return;
lastSeq = data.seq;

如果后端支持标准 SSE id 字段,还可以结合 Last-Event-ID 做断点恢复。

因此,自动重连只解决"重新连上",seq、消息去重和断点续传才解决"连上后数据仍然正确"。

十四、这次封装带来的变化

封装前,业务组件同时承担通信和 UI 职责:

txt 复制代码
组件
  ├─ 创建 EventSource
  ├─ 解析数据
  ├─ 管理定时器
  ├─ 处理重连
  ├─ 判断任务结束
  └─ 更新页面

封装后,职责被重新划分:

txt 复制代码
SSE Client
  负责连接可靠性与生命周期

协议适配层
  负责将原始事件转换为业务数据

组件
  只负责消费数据与展示状态

最终实现的核心能力包括:

  • 每个任务对应独立客户端,支持多条连接并存;
  • 区分连接超时和响应空闲超时;
  • 使用可配置重试和退避策略恢复临时故障;
  • 统一连接、超时、解析和业务错误;
  • 通过生命周期回调隔离通信层与业务层;
  • 在完成、失败、取消和卸载时统一释放资源。

回过头看,这次封装的重点并不是把 new EventSource 放进一个 class,而是为流式任务建立统一、可预测的生命周期。

相关推荐
咩咩啃树皮1 小时前
ES6 Set 核心特点 + 最简数组去重
前端·javascript·html
aa小小1 小时前
uniApp离线数据不丢失方案
前端
代码小学僧2 小时前
从 0 到 1,记录我的第一个出海 SaaS 独立开发项目
前端·rpc·音视频开发
Aaswk2 小时前
从 HTML/CSS/JS 到 Vue + Axios 的一篇实战笔记
前端·css·vue.js·html
IMPYLH2 小时前
HTML 的 <span> 标签
前端·html
计算机魔术师2 小时前
纽约时报诉案曝光:微软高管私下承认,AI抓取是史上最大劳动盗窃
前端
晓得迷路了2 小时前
栗子前端技术周刊第 147 期 - React Router 8.4、Vite 云服务攻击、pnpm 12.4...
前端·javascript·react.js
SEO_juper2 小时前
2026年用Python检测网站薄内容与重复页:用向量相似度找出“搜索引擎眼中一样“的页面(附完整代码)
开发语言·前端·seo·独立站·谷歌优化
IT_陈寒2 小时前
Vite这坑我踩了,说说静态资源加载那些事儿
前端·人工智能·后端