1. 为什么我们需要一个流式对话组件?
如果你用过像DeepSeek、ChatGPT这样的AI助手,一定会对那种“逐字蹦出来”的打字效果印象深刻。这种效果不仅仅是视觉上的酷炫,它背后是流式传输技术在支撑,能让用户实时看到AI的思考过程,极大地提升了交互的即时感和沉浸感。作为前端开发者,当产品经理拿着DeepSeek的界面跟你说“我们也想要这个效果”时,你可能会想:这得用WebSocket吧?其实,有一个更轻量、更简单的原生方案——EventSource。
EventSource,也叫Server-Sent Events (SSE),是HTML5规范的一部分。它允许服务器主动向客户端推送数据,但和WebSocket的双向通信不同,SSE是单向的,服务器到客户端。对于AI对话这种典型的“一问一答,持续流式输出”的场景,SSE简直是量身定做。它基于HTTP协议,使用简单,自动处理重连,浏览器兼容性也不错。在Vue 3项目中,我们完全可以基于Composition API和原生EventSource,封装一个高可复用的流式对话组件,告别笨重的全量刷新,实现丝滑的渐进式打字效果。
我最近就在一个项目中实践了这个方案,踩过一些坑,也积累了不少心得。比如,直接使用EventSource构造函数只能发起GET请求,而我们的对话接口往往是POST;再比如,原生的onmessage事件触发时,数据已经是一整段了,直接渲染就没有了“打字”的节奏感。所以,我们需要从零开始,自己动手封装。这篇文章,我就带你一步步实现这个组件,从核心原理到工程细节,手把手教你打造一个媲美DeepSeek的对话体验。无论你是Vue新手还是想优化现有项目,相信都能从中获得实用的代码和思路。
2. 核心基石:理解EventSource与流式响应
在动手写代码之前,我们得先搞清楚两件事:EventSource是怎么工作的,以及后端返回的数据流长什么样。这就像盖房子前得看懂图纸和了解建材一样重要。
2.1 EventSource:轻量级的服务器推送
你可以把EventSource想象成一个专门用来接收“新闻直播”的收音机。你打开收音机(建立连接),电台(服务器)就会不断地把最新的新闻片段(数据块)推送过来。它有几个关键特性:
- 基于HTTP/HTTPS:这意味着它不需要像WebSocket那样复杂的握手协议,能轻松穿透大部分防火墙和代理。
- 文本协议:传输的数据是纯文本格式,通常是
data: { ... }\n\n这样的结构,非常易于解析。 - 自动重连:如果连接意外断开,浏览器会自动尝试重新连接。
- 单向通信:客户端只能接收,不能通过这个连接发送数据(发送需求通常通过另一个HTTP请求完成)。
然而,原生EventSource有个众所周知的限制:它只支持GET请求。但在实际项目中,向AI服务发送消息往往需要携带较长的提示词或上下文,用GET请求通过URL传参不仅不方便,还可能遇到URL长度限制。这就是为什么我们需要对EventSource进行封装,使其支持POST等更多HTTP方法。
2.2 数据流格式解析:以DeepSeek为例
我们的组件需要和后端API配合。后端通常会调用类似DeepSeek的流式API,返回的数据格式是标准化的。我们来看一个典型的流式响应片段:
data: {"model": "DeepSeek-R1", "choices": [{"index": 0, "delta": {"content": "你", "role": "assistant"}}], "id": "chatcmpl-xxx", "object": "chat.completion.chunk"}
data: {"model": "DeepSeek-R1", "choices": [{"index": 0, "delta": {"content": "好", "role": "assistant"}}], "id": "chatcmpl-xxx", "object": "chat.completion.chunk"}
data: [DONE]
每一段data:后面都是一个独立的JSON对象,两个换行符(\n\n)标识一个消息块的结束。choices[0].delta.content字段就是本次推送的文本增量。当所有内容发送完毕后,服务器会发送一个特殊的data: [DONE]事件来标识流结束。
关键在于“增量”。我们前端不能简单地把每次收到的content直接替换掉之前的全部内容,那样就会丢失“打字”效果。我们需要一个buffer来累积这些增量,然后以一种可控的速度(比如每40毫秒几个字符)将buffer中的内容“释放”到页面上进行渲染。同时,流中可能还包含一些特殊标记,比如DeepSeek的思考过程会用<think>标签包裹,我们需要识别并特殊处理这些内容,实现类似DeepSeek的“思考内容折叠”功能。
3. 从零封装:支持POST请求的EventSource
既然原生的EventSource搞不定POST,我们就得自己造个轮子。别担心,这个轮子不复杂,核心是利用XMLHttpRequest来模拟EventSource的行为。我在项目中参考并改造了一个开源实现,让它更贴合我们的需求。
3.1 封装一个SSE类
我们创建一个sse.js文件,里面是一个SSE类。它模拟了原生EventSource的API,但底层使用XMLHttpRequest,因此可以自由设置请求方法、头部和载荷。
// sse.js
class CustomSSE {
constructor(url, options = {}) {
this.url = url;
this.method = options.method || 'GET';
this.headers = options.headers || {};
this.payload = options.payload;
this.withCredentials = !!options.withCredentials;
this.debug = !!options.debug;
this.listeners = {};
this.xhr = null;
this.readyState = CustomSSE.CONNECTING; // 连接状态
this.progress = 0; // 记录已处理的响应文本位置
this.chunk = ''; // 用于暂存不完整的消息块
// 初始化后立即开始连接
if (options.start !== false) {
this.stream();
}
}
// 事件监听机制,保持和原生EventSource一致
addEventListener(type, listener) {
if (!this.listeners[type]) {
this.listeners[type] = [];
}
this.listeners[type].push(listener);
}
removeEventListener(type, listener) {
// ... 实现移除逻辑
}
// 核心:建立连接并处理流式响应
stream() {
this.xhr = new XMLHttpRequest();
this.xhr.open(this.method, this.url);
// 设置请求头,特别是对于流式响应很重要
for (const [key, value] of Object.entries(this.headers)) {
this.xhr.setRequestHeader(key, value);
}
// 告诉服务器我们期望的是事件流
this.xhr.setRequestHeader('Accept', 'text/event-stream');
th


2万+

被折叠的 条评论
为什么被折叠?



