当前位置:   article > 正文

大模型问答助手前端实现打字机效果

前端接收后端返回的流数据实现打字机效果

f9b7c113cf6dc8ac182cfc0bbbb4e979.gif

一、背景

随着现代技术的快速发展,即时交互变得越来越重要。用户不仅希望获取信息,而且希望以更直观和实时的方式体验它。这在聊天应用程序和其他实时通信工具中尤为明显,用户习惯看到对方正在输入的提示。

ChatGPT,作为 OpenAI 的代表性产品之一,不仅为用户提供了强大的自然语言处理能力,而且关注用户的整体交互体验。在使用 ChatGPT 进行交互时,用户可能已经注意到了一个细节:当它产生回复时,回复会像人类逐字输入的方式逐渐出现,而不是一次性显示完整答案。

这种打字效果给人一种仿佛与真人对话的感觉,进一步增强了其自然语言处理的真实感。一开始,许多开发者可能会误以为这是通过 WebSockets 实现的,这是因为 WebSockets 是一种常用于实时通信的技术。然而,仔细研究后,我们发现 ChatGPT 使用了一种不同的技术:基于 EventStream 的方法。更具体地说,它似乎是通过 SSE (Server-Sent Events) 来实现逐个字地推送答案的。

此外,考虑到 ChatGPT 的复杂性和其涉及的大量计算,响应时间可能会长于其他基于数据库的简单查询。因此,采用 SSE 逐步推送结果的方式可以帮助减少用户感到的等待时间,从而增强用户体验。

297e33727a855e4dcc8d967b8dfaa452.gif

二、SSE 简介

Server-Sent Events(通常简称为 SSE)是一种允许服务器向Web页面发送实时更新的技术。与WebSocket技术相比,SSE专门设计用于从服务器到客户端的单向通信。这种单向性使其在某些场景中更为简单和直观。

2.1 主要特点

  • 单向通信:SSE 专为从服务器到客户端的单向通信设计。客户端不能通过SSE直接发送数据到服务器,但可以通过其他方法如AJAX与服务器进行交互。

  • 基于HTTP:SSE 基于 HTTP 协议运行,不需要新的协议或端口。这使得它能够轻松地在现有的Web应用架构中使用,并且通过标准的HTTP代理和中间件进行支持。

  • 自动重连:如果连接断开,浏览器会自动尝试重新连接到服务器。

  • 格式简单:SSE 使用简单的文本格式发送消息,每个消息都以两个连续的换行符分隔。

  • 原生浏览器支持:许多现代浏览器(如 Chrome、Firefox 和 Safari)已原生支持SSE,但需要注意的是,某些浏览器,如Internet Explorer和早期的Edge版本,不支持SSE。

2.2 SSE 与 WebSockets

虽然 SSE 与 WebSockets 在某种程度上有些相似,但它们之间还存在一些关键差异,如下所示:

对比项

Server-Sent Events (SSE)

WebSockets

基于协议

基于 HTTP,简化了连接和交互的过程

通常基于 WS/WSS(基于TCP),更为灵活

通信能力

单向通信:仅服务器向客户端发送消息

双向通信能力

配置

配置简单,易于理解和使用

需要更复杂的配置和理解

断线与消息追踪

自带的断线重连和消息跟踪功能

通常需要手动处理或使用额外库

数据格式

通常为文本,但可以发送经过编码/压缩的二进制消息

支持文本和原始二进制消息

事件处理

支持多种自定义事件

基本消息机制,不能像SSE那样自定义事件类型

连接并发性

连接数可能受到 HTTP 版本的限制,尤其是在HTTP/1.1中

WebSocket被设计为支持更高的连接并发性

安全性

仅支持HTTP和HTTPS的安全机制

支持WS和WSS,可以在WSS上实现更强大的加密

浏览器兼容性

大部分现代浏览器支持,但不是所有浏览器

几乎所有现代浏览器都支持

开销

由于基于HTTP,每次消息可能有较大的头部开销

