SSE模式核心概念
Server-Sent Events(SSE)是一种基于HTTP协议的单向服务器推送技术,允许服务器通过持久化连接主动向客户端(如浏览器)发送实时数据流。其核心特点包括:
- 单向通信:仅支持服务器到客户端的数据推送,客户端无法直接发送数据。
- 协议轻量:基于标准HTTP/1.1,无需额外端口或复杂握手流程。
- 自动重连:浏览器内置机制,连接断开后自动尝试恢复。
- 事件驱动:支持自定义事件类型(如
data、event、id、retry),便于客户端分类处理。
SSE的典型应用场景包括股票行情更新、新闻推送、实时监控仪表盘等服务端主导的实时数据流场景。例如,携程机票业务通过SSE实现航班动态的实时更新,避免了传统轮询的延迟问题。
SSE的优缺点分析
优势
- 实现简单:仅需设置HTTP响应头(
Content-Type: text/event-stream)并持续写入数据流,无需复杂协议支持。 - 兼容性强:现代浏览器原生支持,无需插件或额外库(IE除外)。
- 资源占用低:相比WebSocket,SSE无需维护双向连接,服务器负载更小。
- 断线重连:内置自动恢复机制,适合网络不稳定环境。
局限性
- 单向通信:无法满足聊天、在线协作等双向交互需求。
- 文本传输:二进制数据需编码后传输,效率略低。
- 浏览器限制:IE全系列不支持,需Polyfill兼容。
- 连接数限制:部分浏览器对同一域名的SSE连接数有限制。
SSE与其他实时通信技术对比
SSE vs WebSocket
| 特性 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 单向(服务器→客户端) | 双向(全双工) |
| 协议基础 | HTTP/1.1 | 独立协议(ws:///wss://) |
| 数据类型 | 文本(需编码二进制) | 文本/二进制 |
| 复杂度 | 低(标准HTTP头) | 高(需握手、心跳机制) |
| 适用场景 | 实时通知、数据监控 | 在线聊天、游戏、协同编辑 |
典型案例:
- SSE:ChatGPT对话流推送、携程航班动态更新。
- WebSocket:微信网页版聊天、腾讯会议实时协作。
SSE vs 轮询技术
| 特性 | SSE | 长轮询 | 短轮询 |
|---|---|---|---|
| 实时性 | 高(服务器主动推送) | 中(等待服务器响应) | 低(固定间隔请求) |
| 服务器负载 | 低(持久连接) | 中(保持连接等待数据) | 高(频繁请求) |
| 实现复杂度 | 低 | 中(需处理超时重试) | 极低(定时请求) |
| 适用场景 | 实时数据流 | 准实时应用(如邮件通知) | 非实时数据更新(如天气预报) |
性能对比:
- SSE:在携程机票业务中,SSE将数据更新延迟从轮询的3-5秒降低至200ms以内。
- 长轮询:阿里云开发者社区案例显示,长轮询可减少80%的无效请求,但服务器仍需维护大量等待连接。
SSE的典型应用场景
-
实时数据仪表盘:
- 金融行业股票价格、交易量实时更新。
- 物联网设备状态监控(如温度、湿度传感器数据流)。
-
通知与警报系统:
- 电商平台订单状态变更推送。
- 安全系统入侵检测实时告警。
-
AI交互场景:
- ChatGPT等大模型的对话流式响应。
- 实时语音转文字结果推送。
-
内容更新:
- 新闻网站头条实时推送。
- 社交媒体动态更新(如Twitter时间线)。
SSE语法
1、SSE数据格式结构
SSE数据流需遵循以下规范,每条消息由多行组成,以特定格式传输:
-
字段定义:
data:(必需):消息内容,支持多行(每行以data:开头,末尾需用\n\n分隔消息)。event:(可选):自定义事件类型,客户端可通过addEventListener监听。id:(可选):消息ID,用于断线重连时恢复上下文。retry:(可选):重连间隔(毫秒),默认3秒。
-
示例消息:
text event: update id: 123 data: {"temperature": 25, "status": "normal"} data: Additional data line\n retry: 5000- 消息结束需用
\n\n分隔。
- 消息结束需用
下边的示例中,我们的数据格式是这样的,data中存放了业务代码,复制某一行内容解析出来如下:
2、HTTP协议配置
服务器需设置以下响应头以启用SSE:
http
HTTP/1.1 200 OK
Content-Type: text/event-stream # 固定MIME类型
Cache-Control: no-cache # 禁用缓存
Connection: keep-alive # 保持长连接
Access-Control-Allow-Origin: * # 跨域支持(可选)
3、前端实现:EventSource API
EventSource 是浏览器原生提供的 SSE 客户端接口,用于建立与服务器之间的持久化连接,接收服务器推送的实时数据流。其设计目标是通过 极简的 API 实现高效的单向通信,同时隐藏底层协议细节(如连接管理、重连机制、事件解析等)。
EventSource 对象触发的事件主要包括以下三种:
- open 事件:当成功连接到服务端时触发。
- message 事件:当接收到服务器发送的消息时触发。该事件对象的 data 属性包含了服务器发送的消息内容。
- error 事件:当发生错误时触发。该事件对象的 event 属性包含了错误信息。
-
基础用法:
javascript const eventSource = new EventSource('/api/sse-endpoint'); // 监听默认消息事件 eventSource.onmessage = (event) => { console.log('Received:', event.data); }; // 监听自定义事件 eventSource.addEventListener('update', (event) => { console.log('Update event:', JSON.parse(event.data)); }); // 错误处理 eventSource.onerror = (error) => { console.error('SSE error:', error); if (eventSource.readyState === EventSource.CLOSED) { console.log('Connection closed'); } }; // 手动关闭连接 // eventSource.close(); -
关键属性与方法:
readyState:连接状态(CONNECTING(0)、OPEN(1)、CLOSED(2))。url:SSE连接的URL。close():主动关闭连接。
-
状态管理示例:
javascript switch (eventSource.readyState) { case EventSource.CONNECTING: console.log('Connecting...'); break; case EventSource.OPEN: console.log('Connected'); break; case EventSource.CLOSED: console.log('Connection closed'); break; }虽然
XMLHttpRequest(XHR)支持长连接和流式响应,但是XHR 仅返回原始响应文本,需手动解析 SSE 格式(如拆分\n\n、提取event:字段)。在最后也会有有基于fetch实现数据获取并处理数据的示例。 -
@microsoft/fetch-event-source:
@microsoft/fetch-event-source是微软开发的 JavaScript 库,用于在浏览器或 Node.js 环境中实现 Server-Sent Events (SSE) 协议。它基于浏览器原生的Fetch API扩展,提供了比传统EventSource更灵活、功能更强大的 SSE 客户端实现,尤其适合需要自定义请求、复杂错误处理或流式数据解析的场景。在实际项目中,我们借助工具包实现快速开发。与原生
EventSource的对比特性 @microsoft/fetch-event-source原生 EventSource请求方法 支持 GET/POST等任意方法仅支持 GET请求头/体 可自定义请求头和请求体 无法设置请求头或请求体 错误处理 提供细粒度错误分类和重试策略 仅支持简单重连,无法自定义逻辑 中断控制 支持 AbortController主动中断需手动关闭连接,无标准化中断机制 环境兼容性 浏览器 + Node.js 仅浏览器 TypeScript 支持 是 否
基于SSE实现AI对话流式输出,vue2举例
一个完整的对话过程包括开场白,提示集,输入框,对话内容等几个要素组成。开场白和提示集可以依赖接口直接返回。当用户输入问题,获取回答的过程就可以基于SSE实现。
1、前端代码,vue2
1、安装@microsoft/fetch-event-source
npm i @microsoft/fetch-event-source --save
2、定义可能需要的数据
export default {
data() {
return {
recommendedQuestions: [], // 示例图片的猜你想问
welcome: '', // 示例图片的开场白
chatList: [], // 示例图片的对话内容
suggestedQuestions: [], // 在ai回复结束以后会有相关问题,类似猜你想问功能
}
}
}
3、在文本框输入内容,点击发送
methods: {
// 用户点击文本框发送消息
onSend() {
const questionId = `question-${Date.now()}` // 定义当前轮次问题的唯一id
const questionItem = {
id: questionId, // 当前轮次问题的唯一id
content: this.msg, // 要发送的内容,也就是文本框输入的内容
isAnswer: false, // 用来标识是用户还是机器人
message_files: null // 用户发送的文件,当前只做文本的回答,根据实际业务调整,这里设置成null
}
const placeholderAnswerId = `answer-placeholder-${Date.now()}` // 定义当前轮次回答的唯一id
const placeholderAnswerItem = {
id: placeholderAnswerId, // 当前轮次回答的唯一id
content: '', // 默认为空,可以根据这个字段切换思考中和机器人回复内容等业务需求
isAnswer: true // 用来标识是用户还是机器人
}
// 用户点击发送,直接把内容回显到问答列表上
this.handleUpdateChatList([questionItem, placeholderAnswerItem])
// 再定义一个取消请求的控制器,比如用户点击停止回答,或者有业务需求要终止当前响应
const abortController = new AbortController()
this.abortControllerRef = abortController
},
// 更新页面问答列表
handleUpdateChatList(newChatList) {
this.chatList = [...this.chatList, ...newChatList]
},
}
4、封装请求
// 导入安装的三方包
import { fetchEventSource } from '@microsoft/fetch-event-source'
// requesOption:配置一些请求信息,比如请求头的一些配置,根据业务自定义,不要的话删除就可以
// otherOptions:业务代码,监听数据流的状态的回调,比如开始响应,响应中,响应完成,报错等事件的回调
const AicsChatStream = (requesOption, otherOptions) => {
const req = new fetchEventSource(请求地址, {
method: 'POST',
signal: abortController.signal,
headers, // 如果需要就传,不需要就不传
body: JSON.stringify(data), // 业务数据,根据后端实际需要处理
onMessage: 接收数据流的回调
onerror: 请求错误的回调,
onclose: 请求关闭的回调
})
}
// 以下是业务代码
// 接收数据流的回调
let isFirstMessage = true; // 是不是第一条数据,可用可不用
const onmessage = (response) => {
handleStream(isFirstMessage, response)
}
// 处理数据流
const handleStream = (isFirstMessage, response) => {
let r = ''
try {
// 此处需要json格式化一下
r = JSON.parse(response.data)
} catch (err) {
// 主动调onData方法,把错误信息当成机器人回复返回去
onData('', false, {
conversationId: undefined,
messageId: '',
errorMessage: `${e}`,
})
}
// 开始解析业务数据
// r的数据结构就是上边截图所示存放的业务代码
// 处理接口返回的数据,防止有特殊编码格式的字符
function unicodeToChar(text) {
if (!text)
return ''
return text.replace(/\\u[0-9a-f]{4}/g, (_match, p1) => {
return String.fromCharCode(parseInt(p1, 16))
})
}
let bufferObj = r
if (bufferObj.event === 'message') {
onData(unicodeToChar(bufferObj.answer), isFirstMessage, {
conversationId: bufferObj.conversation_id,
taskId: bufferObj.task_id,
messageId: bufferObj.id,
})
}
else if (bufferObj.event === 'message_end') {
onMessageEnd?.(bufferObj)
}
// 其他状态判断
}
// 请求错误
const onerror = () => abortController.abort()
// 请求完成
const onclose = () => onCompleted()
5、回显数据
在问答列表上,可以将问和答拆分出来成单独组件,回显markdown语法。这里用的是markdown-it,高亮用的是highlight.js,相关文档都能搜到。
npm i markdown-it highlight.js --save
封装markdown组件回显内容
// 导入相关的包和样式文件,样式文件根据实际需要自动选择
import hljs from 'highlight.js'
import markdownit from 'markdown-it'
import 'github-markdown-css/github-markdown-light.css'
import 'highlight.js/styles/github.css'
// 初始化markdown,如果需要特殊处理可以参考文档
const md = markdownit({
langPrefix: 'language-',
breaks: true,
highlight: function(str, lang) {
if (lang && hljs.getLanguage(lang)) {
const c = hljs.highlight(str, { language: lang, ignoreIllegals: true })
.value
try {
return (
'<pre><code class="hljs">' +
c +
'</code></pre>'
)
} catch (__) {}
}
return (
'<pre><code class="hljs">' + md.utils.escapeHtml(str) + '</code></pre>'
)
}
})
<template>
<div class="markdown-body" v-html="markdownText"></div>
</template>
export default {
computed: {
markdownText() {
//生成html
return md.render(this.content || '')
},
}
}
2、服务端代码,koa2
const Koa = require('koa');
const Router = require('koa-router');
const cors = require('@koa/cors');
// 创建Koa应用
const app = new Koa();
// 创建路由
const router = new Router();
// 启用CORS
app.use(cors());
// 模拟AI回复的函数
function generateAIResponse(userMessage) {
// 简单的关键词匹配模拟AI回复
const responses = {
'你好': '你好!我是AI助手,有什么可以帮助你的吗?',
'你是谁': '我是一个AI对话助手,能够通过SSE提供实时回复。',
'天气': '今天天气不错,阳光明媚,适合外出活动。',
'再见': '再见!祝你有美好的一天!'
};
// 如果没有匹配的关键词,返回默认回复
return responses[userMessage] || '抱歉,我不太理解你的意思。请尝试其他问题。';
}
// SSE端点实现AI对话
router.get('/api/ai-chat', async (ctx) => {
// 设置SSE响应头
ctx.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
// 获取用户输入的消息
const userMessage = ctx.query.message || '你好';
// 模拟思考时间
await new Promise(resolve => setTimeout(resolve, 500));
// 生成AI回复
const aiResponse = generateAIResponse(userMessage);
// 逐字发送AI回复
for (let i = 0; i < aiResponse.length; i++) {
// 发送单个字符
ctx.res.write(`data: ${aiResponse[i]}\n\n`);
// 模拟打字效果
await new Promise(resolve => setTimeout(resolve, 100));
}
// 发送结束信号
ctx.res.write('data: [DONE]\n\n');
ctx.res.end();
});
// 应用路由
app.use(router.routes()).use(router.allowedMethods());
// 启动服务器
const PORT = 3000;
app.listen(PORT, () => {
console.log(`Koa server running on http://localhost:${PORT}`);
console.log(`AI chat SSE endpoint: http://localhost:${PORT}/api/ai-chat?message=你好`);
});
// 说明:
// 1. 安装依赖: npm install koa koa-router @koa/cors
// 2. 运行服务器: node server.js
// 3. 在前端使用EventSource或@microsoft/fetch-event-source连接SSE端点
以上就是实现基于sse模式ai对话的全过程。
3、基于fetch请求的手动处理response.body
1. fetch 默认返回的是 Response 对象,而不是直接解析 SSE 数据
fetch是一个通用的 HTTP 请求 API,它默认返回一个Response对象,该对象包含原始的 HTTP 响应数据(如status、headers、body等)。- SSE 是一种特殊的流式协议,服务器会持续发送
event:格式的数据,而fetch不会自动解析这种格式,而是返回原始的ReadableStream(二进制流)。 - 因此,我们需要手动读取
response.body(即ReadableStream),并解析 SSE 格式的数据。
2. response.body 是一个 ReadableStream,需要 getReader() 逐块读取
response.body是一个 二进制流(ReadableStream) ,而不是直接可读的文本或 JSON。- 要读取这个流,必须使用
getReader()获取一个TextDecoder或Reader对象,然后逐块读取数据。
3. SSE 数据是分块发送的,需要手动拼接
-
SSE 服务器可能会分多次发送数据,每次发送一个
event:或data:块。 -
fetch不会自动拼接这些数据,而是返回原始的流,因此我们需要:- 逐块读取数据。
- 手动拼接 SSE 格式的字符串(如
data: ...\n\n)。 - 解析
event和data字段
4. 对比 EventSource(原生 SSE API)
- 浏览器提供了
EventSourceAPI,它专门用于处理 SSE,自动解析event:和data:格式,并触发onmessage事件。 - 但
EventSource不支持 CORS 自定义头,且 无法手动控制流(如取消请求、重试等)。 - 因此,在需要更灵活控制时,
fetch+ReadableStream是更好的选择。
fetch(url, options).then((response) => {
if (!/^(2|3)\d{2}$/.test(String(response.status))) {
onError?.('Server Error')
return
}
onst reader = response.body?.getReader()
reader?.read().then((result) => {
if (result.done) {
onCompleted && onCompleted()
return
}
buffer += decoder.decode(result.value, { stream: true })
const lines = buffer.split('\n')
try {
lines.forEach((message) => {
if (message.startsWith('data:')) {
bufferObj = JSON.parse(message.substring(5))
// 接下来的代码和上边一堆if判断一样
}
})
} catch (err) {
}
}
其他
上边介绍和代码中,手动进行了完整的从输入到输出的全过程,方便定制化开发和维护。element-plus-x和ant-design-x是vue爱好者和react爱好者分别维护的开箱即用的企业级AI交互组件,类似于使用的element-ui和ant-designui组件库,它们的底层已经实现请求和回显数据的完美封装,通过简单的配置和使用,可以快速帮助开发者完成功能开发。