前言
老粉丝都知道,我们有一个文档问答的AI产品,然后有一个前端要求就是模仿ChatGPT展示后端返回的数据信息(打字效果)。刚开始呢,由于问答比较简单,只是一些简单的文本类型,并且后端返回的结果也有限,加上工期比较紧(反正就是各种原因),我们选择了最原始的前后端数据交互方法。
前端发送问题,后端接入模型分析数据,然后将最后的结果一股脑的返回给前端。就这样岁月静好的度过了一段时间,但是由于需求的变更。后端返回的信息又臭又长,然后还是沿用之前的数据获取和展示方式,就显得捉襟见肘了。
所以,此时我们就从我们知识百宝箱中搜索,然后一眼就相中SSE。之前在写一个类ChatGPT应用,前后端数据交互有哪几种文章中,我们就对其有过简单的介绍。
今天我们就来聊聊,如何实现基于SSE的前后端项目。(我们讲主要逻辑,有些细节例如样式等就不那么考究了)
效果展示
最终,我们就会得到一个类似下面的程序。
图片
好了,天不早了,干点正事哇。
我们能所学到的知识点
- SSE是个啥?
- 用Node实现一个SSE服务
- SSE前端部分(React版本)
- 实现一个打字组件
1. SSE是个啥?
[服务器发送事件]((https://developer.mozilla.org/zh-CN/docs/Web/API/Server-sent_events "服务器发送事件"))(Server-Sent Events,SSE)提供了一种标准方法,通过 HTTP 将服务器数据推送到客户端。与 WebSockets 不同,SSE 专门设计用于服务器到客户端的单向通信,使其非常适用于实时信息的更新或者那些在不向服务器发送数据的情况下实时更新客户端的情况。
服务器发送事件 (SSE) 允许服务器在任何时候向浏览器推送数据:
- 浏览器仍然会发出初始请求以建立连接。
- 服务器返回一个事件流响应并保持连接打开。
- 服务器可以使用这个连接在任何时候发送文本消息。
- 传入的数据在浏览器中触发一个 JavaScript 事件。事件处理程序函数可以解析数据并更新 DOM。
❝
本质上,SSE 是一个无尽的数据流。可以将其视为下载一个无限大的文件,以小块形式拦截和读取。(类比我们之前讲过的大文件分片上传和分片下载)
SSE 首次实现于 2006 年,所有主要浏览器都支持这个标准。它可能不如 WebSockets[1] 知名,但SSE更简单,使用标准 HTTP,支持单向通信,并提供自动重新连接功能。
SSE组件
我们可以将服务器发送事件视为单个 HTTP 请求,其中后端不会立即发送整个主体,而是保持连接打开,并通过每次发送事件时发送单个行来逐步传输答复。
图片
SSE是一个由两个组件组成的标准:
- 浏览器中的 EventSource 接口[2],允许客户端订阅事件:它提供了一种通过抽象较低级别的连接和消息处理来订阅事件流的便捷方法。
- 事件流协议:描述服务器发送的事件必须遵循的标准纯文本格式,以便 EventSource 客户端理解和传播它们
EventSource
作为核心的组件,EventSource的兼容性良好。
图片
工作原理
服务端部分
服务器需要设置 HTTP 头部 Content-Type: text/event-stream 并保持连接不断开,以持续发送事件。典型的服务器发送事件的格式如下:
data: 这是一个事件消息
data: 这是另一个事件消息
可以包含多个字段:
id: 1234
event: customEvent
data: {"message": "这是一个自定义事件"}
retry: 10000
- id:事件 ID,客户端会自动保存这个 ID,并在重连时发送 Last-Event-ID 头部。
- event:事件类型,客户端可以根据类型进行不同处理。
- data:事件数据。
- retry:建议客户端重新连接的时间间隔(毫秒)。
客户端部分
客户端使用 JavaScript 创建一个 EventSource 对象并监听事件:
const eventSource = new EventSource('server-url');
eventSource.onmessage = function(event) {
console.log('收到事件数据:', event.data);
};
eventSource.onerror = function(event) {
console.log('事件源连接错误:', event);
};
eventSource.addEventListener('customEvent', function(event) {
console.log('收到自定义事件:', event.data);
});
更高级用法
在单个频道上发送不同的数据
服务器发送的消息可以有一个相关的事件:在 data: 行上方传递,以识别特定类型的信息:
event: React
data: React is great!
event: Rust
data: { "Rust": "我很喜欢", }
event: AI
data: { "name": "OpenAI" }
这些不会触发客户端的 message 事件处理程序。我们必须为每种类型的事件添加处理程序。例如:
// react 消息处理程序
source.addEventListener('React', e => {
document.getElementById('React')
.textContent = e.data;
});
// Rust 消息处理程序
source.addEventListener('Rust', e => {
const r = JSON.parse(e.data);
document.getElementById('Rust')
.textContent = `${r.Rust}`;
});
// AI 消息处理程序
source.addEventListener('AI', e => {
const ai = JSON.parse(e.data);
document.getElementById(`ai`)
.textContent = `${ai.name}`;
});
使用数据标识符
可选地,服务器也可以在 data: 行之后发送一个 id::
event: React
data: React is great!
id: 42
如果连接断开,浏览器会在 Last-Event-ID HTTP 头中发送最后的 id,以便服务器可以重新发送任何丢失的消息。
最新的 ID 也可以在客户端的事件对象的 .lastEventId 属性中获取:
// news 消息处理程序
source.addEventListener('React', e => {
console.log(`last ID: ${e.lastEventId}`);
document.getElementById('React')
.textContent = e.data;
});
指定重试延迟
虽然重新连接是自动的,但我们的服务器可能知道在特定时间段内不会有新数据,因此无需保持活动的通信通道。服务器可以发送一个包含毫秒值的 retry: 响应,无论是单独发送还是作为最终消息的一部分。例如:
retry: 60000
data: 你很好,这段时间我们还是别联系了!
收到后,浏览器会断开 SSE 连接,并在延迟期过后尝试重新连接。
其他事件处理程序
除了 message 和命名事件,我们还可以在客户端 JavaScript 中创建 open 和 error 处理程序。
open 事件在服务器连接建立时触发。可以用于运行额外的配置代码或初始化 DOM 元素:
const source = new EventSource('/sse1');
source.addEventListener('open', e => {
console.log('SSE connection established.');
});
error 事件在服务器连接失败或终止时触发。我们可以检查事件对象的 .eventPhase 属性以查看发生了什么:
source.addEventListener('error', e => {
if (e.eventPhase === EventSource.CLOSED) {
console.log('SSE connection closed');
} else {
console.log('error', e);
}
});
无需重新连接:它会自动进行。
终止 SSE 通信
浏览器可以使用 EventSource 对象的 .close() 方法终止 SSE 通信。例如:
const source = new EventSource('/sse1');
// 一小时后关闭
setTimeout(() => source.close(), 3600000);
服务器可以通过以下方式终止连接:
- 触发 res.end() 或发送一个 retry: 延迟,然后
- 当相同的浏览器尝试重新连接时返回 HTTP 状态 204。
只有浏览器可以通过创建一个新的 EventSource 对象重新建立连接。
优点
优点 | 描述 |
简单性 | 比 WebSocket 更简单的 API 设计 |
自动管理重连 | 内置的重连机制使开发更简便 |
浏览器支持 | 现代浏览器普遍支持 EventSource |
缺点
缺点 | 描述 |
单向通信 | 无法从客户端向服务器发送数据 |
基于 HTTP | 相比 WebSocket,SSE 在处理高频率数据传输时性能可能较低 |
受限于同源策略 | 跨域通信需要额外配置 CORS(跨域资源共享) |
在讲代码前,我们来简单说一下我们要实现的交互
- 前端输入信息
- 通过Post接口传人后端
- 后端处理请求,拼接数据,返回SSE格式数据
- 前端通过EventSource事件接收数据
2. 用Node实现一个SSE服务
如果想了解一个事物的全貌,那就是接近它,了解它,实现它。
那么,我们就来自己用Node实现一个SSE服务。我们使用express[3]来搭建后端服务。
在我们心仪的目录下,执行如下命令
mkdir SSE &&
cd SSE &&
mkdir Server &&
cd Server &&
npm init
构建一个简单的Node项目。
然后,更新我们的package.json,这里就偷懒了,我直接把本地的内容复制下来了。
{
"name": "server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "tsc && node -v",
"dev": "tsc && tsc-watch --onSuccess \"node dist/index.js\""
},
"dependencies": {
"@types/uuid": "^10.0.0",
"body-parser": "^1.20.2",
"cors": "^2.8.5",
"express": "^4.18.2",
"uuid": "^10.0.0"
},
"devDependencies": {
"@types/cors": "^2.8.13",
"@types/express": "^4.17.17",
"tsc-watch": "^6.0.4",
"typescript": "^5.1.6"
},
"author": "Front789",
"license": "ISC"
}
处理主要逻辑
我们将只要的逻辑方式在src/index.ts中。
图片
让我们来挑几个重要的点来解释一下:
导入依赖和初始化Express
import express from "express";
import cors from "cors";
import bodyParser from "body-parser";
import { v4 as uuidv4 } from "uuid";
const app = express();
app.use(cors());
app.use(bodyParser.json());
const port = 4000;
这是一段实例化Express的代码。不做过多解释。我们是用了两个中间件
- app.use(cors()): 应用 CORS 中间件,使服务器能够处理跨域请求。
- app.use(bodyParser.json()): 应用 Body Parser 中间件,自动解析请求体中的 JSON 数据,并将其存储在 req.body 中。
处理SSE链接
// SSE连接处理
app.get('/api/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders(); // 发送头部信息到客户端
const clientId = uuidv4();
const newClient = {
id: clientId,
res,
};
clients.push(newClient);
req.on('close', () => {
console.log(`${clientId} Connection closed`);
clients = clients.filter((client) => client.id !== clientId);
});
});
这部分其实也很简单,但是呢,我们要特别注意一下res.setHeader()部分。
- Content-Type: text/event-stream: 设置内容类型为 text/event-stream,表明这是一个 SSE 连接。
- Cache-Control: no-cache: 禁用缓存,以确保实时数据传输。
- Connection: keep-alive: 保持连接不断开。
- res.flushHeaders(): 立即将响应头部发送给客户端,确保连接保持活跃状态。
当我们每次接收到/api/events时,没有立马向请求方返回数据,而是构建一个newClient,并且将其push到一个全局变量clients中。
当客户端关闭连接时,从列表中移除相应的客户端,我们在close中执行对应的移除操作。
处理Post请求
// 处理POST请求
app.post('/api/message', (req, res) => {
const userInput = req.body.message;
// 模拟处理消息并推送给所有客户端
const responses = generateChatGPTResponse(userInput);
let index = 0;
const intervalId = setInterval(() => {
if (index < responses.length) {
clients.forEach((client) => client.res.write(`data: ${JSON.stringify({ message: responses[index] })}\n\n`));
index++;
} else {
clearInterval(intervalId);
res.end();
}
}, 1000); // 每秒发送一个响应
res.status(200).send();
});
function generateChatGPTResponse(input:string) {
// 模拟AI模型的响应,这里可以替换为实际的模型调用
return [
`你说的是: ${input}`,
"这是AI模型的第一段响应。",
"这是AI模型的第二段响应。",
"这是AI模型的第三段响应。",
];
}
该段代码代码也是我们常见的用于处理Post请求的方法。有几点需要额外注意一下
- 使用 req.body.message 获取客户端发送的消息内容,这需要 body-parser 中间件来解析请求体中的 JSON 数据
- 使用 setInterval 定时器每秒推送一条消息给所有 SSE 连接的客户端
- 在消息推送开始之前,立即向发送 POST 请求的客户端返回一个 200 状态码,表示请求已成功接收。
服务启动
然后我们就可以使用yarn dev在port为4000的端口中启动一个SSE服务,此时坐等对应的请求到来即可。
3. SSE前端部分(React版本)
既然,SSE后端服务已经有了,那么我们来在前端接入对应的服务。
我们在SSE目录下,使用我们的脚手架在生成一个前端服务。
npx f_cli_f create Client
然后选择自己擅长的技术即可。然后按照对应的提示按照并启动服务即可。如果对我们的脚手架还不了解,可以翻看之前的文章Rust 赋能前端-开发一款属于你的前端脚手架
最后,我们在SSE目录下,就会生成如下的目录信息。
---SSE
---Client(前端项目)
---Server (后端服务)
前端代码逻辑
我们在Client/src/pages/新建一个ChatComponent组件。
UI部分
<div className='flex flex-col justify-center items-center w-full h-full'>
<div className='flex flex-col justify-center items-center flex-1 w-full'>
<Typewriter text={'大家好,我是柒八九。一个专注于前端开发技术/Rust及AI应用知识分享的Coder'} delay={100} infinite={false} />
{messages.map((msg, index) => (
// <p key={index}>{msg}</p>
<Typewriter text={msg} delay={100} infinite={false} key={index}/>
))}
</div>
<Form form={form} className='w-9/12'>
<Form.Item className={styles['message-item']} name="message">
<Input.TextArea
autoSize={{ minRows: 1, maxRows: 3 }}
placeholder="输入内容开始对话(Shift + Enter 换行)"
onPressEnter={handleTextAreaKeyDown}
/>
</Form.Item>
</Form>
</div>
UI部分呢,我们大致分为两部分
- 展示后端返回信息
- TextArea收集用户输入信息
然后,我们在TextArea的PressEnter中执行向后端发送的操作。
注册EventSource
我们在Effect中注册EventSource相关事件。
useEffect(() => {
const eventSource = new EventSource('http://localhost:4000/api/events');
eventSource.onmessage = function (event) {
const data = JSON.parse(event.data);
const { message } = data;
setMessages((prevMessages) => [...prevMessages, message]);
};
return () => {
eventSource.close();
};
}, []);
有几点需要说明
- 我们是在组件初始化的时候,注册EventSource
- 由于我们在上一节中已经在http://localhost:4000中启用了SSE服务,所以在EventSource中传人的是对应的SSE地址
- 在onmessage中我们解析从后端返回的数据,并存入到state-message中。
当数据返回后,对应的state-message发生变化,那也就触发了React的重新渲染。就可以在UI部分看到后端返回的信息。
handleTextAreaKeyDown
这部分是调用指定的后端接口,将用户信息传递给后端服务,用于做指定信息的处理。
const handleTextAreaKeyDown= async (event) => {
const { keyCode, shiftKey } = event;
if (keyCode == 13 && !shiftKey) {
event.preventDefault();
const message = form.getFieldValue('message');
if (message && message.trim().length > 0) {
if (message.trim().length > 0) {
await fetch('http://localhost:4000/api/message', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message }),
});
}
form.setFieldValue('message', '');
}
}
};
在PressEnter事件中,我们还判断event的keyCode和shiftKey来实现在TextArea中换行的操作。也就是只有在单纯的触发Enter才会向后端传递数据。
我们之所以选择用Post来向后端发起情况,因为我们用户输入的信息,不单单是文本信息,也可以是PDF/Word/Text等文本资源。
最终,我们就会得到一个和本文开头的那个效果。
图片
求豆麻袋,好像有一个东西没给大家展示,那就是实现打字效果。别着急,我们这就说。
4. 实现一个打字组件
其实呢,针对一个完整的应用,我们不仅仅需要处理纯文本信息,我们还需要处理类似Table/Code/Img等富文本的展示。
此时,最好的后端数据返回是啥呢,MarkDown。没错,ChatGPT也是这种格式,只不过它在前端显示的时候,用了针对这类信息的展示处理。
而,我们今天的主要方向是讲SSE,而针对其他类型的信息展示不在此篇文章内。如果大家有兴趣了解,后面我们也可以针对此处的内容展开聊聊。
话题扯的有点远了,我们现在进入这节的主题,写一个纯前端的打字效果。
其实呢,针对现成的打字效果有很多。例如
- typed-js[4]
- react-typed[5]
但是呢,本着知识探索的精神,我们今天来实现一个属于自己的打字效果。
在ChatComponent目录下,新建一个Typewriter文件夹。
然后新建三个文件
- index.tsx:只要逻辑
- Cursor.tsx:处理光标逻辑
- index.module.scss:存放样式信息。
下面我们就来看看它们各自的实现逻辑。
index.tsx
import React, { useState, useEffect } from 'react';
import style from './index.module.scss';
import Cursor from './Cursor';
interface TypewriterProps {
text: string | React.ReactNode;
delay: number;
infinite?: boolean;
}
const Typewriter: React.FC<TypewriterProps> = ({ text, delay, infinite }) => {
const [currentText, setCurrentText] = useState<string>('');
const [currentIndex, setCurrentIndex] = useState<number>(0);
useEffect(() => {
let timeout: number;
if (currentIndex < text.length) {
timeout = setTimeout(() => {
setCurrentText((prevText) => prevText + text[currentIndex]);
setCurrentIndex((prevIndex) => prevIndex + 1);
}, delay);
} else if (infinite) {
setCurrentIndex(0);
setCurrentText('');
}
return () => clearTimeout(timeout);
}, [currentIndex, delay, infinite, text]);
return (
<span className={style['text-writer-wrapper']}>
<span dangerouslySetInnerHTML={{ __html: currentText }}></span>
{currentIndex < text.length && <Cursor />}
</span>
);
};
export default Typewriter;
其实呢,上面的逻辑很简单。
Typewriter接收三个参数
- text:要显示的文本,可以是字符串或 React 节点。
- delay:每个字符之间的延迟时间(以毫秒为单位)。
- infinite:是否无限循环显示文本,默认为 false。
使用 useEffect 钩子在每次 currentIndex 改变时运行:
- 如果 currentIndex 小于 text 的长度:
设置一个 setTimeout 以延迟添加下一个字符到 currentText。
递增 currentIndex。
- 否则如果 infinite 为 true:
重置 currentIndex 和 currentText 以开始新的循环。
返回一个清除定时器的函数,以避免内存泄漏。
然后,我们使用dangerouslySetInnerHTML来更新文本信息。
Cursor.tsx
这个组件就更简单了,就是绘制了一个svg,用于在文本输入过程中显示光标。
import style from './index.module.scss';
export default function CursorSVG() {
return (
<svg viewBox="8 4 8 16" xmlns="http://www.w3.org/2000/svg" className={style['cursor']}>
<rect x="10" y="6" width="2" height="100%" fill="black" />
</svg>
);
}
index.module.scss
.text-writer-wrapper{
@keyframes flicker {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.cursor {
display: inline-block;
width: 1ch;
animation: flicker 0.5s infinite;
}
}
这段代码主要用于创建打字机效果中的光标闪烁效果:
- @keyframes flicker 动画定义了光标的闪烁效果,通过改变透明度来实现闪烁。
- .cursor 类应用了闪烁动画,并设置了宽度,使其显示为一个闪烁的光标。
最终效果是在 .text-writer-wrapper 中显示的光标会每 0.5 秒闪烁一次,模拟文本编辑器中的光标效果。