最近进行了AI答疑相关的需求开发,总共进行了两次版本迭代,在此做个记录并分享前端的实现过程。
1.需求描述
前端进行提问,接口这边使用AI进行答疑,回答内容流式输出到前端。接口的 Response Headers 的 Content-Type 为 text/event-stream; charset=utf-8
。
2.第一个版本
前端实现:
/** 获取答案 */
export const getAnswer = (params) => {
return fetch('https://gpt.xxxx.com/chat',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify(params)
}
)
}
// 调用
getAnswer({
question: '为啥?'
})
.then(async response => {
if (!response.ok) {
throw new Error('Network response was not ok')
}
// 确保响应是可读流
if (!response.body) {
throw new Error('Response body is not available')
}
const reader = response.body.getReader()
const textDecoder = new TextDecoder()
let result = true
let output = ''
while (result) {
const { done, value } = await reader.read()
if (done) {
console.log('Stream ended')
result = false
break
}
const chunkText = textDecoder.decode(value)
output += chunkText
}
console.log('output:', output)
})
.catch(() => {
})
为什么不用 axios:不支持。开始也尝试使用过 axios,设置 responseType: 'stream'
,但并不会实现流式输出。
此版本存在的缺陷:后端将前端的提问传给 chatgpt,等带 chatgpt 输入完后才流式输出给前端…
TextDecoder
TextDecoder 是 JavaScript 中用于将字节流(通常是 Uint8Array 或 ArrayBuffer)解码为字符串的 API。
核心功能
- 字节到字符串的转换:
- 将二进制数据(如网络传输的原始字节)转换为可读的字符串
- 支持多种编码格式(UTF-8、ISO-8859-2 等)
- 流式处理:
- 可以分段解码大数据流,避免内存问题
- 自动处理跨分块的字符(如一个多字节字符被分割在两个数据块中)
3.第二个版本
后端修改了第一个版本的不合理的流式输出,采用了 SSE 模式实现,不用等待 chatgpt 回答完成才输出内容。
简单介绍下SSE
SSE:Server-Sent Events 服务器推送事件,简称 SSE,是一种服务端实时主动向浏览器推送消息的技术。
SSE 是 HTML5 中一个与通信相关的 API,主要由两部分组成:服务端与浏览器端的通信协议(HTTP 协议)及浏览器端可供 JavaScript 使用的 EventSource 对象。
从“服务端主动向浏览器实时推送消息”这一点来看,该 API 与 WebSockets API 有一些相似之处。但是,该 API 与 WebSockers API 的不同之处在于:
Server-Sent Events API | WebSockets API |
---|---|
基于 HTTP 协议 | 基于 TCP 协议 |
单工,只能服务端单向发送消息 | 全双工,可以同时发送和接收消息 |
轻量级,使用简单 | 相对复杂 |
内置断线重连和消息追踪的功能 | 手动实现心跳检测 和重连逻辑 |
文本消息(或二进制转 Base64 字符串、或者先 gzip 压缩再转 Base64 字符串) | 类型广泛(二进制、文本) |
支持自定义事件类型 | 不支持自定义事件类型 |
连接数 HTTP/1.1 6 个,HTTP/2 可协商(默认 100) | 连接数无限制 |
心跳检测
心跳检测是一种用于维持和监控网络连接状态的机制,通常用于长连接(如 WebSocket、TCP 连接),以确保通信双方仍然在线,并及时发现连接异常。它的核心原理是定期发送小型数据包(心跳包),如果对方未响应,则认为连接已断开。
自定义事件类型
SSE 协议允许服务器发送带有 自定义事件名 的消息,客户端可以监听特定事件。
示例:
event: userUpdate
data: {"id": 123, "name": "Alice"}
retry: 15000
客户端监听:
const eventSource = new EventSource("/updates");
eventSource.addEventListener("userUpdate", (e) => {
console.log("User updated:", JSON.parse(e.data));
});
用途:
- 分类推送不同业务逻辑的消息(如订单状态变更、通知、日志)。
- 避免客户端解析 data 内容来区分消息类型。
前端实现
/** 获取答案 */
export const getAnswer = (
params: IAnswerParams,
config?: {
onopen?: (response: Response) => Promise<void>
onmessage?: (e: EventSourceMessage) => void
onerror?: (e: any) => void
onclose?: () => void
}
) => {
let p = Object.assign({}, params, {
env: envObj[import.meta.env.VITE_ENV]
})
removeAbort()
window.abortControllerAi = new AbortController()
// console.log(' --- AbortController signal --- ', window.abortControllerAi.signal)
window.abortControllerAi.signal.addEventListener('abort', () => {
console.log(' --- AbortController abort --- ')
// 用户手动中止
})
return fetchEventSource(
'https://gpt.xxxx.com/chat',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify(p),
signal: window.abortControllerAi.signal,
// true: 页面不可见时保持链接
// false: 中断链接, 切回页面或app时再重新发送请求(会导致再次发起请求), 重新建立连接
openWhenHidden: isApp ? false : true, // 可根据自身情况决定
...config
}
)
}
/** 中断ai请求 */
export const abortAiRequest = () => {
if (window.abortControllerAi) {
window.abortControllerAi.abort()
removeAbort()
}
}
function removeAbort() {
if (window.abortControllerAi) {
window.abortControllerAi.signal.removeEventListener('abort', () => {})
}
}
// 调用
let answer = ref('')
getAnswer(
{ question: '为啥?' },
{
onmessage(event) {
// console.log(event.data)
// event.data 为 [DONE] 代表结束
let value: any = {}
try {
value = JSON.parse(event.data)
} catch (e) {}
// 回答内容
let str = ''
try {
str = value.choices.map(i => i.delta.content).join('')
} catch (e) {}
answer.value += str
},
onclose() {
console.log('close')
},
onerror(err) {
console.log('err', err)
throw new Error() // 中断链接
}
}
)
这里使用了 '@microsoft/fetch-event-source'
库,以解决 EventSource
的不足(不支持自定义请求头、仅能使用get等)。
4.EventSource 与 @microsoft/fetch-event-source 的比较
EventSource 是浏览器原生实现的 Server-Sent Events (SSE) 客户端 API,而 @microsoft/fetch-event-source
是一个基于 Fetch API 的 SSE 客户端库。
以下是 EventSource 相对于 @microsoft/fetch-event-source
的主要不足:
1. 功能限制
- 缺乏请求自定义能力:原生 EventSource 不支持自定义请求头、请求方法或请求体
- 无认证支持:无法轻松添加认证头信息(如 Bearer token)
- 仅支持 GET 请求:不能使用其他 HTTP 方法
2. 错误处理不足
- 有限的错误恢复:重连机制较为简单,缺乏精细控制
- 无重试策略定制:无法自定义重试延迟、最大重试次数等
3. 连接控制
- 无法手动关闭并重新连接:原生 EventSource 一旦关闭就不能重新打开
- 缺乏连接状态管理:难以获取详细的连接状态信息
4. 数据格式限制
- 仅支持文本数据:无法直接处理二进制数据
- 固定的事件格式:必须遵循
event: name
+data: ...
的严格格式
5. 浏览器兼容性
- 不支持所有现代功能:如中止信号(AbortSignal)等新特性
- 部分浏览器实现不一致:不同浏览器可能有微小行为差异
6. 缺乏扩展性
- 无法中间件或拦截器:不能在请求/响应链中插入自定义逻辑
- 无进度事件:无法监听连接建立进度
5.AbortController
AbortController
接口表示一个控制器对象,允许你根据需要中止一个或多个 Web 请求。
你可以使用 AbortController()
构造函数创建一个新的 AbortController 对象。使用 AbortSignal
对象可以完成与异步操作的通信。
AbortSignal
继承了其父接口 EventTarget
的属性,所以可以使用 addEventListener()
事件监听器。
AbortController
AbortController.signal
返回一个 AbortSignal 对象实例,可以用它来和异步操作进行通信或者中止这个操作。
AbortController.abort()
中止一个尚未完成的异步操作。这能够中止 fetch 请求及任何响应体和流的使用
AbortSignal
AbortSignal.aborted
一个 Boolean,表示与之通信的请求是否被中止(true)或未中止(false)。
AbortSignal.reason
一旦信号被中止,提供一个使用 JavaScript 值表示的中止原因。
AbortSignal.abort()
返回一个已经被设置为中止的 AbortSignal 实例。
AbortSignal.any()
返回一个在任意给定的中止信号时中止时中止的 AbortSignal 实例。
AbortSignal.timeout()
返回一个在指定时间后自动中止的 AbortSignal 实例。