握手后,消息头部开销相对较小

三、服务端深入解析

3.1 SSE 的协议机制

Server-Sent Events(SSE)是一个基于 HTTP 的协议,允许服务器单向地向浏览器推送信息。为了成功地使用 SSE,服务器和客户端都必须遵循一定的规范和流程。

当客户端(例如浏览器)发出请求订阅 SSE 服务时,服务器需要通过设置特定的响应头部信息来确认该请求。这些头部信息包括:

  • Content-Type: text/event-stream: 这表示返回的内容为事件流。

  • Cache-Control: no-cache: 这确保服务器推送的消息不会被缓存,以保障消息的实时性。

  • Connection: keep-alive: 这指示连接应始终保持开放,以便服务器可以随时发送消息。

3.2 消息的格式和结构

SSE 使用简单的文本格式来组织和发送消息。基本的消息结构是由一系列行组成,每一行由字段名、一个冒号和字段值组成。

以下是消息中可以使用的一些字段及其用途:

  • event: 定义了事件的类型。这可以帮助客户端确定如何处理接收到的消息。

  • id: 提供事件的唯一标识符。如果连接中断,客户端可以使用最后收到的事件 ID 来请求服务器从某个点重新发送消息。

  • retry: 指定了当连接断开时,客户端应等待多少毫秒再尝试重新连接。这为连接中断和重连提供了一种机制。

  • data: 这是消息的主体内容。它可以是任何 UTF-8 编码的文本,而且可以跨多行。每行数据都会在客户端解析时连接起来,中间使用换行符分隔。

为了确保消息的正确和完整传输,服务器通常在消息的末尾添加一个空行,表示消息的结束。

示例:

 
 
  1. id: 123
  2. event: update
  3. data: {"message": "This is a test message"}

此外,SSE 也支持多条连续消息的发送。只要每条消息之间使用两个换行符隔开即可。

四、客户端实践

接入 SSE 并不困难,尤其在客户端这边。主流浏览器提供了 EventSource API,使得与 SSE 服务端建立和维护连接变得异常简单。

4.1 如何建立连接

首先,需要创建一个 EventSource 对象,它将代表与服务器的持久连接。初始化时,可以为它提供一些选项,以满足特定需求。

 
 
  1. const options = {
  2. withCredentials: true // 允许跨域请求携带凭证
  3. };
  4. // 创建一个 EventSource 对象以开始监听
  5. const eventSource = new EventSource('your_server_url', options);

在上面的代码中,withCredentials 参数用于指示是否应该在请求中发送凭证(例如 cookies)。这在跨域场景中可能会非常有用。

4.2 如何处理收到的事件

一旦与服务器建立了连接,就可以开始监听从服务器发送过来的事件。

  • 通用事件处理:

默认情况下,EventSource 对象会对三种基本的事件类型进行响应:open、message 和 error。可以设置对应的处理函数来对它们进行响应。

 
 
  1. // 监听连接打开事件
  2. eventSource.onopen = function(event) {
  3. console.log('Connection to SSE server established!');
  4. };
  5. // 监听标准消息事件
  6. eventSource.onmessage = function(event) {
  7. console.log('Received data from server: ', event.data);
  8. };
  9. // 监听错误事件
  10. eventSource.onerror = function(event) {
  11. console.error('An error occurred while receiving data:', event);
  12. };
  • 自定义事件处理:

除了上述的基本事件外,服务器还可能发送自定义的事件类型。为了处理这些事件,需要使用 addEventListener() 方法。

 
 
  1. // 监听一个名为 "update" 的自定义事件
  2. eventSource.addEventListener('update', function(event) {
  3. console.log('Received update event:', event.data);
  4. });

4.3 关闭连接

如果不再需要从服务器接收事件,可以使用 close 方法关闭连接。

 
 
eventSource.close();

关闭连接后,将不再接收任何事件,除非再次初始化 EventSource 对象。

