每个程序员的 AI 课 你知道 ChatGPT Style 应用中 一个字一个字回复这种效果怎么实现的吗

现在 ChatGPT Style 的 App 非常热,在 AI 回复消息时通常都是用这种"打字机"效果像上图这样,那这样的效果是怎么实现的呢?本文带你一探究竟。

实现这种效果至少有3种方式:

  • 轮询接口
  • Webscoket
  • SSE

传统的 Ajax 轮询低效且消耗资源更多,先被我们排除掉,接下来看看 SSE 和 Webscoket。Websocket 大家不陌生,那 SSE 是什么呢? SSE是一种基于 HTTP 的服务器端推送技术,与传统的 Ajax 轮询相比,SSE更加高效,因为它只在有新数据时才会向客户端发送数据。想象一下,你正在玩一款在线游戏,想要得到实时的游戏状态。但是,如果每秒钟都要向服务器发起请求,那么你就不得不承受一堆烦人的加载时间和浪费的带宽。这时候,SSE就可以帮到你了,让你的体验更加流畅和舒适。

听起来 SSE 是不是很像 Websocket? 但他们有一些区别,SSE 可以让服务器向客户端推送数据,而无需客户端发起请求。这就像是你在等待一封信,只有发件人可以给你写信,而你却无法回信,即 SSE 是从服务端发送更新的单向通信。

SSE 通信是如何建立起来的呢?

使用 SSE,客户端通过发送 GET 请求与服务器建立持久连接,然后服务器通过该连接向客户端发送数据。在发送数据时,服务器需要将需要推送的数据封装在消息中,并使用特殊的 MIME 类型"text/event-stream"进行标识,以便客户端能够识别。客户端在接收到该响应后,会自动将其解析成一个事件流,并将事件流传递给 JavaScript 应用程序进行处理。

也就是说在收到浏览器的 GET 请求后,服务端只要设置响应头为 Content-Type: text/event-stream 即可开启 SSE 通信协议, 并且这个协议还有一个非常重要的特性:支持自动重连和心跳机制,确保连接的可靠性。如果连接断开,客户端会自动发起重连,而服务器可以通过发送一个空的消息,来保持连接的活跃状态。

开启 SSE Node.js 示例代码, 截选自 sse.js

js 复制代码
SSEClient.prototype.initialize = function() {
  ...
  this.res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache, no-transform',
    'Connection': 'keep-alive'
  });
  this.res.write(':ok\n\n');
};

这意味着 SSE 是一种高效、可靠、易于实现(心智负担低)的实时数据推送协议,给开发者的业务场景开发多了一种技术选择方案。

实现 SSE

服务端部分,以Node.js 为例

js 复制代码
const http = require('http');
const { EventEmitter } = require('events');

const emitter = new EventEmitter();

http.createServer((req, res) => {
  if (req.url === '/sse') {
    console.log('new client connected');
    res.writeHead(200, {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    });

    const listener = (data) => {
      res.write(`data: ${JSON.stringify(data)}\\n\\n`);
    };

    // Listen for events
    emitter.on('event', listener);

    // Remove listener on client disconnect
    req.on('close', () => {
      console.log('client disconnected');
      emitter.off('event', listener);
    });
  }
}).listen(8080, () => {
  console.log('SSE server listening on port 8080');
});

// Emit events
setInterval(() => {
  emitter.emit('event', { message: 'Hello, world!' });
}, 1000);

简单解释一下代码,先通过 http 起一个 Http server 监听 /sse 路由,当收到客户端的请求 localhost:8080/sse 时建立一个 SSE 连接,通过设置响应头,建立了连接后,如果服务端有更新,就通过 events 库推送给前端即可。

在客户端部分, 我们什么都不需要做,浏览器已经帮我们实现了,并且还兼容性良好。

前端只要调用浏览器同的 EventSource API 与服务端建立连接,然后监听服务端的更新并且将消息追加到页面上即可

html 复制代码
<!DOCTYPE html>
<html>
<head>
  <title>SSE Chat Example</title>
