背景
最近在做 AI 相关的项目,前端部分需要实现类似 ChatGPT 的打字机效果。本文整理了我认为比较常见的几种方案,也给出了自己的一些理解,希望可以帮助到大家快速实现类似效果。
本文适用于 Web 端的效果,如果对微信小程序中实现类似效果感兴趣的同学,可以查看以下两篇文章:
tips: 本文不会详细介绍 SSE 原理,如感兴趣可阅读 MDN 相关文档 Server-sent event。
技术背景
本文所用示例涉及到的技术栈
- React + Tailwindcss
- Express
- 其他库:
- @microsoft/fetch-event-source
- alova.js
tips: 默认你已经知道上述库或框架如何使用,本文不会介绍相关技术栈的使用教程。
目录
- 服务端 SSE 接口示例
- 方案一:普通请求 + 前端模拟
- 方案二:基于 SSE 技术的请求实现
- 方案三(推荐):Fetch 请求 + ReadableStream.getReader() 流读取
- 踩坑分享
- Umijs 开发环境配置
- Nginx 配置
服务端 SSE 接口示例
经过调研和实践,我总结出了现有的三种方案。
在介绍这三种方案之前,先用 express 模拟一个 SSE 的服务端接口,方便前端访问。代码如下:
const express = require("express");
const cors = require("cors");
const app = express();
app.use(cors());
const PORT = 5000;
const getTime = () => new Date().toLocaleTimeString();
const contentStr = "很高兴为您服务,我是模拟的 ChatGPT 机器人。".split('')
app.get("/sse", function (req, res) {
res.writeHead(200, {
Connection: "keep-alive",
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
});
// 正常的 sse 结束,需要从客户端触发 close 事件,如果从服务端触发,客户端会收到 error
req.on('close', function () {
console.log('close')
clearInterval(interval)
})
let count = 0
// 此处用计时器来模拟大模型的查询结果
// 通过发送字符数组的长度,来模拟 SSE 服务的 start、cmpl、done 状态
const interval = setInterval(() => {
// 如果前端没有正确触发 SSE 的 close 事件,服务端判断如果数据已发送完成,也会主动关闭事件
if (count > contentStr.length) {
res.end()
clearInterval(interval)
return
} else if (count === 0) {
res.write(
`data:${JSON.stringify({
time: getTime(),
event: 'start',
content: contentStr[count]
})}`
);
res.write("\n\n");
} else if (count === contentStr.length) {
res.write(
`data:${JSON.stringify({
time: getTime(),
event: 'done',
})}`
);
res.write("\n\n");
}
else {
res.write(
`data:${JSON.stringify({
time: getTime(),
event: 'message',
content: contentStr[count]
})}`
);
res.write("\n\n");
}
count++
}, 100);
});
app.listen(PORT, function () {
console.log(`Server is running on port ${PORT}`);
});
注意事项:
- 在 Express 中 SSE 通信需要在浏览器端触发 close 之后,整个连接才会断开,我并没有去调研其他语言是否也是这样,所以此处需要根据实际项目情况做对应调整
- 服务端不能完全依赖前端的 close 触发,因此需要做主动关闭的逻辑处理,避免造成服务器的压力,但需要注意在使用 SSE 时,如果服务端主动断开,会触发浏览器端 SSE 监听的 error 事件,因此需要在浏览器端做相应的业务处理
方案一:普通请求 + 前端模拟
描述
前端模拟,本质是通过常规的 ajax 或者 fetch 请求,一次性拿到数据后,再前端通过定时器的方式,按照一定时间来实现打字机的效果。
核心代码
import { useRef, useState } from 'react';
const SSEOnlyFE = () => {
const [data, setData] = useState('');
const timer = useRef(null)
const handleClick = () => {
setData('思考中...')
clearTimer()
fetch('http://localhost:5000/sse').then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.text();
}).then(resData => {
setData('')
const rst = filterData(resData)
timerEffect(rst)
})
}
const filterData = (dataString) => {
let rst = ''
const dataBlocks = dataString.split('data:');
// 过滤掉第一个空项(由于split()在字符串开始处不匹配)
dataBlocks.shift();
// 遍历每个数据块,解析JSON并提取content
dataBlocks.forEach(block => {
const jsonData = JSON.parse(block);
// 根据 event 来整合最终的数据
if (jsonData.event === 'start' || jsonData.event === 'message') {
rst += jsonData.content
}
// 因为当前方案的 fetch 请求获取的是 SSE 连接结束后的整体数据,因此不必在意 jsonData.event 为 done 的状态
});
return rst
}
// 用 setInterval 来实现逐个字符的输出
const timerEffect = (contentStr) => {
const contentList = contentStr.split('')
timer.current = setInterval(() => {
if (contentList.length > 0) {
const content = contentList.shift()
setData(prevData => prevData + content);
} else {
clearTimer()
}
}, 200)
}
const clearTimer = () => {
if (timer.current) {
clearInterval(timer.current)
}
timer.current = null
}
return <div className='m-[20px] ml-[40px]'>
<h2 className='mb-[10px] text-[20px] font-bold'>普通请求 + 前端模拟</h2>
<div className=' p-[10px] w-[400px] h-[200px] bg-slate-200'>
<div className='w-[80px] h-[30px] text-center rounded-[10px] bg-blue-300 cursor-pointer' onClick={handleClick}><span>发起请求</span></div>
<div>
<div>输出结果:</div>
<div className='text-[#333] w-[300px] '>{data}</div>
</div>
</div>
</div>
};
export default SSEOnlyFE;
效果示例
分析
- 上述示例使用了
fetch请求数据,因为是请求的SSE流,因此只有当所有数据都完成时,请求才算完成,才可以拿到最终结果。 - 拿到的最终结果在
response中可以看到跟我们常见的数据格式有所区别,因此需要用到filterData对数据格式做一次处理,在实际项目中请根据自身数据格式做对应处理。 - 每次拿到的数据都是最终字符串,如果直接渲染到页面,则没有打字机逐字出现的效果,因此借助了
setInterval定时器来模拟实现打字机效果。 - 此方案的优缺点:
- 优点:前端可以当作普通接口来请求数据,不需要额外调整项目中已有的请求器封装,只需要对数据做一次转换处理,以及增加一个模拟打字机的效果即可。对已有项目改造成本较低。
- 缺点:在实际项目中,如果大模型回答速度比较慢,且内容比较多时,整个接口请求的时间就会边长,而且由于前端也增加了一层定时器的模拟效果,会把整个问答流程的时间拉长至少2倍,用户体验极差。
- 建议:可以在问答比较少,且项目前期跑通流程的阶段暂时使用,一旦问答内容变多且完成了初期流程跑通,建议立刻替换为
SSE方式
方案二:基于 SSE 技术的请求实现
描述
使用 SSE 技术,本质是直接使用浏览器的 eventSource 对象,最基础的使用示例如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>SSE Demo</title>
</head>
<body>
<h1>Server Sent Events (SSE) Demo</h1>
<div id="show"></div>
<script>
const eventSource = new EventSource('http://localhost:5000/sse');
let output = ''
const showDom = document.getElementById('show');
eventSource.onmessage = (event) => {
console.log(event)
try {
const data = JSON.parse(event.data);
if(data.event === 'start'){
// 当收到 start 事件时,表示数据开始传递
output += `${data.content}`
} else if(data.event === 'done'){
// 当收到done事件时,关闭sse连接
eventSource.close()
} else {
// 其他状态默认为传输中
output += `${data.content}`
}
showDom.innerText = output
} catch(err){
console.log(err)
}
};
eventSource.onerror = (error) => {
console.error('Error occurred:', error);
eventSource.close();
};
</script>
</body>
</html>
但是在实际项目中,还需要一些其他的深层次的封装,所以这里给大家推荐一个可以支持 SSE 的请求库 alova.js,本示例采用此库。
核心代码
import React, { useRef, useEffect, useState } from 'react';
import { useSSE } from '@alova/scene-react';
import { createAlova } from 'alova';
import GlobalFetch from 'alova/GlobalFetch';
const alovaInstance = createAlova({
requestAdapter: GlobalFetch()
});
const SSEAlova = () => {
// 此处是 alova 库的用法,详情请参考相关文档
const method = (value) => alovaInstance.Get('http://localhost:5000/sse', { param: { key: value } });
const { data, send, close } = useSSE(method, {
immediate: false,
});
const [value, setValue] = useState('');
useEffect(() => {
if (data) {
try {
const jsonData = JSON.parse(data);
if (jsonData.event === 'start' || jsonData.event === 'message') {
setValue(prevData => prevData + jsonData.content);
} else if (jsonData.event === 'done') {
// 当消息发送完毕时,接收到 done 的事件,则前端主动关闭,否则会持续获取消息
close()
}
} catch (err) {
console.log(err)
close()
}
}
}, [data])
const handleClick = () => {
setValue('')
send('begin')
}
return <div className='m-[20px] ml-[40px]'>
<h2 className='mb-[10px] text-[20px] font-bold'>SSE请求 + Alova.js</h2>
<div className=' p-[10px] w-[400px] h-[200px] bg-slate-200'>
<div className='w-[80px] h-[30px] text-center rounded-[10px] bg-blue-300 cursor-pointer' onClick={handleClick}><span>发起请求</span></div>
<div>
<div>输出结果:</div>
<div className='text-[#333] w-[300px] '>{value}</div>
</div>
</div>
</div>
};
export default SSEAlova;
效果示例
分析
- 本示例是标准的 SSE 请求,在实际项目中,建议可以先用
alova.js请求库,可以减少一些逻辑封装。 - 标准的 SSE 请求中,我们收到的数据是流式数据,只要连接建立,就可以实时从服务端获取到最新的数据,不需要额外等待数据传输完成,也不需要前端用定时器做模拟效果,使用起来相对简单。
- 此方案的优缺点:
- 优点: 可以高效率的利用 SSE 技术的优势,实时获取数据。
- 缺点: 无法设置 header 参数,只能在 url 上添加参数,如果项目中有通用的 header 设置,则需要让服务端针对 SSE 接口做额外的处理,尤其是常规项目中,我们会把一些鉴权逻辑相关的 token 信息统一加在请求的 header 中。此时就需要服务端特殊处理,改为从 url 上获取。
- 建议: 如果是新项目,并且服务端也愿意在请求的 url 上处理鉴权逻辑,则可以考虑用这个方案,如果项目比较复杂,直接在 url 增加鉴权逻辑相关的数据成本较大,则不建议使用此方案。
方案三(推荐):Fetch 请求 + ReadableStream.getReader() 流读取
描述
虽然 SSE 的技术符合规范,但由于它无法支持添加 header 请求头的限制,在真正的业务场景中,使用的并不多。
在阅读 dify 源码时,发现他们是利用了 Fetch + ReadableStream.getReader() 的技术方案。核心是借助我们常见的 fetch 请求,以及 ReadableStream 技术的组合。
这里大家可以基于 fetch + ReadableStream.getReader() 自己封装,也可以直接使用现成的库 @microsoft/fetch-event-source 。本示例采用此库。
核心代码
import React, { useRef, useEffect, useState } from 'react';
import { fetchEventSource } from '@microsoft/fetch-event-source';
const SSEFetchEventSource = () => {
const [value, setValue] = useState('');
const handleClick = () => {
setValue('思考中...')
fetchEventSource('http://localhost:5000/sse', {
headers: {
'authorization': 'test sse'
},
onopen(res) {
console.log('连接:', res)
setValue('')
},
onmessage(res) {
try {
const jsonData = JSON.parse(res.data);
if (jsonData.event === 'start' || jsonData.event === 'message') {
setValue(prevData => prevData + jsonData.content);
} else if (jsonData.event === 'done') {
// 因为本质还是 fetch 接口,当消息发送完毕时,
// 接收到 done 的事件,如无特殊逻辑可以不做处理,有特殊逻辑可以做其他逻辑处理
}
} catch (err) {
console.log(err)
}
},
onerror(err) {
console.log('错误:', err)
}
})
}
return <div className='m-[20px] ml-[40px]'>
<h2 className='mb-[10px] text-[20px] font-bold'>Fetch 请求 + ReadableStream </h2>
<div className=' p-[10px] w-[400px] h-[200px] bg-slate-200'>
<div className='w-[80px] h-[30px] text-center rounded-[10px] bg-blue-300 cursor-pointer' onClick={handleClick}><span>发起请求</span></div>
<div>
<div>输出结果:</div>
<div className='text-[#333] w-[300px] '>{value}</div>
</div>
</div>
</div>
};
export default SSEFetchEventSource;
效果示例
分析
- 此方案是当前实际项目中用的比较多的方案,因此当你想在自己的项目中使用时,建议优先选择此方案。
- 如果团队内部人力比较充足或项目工期没有那么赶,可以研究一下
@microsoft/fetch-event-source之后自行封装,否则建议直接使用此库。 - 此方案的优缺点:
- 优点: 可以直接应用到当前项目中,很快的满足已有项目的鉴权逻辑。
- 缺点: 没有直接应用 SSE 技术,
@microsoft/fetch-event-source库本身很久没有迭代了,遇到问题需要具备阅读源码的能力。 - 建议: 推荐优先考虑此方案。
拓展优化
因为不同的大模型返回数据的速度和内容并不一致,如果前端想要更加丝滑的体验,则可以再做一层优化:即基于现有方案的同时,再配合前端定时器,来保证数据可以保持匀速的打印效果。
此处仅贴出本人自己封装的简版控制器,希望可以起到抛砖引玉的作用。
/**
* 打字机效果
*/
export default class MessageManager {
messageList: string[] = [];
timer: any = null;
timerDelay = 100;
onFinish: () => void;
onMessage: (message: string) => void;
stopFlag = false; // 停止标志,如果设置了停止,但是队列没走完,就会等队列走完之后再停止
constructor(
messageList: string[],
timerDelay: number,
onMessage: (message: string) => void,
onFinish: () => void,
) {
this.messageList = messageList;
this.timerDelay = timerDelay;
this.onFinish = onFinish;
this.onMessage = onMessage;
}
start() {
this.timer = setInterval(() => {
if (this.messageList.length > 0) {
this.consume();
} else {
if (this.stopFlag) {
this.immediatelyStop();
}
}
}, this.timerDelay);
}
consume() {
if (this.messageList.length > 0) {
const str = this.messageList.shift();
str && this.onMessage(str);
}
}
add(str: string) {
if (!str) return;
const strChars = str.split('');
this.messageList = [...this.messageList, ...strChars];
}
stop() {
this.stopFlag = true;
}
immediatelyStop() {
// 立刻停止
clearInterval(this.timer);
this.timer = null;
this.messageList = [];
this.onFinish();
}
}
代码很简单,所以就不展开说明了,也不额外做使用示例了。
踩坑分享
在实际开发中,本人也遇到了一些不是很容易被发现的坑,这里一并分享给大家,如果有遇到类似情况的同学,希望可以为你提供一些思路。
Umijs 开发环境配置
本人的项目中用到了 Umijs 脚手架,采用的是上述的第三种方案,但在本地调试过程中发现始终无法实时获取到 onmessage 中的数据,会卡在最后时间一次性输出。
经过查看 Umijs 的 issues 后发现是 Umijs 本地启动的服务器做了一层压缩导致的,详情如下:
在 4.1.5 版本给出了相关的解决方案, UMI_DEV_SERVER_COMPRESS ,默认 Umi 开发服务器自带 compress 压缩中间件,这会使开发时 SSE 数据的传输 无法流式获取 ,通过指定 UMI_DEV_SERVER_COMPRESS=none 来关闭 compress 压缩功能:
UMI_DEV_SERVER_COMPRESS=none umi dev
因此如果你也使用了 Umijs ,遇到类似问题可以考虑升级脚手架,并增加配置项,不过此效果仅影响本地调试,不影响打包后的生产环境。
Nginx 配置
当我们开发完成后,部署到服务器时,也可能会发现服务端发送过来的数据并没有按我们预期实时显示,而是接收完后一并显示,此时需要注意,如果你使用了类似 Nginx 的服务器代理,则需要对其进行配置。因为默认情况下 Nginx 会对流数据做压缩,所以我们可以添加很简单的一句话,使其支持流数据即可。
此处建议只针对 SSE 接口做处理,而尽量减少对其他已有接口的影响。配置示例如下:
# 配置 SSE 请求
location /sse {
proxy_pass http://localhost:5000/sse;
# 最重要的一条配置
proxy_buffering off;
# Other necessary SSE headers
proxy_set_header Cache-Control 'no-cache';
proxy_set_header Connection 'keep-alive';
}
杂谈
- 本文没有着重介绍每一种技术方案的细节原理,而是从实际项目开发者的角度跟大家分享个人的实战案例。
- 因为当初自己在寻找解决方案时,发现大家多是在分享 SSE 技术的基本使用,拿它与现有的 websoket 、fetch 、ajax 作比较,对实际应用的分析较少,所以希望此文可以方便后续有需求的同学可以快速应用到自己项目中。
- 如果你对其中诸如 SSE 技术、
@microsoft/fetch-event-source源码解析等内容感兴趣,可以根据相关关键词进行深入研究。
浏览知识共享许可协议
本作品采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可。