总结:使用 EventSource API,客户端可以方便地与 SSE 服务器交互,从而实时接收数据更新。这为创建响应迅速的 web 应用提供了极大的便利,同时避免了传统的轮询方式带来的资源浪费。

五、理论实践

5.1 服务端

 
 
  1. const http = require('http');
  2. const fs = require('fs');
  3. // 初始化 HTTP 服务器
  4. http.createServer((req, res) => {
  5. // 为了简洁,将响应方法抽离成函数
  6. function serveFile(filePath, contentType) {
  7. fs.readFile(filePath, (err, data) => {
  8. if (err) {
  9. res.writeHead(500);
  10. res.end('Error loading the file');
  11. } else {
  12. res.writeHead(200, {'Content-Type': contentType});
  13. res.end(data);
  14. }
  15. });
  16. }
  17. function handleSSEConnection() {
  18. res.writeHead(200, {
  19. 'Content-Type': 'text/event-stream',
  20. 'Cache-Control': 'no-cache',
  21. 'Connection': 'keep-alive'
  22. });
  23. let id = 0;
  24. const intervalId = setInterval(() => {
  25. const message = {
  26. event: 'customEvent',
  27. id: id++,
  28. retry: 30000,
  29. data: { id, time: new Date().toISOString() }
  30. };
  31. for (let key in message) {
  32. if (key !== 'data') {
  33. res.write(`${key}: ${message[key]}\n`);
  34. } else {
  35. res.write(`data: ${JSON.stringify(message.data)}\n\n`);
  36. }
  37. }
  38. }, 1000);
  39. req.on('close', () => {
  40. clearInterval(intervalId);
  41. res.end();
  42. });
  43. }
  44. switch (req.url) {
  45. case '/':
  46. serveFile('index.html', 'text/html');
  47. break;
  48. case '/events':
  49. handleSSEConnection();
  50. break;
  51. default:
  52. res.writeHead(404);
  53. res.end();
  54. break;
  55. }
  56. }).listen(3000);
  57. console.log('Server listening on port 3000');

5.2 客户端

 
 
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta http-equiv="X-UA-Compatible" content="IE=edge">
  6. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  7. <title>SSE Demo</title>
  8. </head>
  9. <body>
  10. <h1>SSE Demo</h1>
  11. <button onclick="connectSSE()">建立 SSE 连接</button>
  12. <button onclick="closeSSE()">断开 SSE 连接</button>
  13. <br /><br />
  14. <div id="message"></div>
  15. <script>
  16. const messageElement = document.getElementById('message');
  17. let eventSource;
  18. // 连接 SSE
  19. function connectSSE() {
  20. eventSource = new EventSource('/events');
  21. eventSource.addEventListener('customEvent', handleReceivedMessage);
  22. eventSource.onopen = handleConnectionOpen;
  23. eventSource.onerror = handleConnectionError;
  24. }
  25. // 断开 SSE 连接
  26. function closeSSE() {
  27. eventSource.close();
  28. appendMessage(`SSE 连接关闭,状态${eventSource.readyState}`);
  29. }
  30. // 处理从服务端收到的消息
  31. function handleReceivedMessage(event) {
  32. const data = JSON.parse(event.data);
  33. appendMessage(`${data.id} --- ${data.time}`);
  34. }
  35. // 连接建立成功的处理函数
  36. function handleConnectionOpen() {
  37. appendMessage(`SSE 连接成功,状态${eventSource.readyState}`);
  38. }
  39. // 连接发生错误的处理函数
  40. function handleConnectionError() {
  41. appendMessage(`SSE 连接错误,状态${eventSource.readyState}`);
  42. }
  43. // 将消息添加到页面上
  44. function appendMessage(message) {
  45. messageElement.innerHTML += `${message}<br />`;
  46. }
  47. </script>
  48. </body>
  49. </html>

将上面的两份代码保存为 server.js 和 index.html,并在命令行中执行 node server.js 启动服务端,然后在浏览器中打开 http://localhost:3000 即可看到 SSE 效果。

dfb1abc6289c15b8f5c865fbeb964d59.gif