</head>
<body>
  <ul id="chat"></ul>
  <script>
    const chat = document.getElementById('chat');
    const source = new EventSource('/sse');

    source.addEventListener('ping', function(event) {
      const data = JSON.parse(event.data);
      const message = data.message;
      const li = document.createElement('li');
      li.textContent = message;
      chat.appendChild(li);
    });
  </script>
</body>
</html>

缺了点什么?

我们虽然可以通过浏览器的 EventSource API 来实现这样的打字效果。但是这个解决方案有一些限制。

从前文的介绍中我们可以看到第一个限制,它必须要求我们使用 GET 请求。而且当想给服务端传递一些信息时无法通过Body 传递,只能将内容写到 URL 中(还必须设置 withCredentials),那就会受到浏览器对 URL 长度的限制。比如大多数浏览器输入的 URL 最多 2000 个字符。当然也无法传递一些自定义请求头。前面我们虽然提到了断线重连,但是他的重连策略很弱,重连几次无果后就停止了,在生产环境还是很危险的。

那针对这些问题怎么办呢? 不使用 EventSource 呗,所以类似 ChatGPT 这样的应用都会封装一个 基于 fetch API 的 SSE 版本。怎么封装呢? 没有魔法,后端不需要做更改,前端通过 fetch 包装一层 SSE 的事件就可以把缺的这部分补回来,成为一个更好的 SSE 解决方案,并且在使用方式上没什么变化

js 复制代码
// BEFORE:
const sse = new EventSource('/api/sse');
sse.onmessage = (ev) => {
    console.log(ev.data);
};

// AFTER:
import { fetchEventSource } from 'your-fetch-event-source';

await fetchEventSource('/api/sse', {
    onmessage(ev) {
        console.log(ev.data);
    }
});
  • 解决请求限制,就是普通 fetch 请求,可以接收各种类型请求和请求头
js 复制代码
fetchEventSource('/api/sse', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
    },
    body: JSON.stringify({
        foo: 'bar'
    }),
});
  • 对监听到的数据有了更细粒度的控制,之前通过浏览器API拿到的直接就是后端更新的内容,现在拿到的是reponse对象
js 复制代码
fetchEventSource('/api/sse', {
   async onopen(response) {
        if (response.ok && response.headers.get('content-type') === EventStreamContentType) {
            return; // everything's good
        } else if (response.status >= 400 && response.status < 500 && response.status !== 429) {
            // client-side errors are usually non-retriable:  
        }
    },
  
  • 连接中断时可以自己定义出错的逻辑
js 复制代码
class RetriableError extends Error { }
class FatalError extends Error { }

fetchEventSource('/api/sse', {
    ...
    onclose() {
        // if the server closes the connection unexpectedly, retry:
        throw new RetriableError();
    },
    onerror(err) {
        if (err instanceof FatalError) {
            throw err; // rethrow to stop the operation
        } else {
            // do nothing to automatically retry. You can also
            // return a specific retry interval here.
        }
    }
});

代码来自微软封装的@microsoft/fetch-event-source,好奇宝宝可以自行阅读https://github.com/Azure/fetch-event-source/blob/main/src/fetch.ts, 本篇分享结束,都看到这里了点个赞吧!

相关推荐
阿伟来咯~12 分钟前
记录学习react的一些内容
javascript·学习·react.js
吕彬-前端17 分钟前
使用vite+react+ts+Ant Design开发后台管理项目(五)
前端·javascript·react.js
学前端的小朱20 分钟前
Redux的简介及其在React中的应用
前端·javascript·react.js·redux·store
许野平27 分钟前
Rust: 利用 chrono 库实现日期和字符串互相转换
开发语言·后端·rust·字符串·转换·日期·chrono
guai_guai_guai29 分钟前
uniapp
前端·javascript·vue.js·uni-app
也无晴也无风雨30 分钟前
在JS中, 0 == [0] 吗
开发语言·javascript
bysking1 小时前
【前端-组件】定义行分组的表格表单实现-bysking
前端·react.js
王哲晓2 小时前
第三十章 章节练习商品列表组件封装
前端·javascript·vue.js
fg_4112 小时前
无网络安装ionic和运行
前端·npm
理想不理想v2 小时前
‌Vue 3相比Vue 2的主要改进‌?
前端·javascript·vue.js·面试