Vue 3 + EventSource 实战:从零封装流式对话组件,实现类DeepSeek的渐进式打字效果

AI 时代程序员必备技能

Codex、Claude Code、Cursor、Hermes Agent、OpenClaw等工程化实战专栏 ,讲透 AI 如何接管脏活累活

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想象成一个专门用来接收“新闻直播”的收音机。你打开收音机(建立连接),电台(服务器)就会不断地把最新的新闻片段(数据块)推送过来。它有几个关键特性:

  1. 基于HTTP/HTTPS:这意味着它不需要像WebSocket那样复杂的握手协议,能轻松穿透大部分防火墙和代理。
  2. 文本协议:传输的数据是纯文本格式,通常是data: { ... }\n\n这样的结构,非常易于解析。
  3. 自动重连:如果连接意外断开,浏览器会自动尝试重新连接。
  4. 单向通信:客户端只能接收,不能通过这个连接发送数据(发送需求通常通过另一个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

AI 时代程序员必备技能

Codex、Claude Code、Cursor、Hermes Agent、OpenClaw等工程化实战专栏 ,讲透 AI 如何接管脏活累活

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值