老粉丝都知道,我们有一个文档问答的AI产品,然后有一个前端要求就是模仿ChatGPT展示后端返回的数据信息(打字效果)。刚开始呢,由于问答比较简单,只是一些简单的文本类型,并且后端返回的结果也有限,加上工期比较紧(反正就是各种原因),我们选择了最原始的前后端数据交互方法。
前端发送问题,后端接入模型分析数据,然后将最后的结果一股脑的返回给前端。就这样岁月静好的度过了一段时间,但是由于需求的变更。后端返回的信息又臭又长,然后还是沿用之前的数据获取和展示方式,就显得捉襟见肘了。
所以,此时我们就从我们知识百宝箱中搜索,然后一眼就相中SSE。之前在写一个类ChatGPT应用,前后端数据交互有哪几种文章中,我们就对其有过简单的介绍。
今天我们就来聊聊,如何实现基于SSE的前后端项目。(我们讲主要逻辑,有些细节例如样式等就不那么考究了)
最终,我们就会得到一个类似下面的程序。
图片
好了,天不早了,干点正事哇。
[服务器发送事件]((https://developer.mozilla.org/zh-CN/docs/Web/API/Server-sent_events "服务器发送事件"))(Server-Sent Events,SSE)提供了一种标准方法,通过 HTTP 将服务器数据推送到客户端。与 WebSockets 不同,SSE 专门设计用于服务器到客户端的单向通信,使其非常适用于实时信息的更新或者那些在不向服务器发送数据的情况下实时更新客户端的情况。
服务器发送事件 (SSE) 允许服务器在任何时候向浏览器推送数据:
❝
本质上,SSE 是一个无尽的数据流。可以将其视为下载一个无限大的文件,以小块形式拦截和读取。(类比我们之前讲过的大文件分片上传和分片下载)
SSE 首次实现于 2006 年,所有主要浏览器都支持这个标准。它可能不如 WebSockets[1] 知名,但SSE更简单,使用标准 HTTP,支持单向通信,并提供自动重新连接功能。
我们可以将服务器发送事件视为单个 HTTP 请求,其中后端不会立即发送整个主体,而是保持连接打开,并通过每次发送事件时发送单个行来逐步传输答复。
图片
SSE是一个由两个组件组成的标准:
作为核心的组件,EventSource的兼容性良好。
图片
服务器需要设置 HTTP 头部 Content-Type: text/event-stream 并保持连接不断开,以持续发送事件。典型的服务器发送事件的格式如下:
data: 这是一个事件消息data: 这是另一个事件消息
可以包含多个字段:
id: 1234event: customEventdata: {"message": "这是一个自定义事件"}retry: 10000
客户端使用 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: Reactdata: React is great!event: Rustdata: { "Rust": "我很喜欢", }event: AIdata: { "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: Reactdata: 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: 60000data: 你很好,这段时间我们还是别联系了!
收到后,浏览器会断开 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); }});
无需重新连接:它会自动进行。
浏览器可以使用 EventSource 对象的 .close() 方法终止 SSE 通信。例如:
const source = new EventSource('/sse1');// 一小时后关闭setTimeout(() => source.close(), 3600000);
服务器可以通过以下方式终止连接:
只有浏览器可以通过创建一个新的 EventSource 对象重新建立连接。
优点 | 描述 |
简单性 | 比 WebSocket 更简单的 API 设计 |
自动管理重连 | 内置的重连机制使开发更简便 |
浏览器支持 | 现代浏览器普遍支持 EventSource |
缺点 | 描述 |
单向通信 | 无法从客户端向服务器发送数据 |
基于 HTTP | 相比 WebSocket,SSE 在处理高频率数据传输时性能可能较低 |
受限于同源策略 | 跨域通信需要额外配置 CORS(跨域资源共享) |
在讲代码前,我们来简单说一下我们要实现的交互
如果想了解一个事物的全貌,那就是接近它,了解它,实现它。
那么,我们就来自己用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中。
图片
让我们来挑几个重要的点来解释一下:
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的代码。不做过多解释。我们是用了两个中间件
// 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()部分。
当我们每次接收到/api/events时,没有立马向请求方返回数据,而是构建一个newClient,并且将其push到一个全局变量clients中。
当客户端关闭连接时,从列表中移除相应的客户端,我们在close中执行对应的移除操作。
// 处理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请求的方法。有几点需要额外注意一下
然后我们就可以使用yarn dev在port为4000的端口中启动一个SSE服务,此时坐等对应的请求到来即可。
既然,SSE后端服务已经有了,那么我们来在前端接入对应的服务。
我们在SSE目录下,使用我们的脚手架在生成一个前端服务。
npx f_cli_f create Client
然后选择自己擅长的技术即可。然后按照对应的提示按照并启动服务即可。如果对我们的脚手架还不了解,可以翻看之前的文章Rust 赋能前端-开发一款属于你的前端脚手架
最后,我们在SSE目录下,就会生成如下的目录信息。
---SSE ---Client(前端项目) ---Server (后端服务)
我们在Client/src/pages/新建一个ChatComponent组件。
<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的PressEnter中执行向后端发送的操作。
我们在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(); };}, []);
有几点需要说明
当数据返回后,对应的state-message发生变化,那也就触发了React的重新渲染。就可以在UI部分看到后端返回的信息。
这部分是调用指定的后端接口,将用户信息传递给后端服务,用于做指定信息的处理。
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等文本资源。
最终,我们就会得到一个和本文开头的那个效果。
图片
求豆麻袋,好像有一个东西没给大家展示,那就是实现打字效果。别着急,我们这就说。
其实呢,针对一个完整的应用,我们不仅仅需要处理纯文本信息,我们还需要处理类似Table/Code/Img等富文本的展示。
此时,最好的后端数据返回是啥呢,MarkDown。没错,ChatGPT也是这种格式,只不过它在前端显示的时候,用了针对这类信息的展示处理。
而,我们今天的主要方向是讲SSE,而针对其他类型的信息展示不在此篇文章内。如果大家有兴趣了解,后面我们也可以针对此处的内容展开聊聊。
话题扯的有点远了,我们现在进入这节的主题,写一个纯前端的打字效果。
其实呢,针对现成的打字效果有很多。例如
但是呢,本着知识探索的精神,我们今天来实现一个属于自己的打字效果。
在ChatComponent目录下,新建一个Typewriter文件夹。
然后新建三个文件
下面我们就来看看它们各自的实现逻辑。
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接收三个参数
使用 useEffect 钩子在每次 currentIndex 改变时运行:
设置一个 setTimeout 以延迟添加下一个字符到 currentText。
递增 currentIndex。
重置 currentIndex 和 currentText 以开始新的循环。
返回一个清除定时器的函数,以避免内存泄漏。
然后,我们使用dangerouslySetInnerHTML来更新文本信息。
这个组件就更简单了,就是绘制了一个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> );}
.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; }}
这段代码主要用于创建打字机效果中的光标闪烁效果:
最终效果是在 .text-writer-wrapper 中显示的光标会每 0.5 秒闪烁一次,模拟文本编辑器中的光标效果。
本文链接:http://www.28at.com/showinfo-26-99894-0.htmlSSE打扮你的AI应用,让它美美哒!
声明:本网页内容旨在传播知识,若有侵权等问题请及时与本网联系,我们将在第一时间删除处理。邮件:2376512515@qq.com