六、业务实践

6.1 存在问题

在业务真实使用场景中,基于SSE的方法存在一些问题和限制:

  • 默认请求仅支持 GET 方法。当前端需要向后端传递参数时,参数只能拼接在请求的 URL 上,对于复杂的业务场景来说实现较为麻烦。

  • 对于服务端返回的数据格式有固定要求,必须按照 event、id、retry、data 的结构返回。

  • 服务端发送的数据可以在浏览器控制台中查看,这可能会暴露敏感数据,导致数据安全问题。

为了解决以上问题,并使其支持 POST 请求以及自定义的返回数据格式,我们可以使用以下技巧

6.2 优化技巧

利用 Fetch API 的流处理能力,我们可以实现对 SSE 的扩展:

 
 
  1. /**
  2. * Utf8ArrayToStr: 将Uint8Array的数据转为字符串
  3. * @param {Uint8Array} array - Uint8Array数据
  4. * @return {string} - 转换后的字符串
  5. */
  6. function Utf8ArrayToStr(array) {
  7. const decoder = new TextDecoder();
  8. return decoder.decode(array);
  9. }
  10. /**
  11. * fetchStream: 建立一个SSE连接,并支持多种HTTP请求方式
  12. * @param {string} url - 请求的URL地址
  13. * @param {object} params - 请求的参数,包括HTTP方法、头部、主体内容等
  14. * @return {Promise} - 返回一个Promise对象
  15. */
  16. const fetchStream = (url, params) => {
  17. const { onmessage, onclose, ...otherParams } = params;
  18. return fetch(url, otherParams)
  19. .then(response => {
  20. let reader = response.body?.getReader();
  21. return new ReadableStream({
  22. start(controller) {
  23. function push() {
  24. reader?.read().then(({ done, value }) => {
  25. if (done) {
  26. controller.close();
  27. onclose?.();
  28. return;
  29. }
  30. const decodedData = Utf8ArrayToStr(value);
  31. console.log(decodedData);
  32. onmessage?.(decodedData);
  33. controller.enqueue(value);
  34. push();
  35. });
  36. }
  37. push();
  38. }
  39. });
  40. })
  41. .then(stream => {
  42. return new Response(stream, {
  43. headers: { "Content-Type": "text/html" }
  44. }).text();
  45. });
  46. };
  47. // 示例:调用fetchStream函数
  48. fetchStream("/events", {
  49. method: "POST", // 使用POST方法
  50. headers: {
  51. "content-type": "application/json"
  52. },
  53. credentials: "include",
  54. body: JSON.stringify({
  55. // 这里列出了一些示例数据,实际业务场景请替换为你的数据
  56. boxId: "exampleBoxId",
  57. sessionId: "exampleSessionId",
  58. queryContent: "exampleQueryContent"
  59. }),
  60. onmessage: res => {
  61. console.log(res); // 当接收到消息时的回调
  62. },
  63. onclose: () => {
  64. console.log("Connection closed."); // 当连接关闭时的回调
  65. }
  66. });

6.3 封装插件

我们定义一个名为eventStreamHandler.ts的文件

 
 
  1. // 定义请求主体的接口,需要根据具体的应用场景定义具体的属性
  2. interface RequestBody {
  3. // 示例属性,具体属性需要根据实际需求定义
  4. key?: string;
  5. }
  6. // 错误响应的结构
  7. interface ErrorResponse {
  8. error: string;
  9. detail: string;
  10. }
  11. // 返回值类型定义
  12. type TextStream = ReadableStreamDefaultReader<Uint8Array>;
  13. // 获取数据并返回TextStream
  14. async function fetchData(
  15. url: string,
  16. body: RequestBody,
  17. accessToken: string,
  18. onError: (message: string) => void
  19. ): Promise<TextStream | undefined> {
  20. try {
  21. // 尝试发起请求
  22. const response = await fetch(url, {
  23. method: "POST",
  24. cache: "no-cache",
  25. keepalive: true,
  26. headers: {
  27. "Content-Type": "application/json",
  28. Accept: "text/event-stream",
  29. Authorization: `Bearer ${accessToken}`,
  30. },
  31. body: JSON.stringify(body),
  32. });
  33. // 检查是否有冲突,例如重复请求
  34. if (response.status === 409) {
  35. const error: ErrorResponse = await response.json();
  36. onError(error.detail);
  37. return undefined;
  38. }
  39. return response.body?.getReader();
  40. } catch (error) {
  41. onError(`Failed to fetch: ${error.message}`);
  42. return undefined;
  43. }
  44. }
  45. // 读取流数据
  46. async function readStream(reader: TextStream): Promise<string | null> {
  47. const result = await reader.read();
  48. return result.done ? null : new TextDecoder().decode(result.value);
  49. }
  50. // 处理文本流数据
  51. async function processStream(
  52. reader: TextStream,
  53. onStart: () => void,
  54. onText: (text: string) => void,
  55. onError: (error: string) => void,
  56. shouldClose: () => boolean
  57. ): Promise<void> {
  58. try {
  59. // 开始处理数据
  60. onStart();
  61. while (true) {
  62. if (shouldClose()) {
  63. await reader.cancel();
  64. return;
  65. }
  66. const text = await readStream(reader);
  67. if (text === null) break;
  68. onText(text);
  69. }
  70. } catch (error) {
  71. onError(`Processing stream failed: ${error.message}`);
  72. }
  73. }
  74. /**
  75. * 主要的导出函数,用于处理流式文本数据。
  76. *
  77. * @param url 请求的URL。
  78. * @param body 请求主体内容。
  79. * @param accessToken 访问令牌。
  80. * @param onStart 开始处理数据时的回调。
  81. * @param onText 接收到数据时的回调。
  82. * @param onError 错误处理回调。
  83. * @param shouldClose 判断是否需要关闭流的函数。
  84. */
  85. export async function streamText(
  86. url: string,
  87. body: RequestBody,
  88. accessToken: string,
  89. onStart: () => void,
  90. onText: (text: string) => void,
  91. onError: (error: string) => void,
  92. shouldClose: () => boolean
  93. ): Promise<void> {
  94. const reader = await fetchData(url, body, accessToken, onError);
  95. if (!reader) {
  96. console.error("Reader is undefined!");
  97. return;
  98. }
  99. await processStream(reader, onStart, onText, onError, shouldClose);
  100. }

七、兼容性

发展至今,SSE 已具有广泛的的浏览器兼容性,几乎除 IE 之外的浏览器均已支持。

b55cbdeb97e9be49eee92594a15f98ac.jpeg

八、总结

SSE (Server-Sent Events) 是基于 HTTP 协议的轻量级实时通信技术。其核心特点是由服务器主动推送数据到客户端,而不需要客户端频繁请求。这样的特点使得 SSE 在某些应用场景中成为了理想选择,例如股票行情实时更新、网站活动日志推送、或聊天室中的实时在线人数统计。

然而,尽管 SSE 有很多优势,如断线重连机制、相对简单的实现和轻量性等,但它也存在明显的局限性。首先,SSE 只支持单向通信,即服务器到客户端的数据推送,而无法实现真正的双向交互。其次,由于浏览器对并发连接数有限制,当需要大量的实时通信连接时,SSE 可能会受到限制。

相对而言,WebSockets 提供了一个更加强大的双向通信机制,能够满足高并发、高吞吐量和低延迟的需求。因此,在选择适合的实时通信方案时,开发者需要根据应用的具体需求和场景来做出选择。简而言之,对于需要简单、低频率更新的场景,SSE 是一个非常不错的选择;而对于需要复杂、高频、双向交互的应用,WebSockets 可能更为合适。

最后,无论选择哪种技术,都应对其优缺点有深入了解,以确保在特定场景下可以提供最佳的用户体验。

-end-

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/盐析白兔/article/detail/880249
推荐阅读
相关标签
  

闽ICP备14008679号