从零构建类ChatGPT流式对话:基于@microsoft/fetch-event-source的现代前端实践
如果你正在开发一个AI对话应用,并且希望它拥有像ChatGPT那样流畅、逐字输出的体验,那么你很可能已经遇到了一个技术瓶颈:浏览器原生的EventSource API功能太弱了。它只支持GET请求,不能自定义请求头,更别提传递复杂的JSON数据体了。这就像给你一辆只能前进不能后退的汽车,在复杂的应用场景下寸步难行。
我在去年重构一个企业级AI助手项目时就遇到了这个问题。后端同事已经实现了完善的流式响应接口,但前端用EventSource调用时,因为无法传递上下文对话历史,导致AI每次回答都像失忆了一样。直到我发现了微软开源的@microsoft/fetch-event-source库,才真正解决了这个痛点。今天我就来分享如何用这个库,结合Vue 3的Composition API,打造一个完整的、生产可用的类ChatGPT对话界面。
1. 为什么选择fetch-event-source:超越原生EventSource的四大优势
在深入代码之前,我们先搞清楚这个库到底解决了什么问题。很多人以为SSE(Server-Sent Events)技术已经很成熟了,直接用EventSource不就行了吗?实际上,原生API有几个致命的限制:
1.1 请求方法的单一性 原生EventSource只支持GET方法。这意味着所有数据都必须通过URL参数传递。想象一下,你要发送一个包含10轮对话历史的上下文,每轮对话几百字,URL长度很快就会超过浏览器的限制(通常2000字符左右)。而fetch-event-source支持任何HTTP方法,包括POST、PUT等。
// 原生EventSource - 只能GET
const sse = new EventSource('/api/chat?context=很长很长的参数...');
// fetch-event-source - 支持POST和请求体
await fetchEventSource('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [...完整的对话历史] })
});
1.2 自定义请求头的缺失 现代Web应用几乎都需要身份验证,通常通过Authorization头传递token。原生EventSource不支持任何自定义请求头,这意味着你无法在SSE连接中携带认证信息。
1.3 重试策略的不可控 当连接断开时,浏览器会自动重试几次,然后默默放弃。你无法知道重试了多少次,也无法自定义重试逻辑。在生产环境中,这种黑盒行为是不可接受的。
1.4 响应处理的局限性 你无法在建立连接前对响应进行预处理。比如,如果网关返回了401错误,你希望先刷新token再重试,原生API做不到这一点。
@microsoft/fetch-event-source完美解决了这些问题,它基于现代的Fetch API构建,提供了完全兼容SSE协议但功能更强大的接口。
2. 环境搭建与基础配置
让我们从最基础的开始。假设你正在使用Vue 3 + TypeScript + Vite构建项目。
2.1 安装依赖 首先安装必要的包:
npm install @microsoft/fetch-event-source
# 如果你使用TypeScript,类型定义已经包含在包中
2.2 创建基础工具函数 我习惯先创建一个专门处理SSE连接的工具函数,这样可以在多个组件中复用。创建一个src/utils/sse.ts文件:
import { fetchEventSource, EventSourceMessage } from '@microsoft/fetch-event-source';
// 自定义错误类型,用于区分可重试和不可重试的错误
export class RetriableError extends Error {
constructor(message?: string) {
super(message);
this.name = 'RetriableError';
}
}
export class FatalError extends Error {
constructor(message?: string) {
super(message);
this.name = 'FatalError';
}
}
// SSE内容类型常量
export const EventStreamContentType = 'text/event-stream';
// 基础配置接口
export interface SSEClientOptions {
url: string;
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
headers?: Record<string, string>;
body?: any;
onOpen?: (response: Response) => Promise<void> | void;
onMessage: (msg: EventSourceMessage) => void;
onClose?: () => void;
onError?: (err: any) => void;
signal?: AbortSignal;
openWhenHidden?: boolean;
fetch?: typeof fetch;
}
/**
* 创建SSE连接的通用函数
*/
export async function createSSEConnection(options: SSEClientOptions): Promise<() => void> {
const {
url,
method = 'POST',
headers = {},
body,
onOpen,
onMessage,
onClose,
onError,
signal,
openWhenHidden = true,
fetch: customFetch
} = options;
// 准备请求头
const requestHeaders = {
'Content-Type': 'application/json',
...headers
};
// 创建AbortController用于手动取消
const abortController = signal ? undefined : new AbortController();
const actualSignal = signal || abortController?.signal;
try {
await fetchEventSource(url, {
method,
headers: requestHeaders,
body: body ? JSON.stringify(body) : undefined,
signal: actualSignal,
openWhenHidden,
fetch: customFetch,
async onopen(response) {
console.log('SSE连接已建立', response);
// 自定义连接验证逻辑
if (onOpen) {
await onOpen(response);
return;
}
// 默认验证逻辑
if (response.ok && response.headers.get('content-type') === EventStreamContentType) {
return; // 一切正常
} else if (response.status >= 400 && response.status < 500 && response.status !== 429) {
// 客户端错误,通常是不可重试的
throw new FatalError(`客户端错误: ${response.status}`);
} else {
// 服务器错误或网络问题,可以重试
throw new RetriableError(`服务器错误: ${response.status}`);
}
},
onmessage(msg) {
// 这里可以添加全局的消息处理逻辑
// 比如检查特定的错误事件
if (msg.event === 'error') {
throw new FatalError(`服务器返回错误: ${msg.data}`);
}
// 调用用户定义的消息处理器
onMessage(msg);
},
onclose() {
console.log('SSE连接已关闭');
if (onClose) {
onClose();
}
// 如果连接意外关闭,可以触发重试
// throw new RetriableError('连接意外关闭');
},
onerror(err) {
console.error('SSE连接错误:', err);
if (onError) {
onError(err);
return;
}
// 默认错误处理逻辑
if (err instanceof FatalError) {
throw err; // 抛出致命错误,停止重试
}
// 对于可重试错误,不抛出异常,库会自动重试
// 可以返回一个重试间隔(毫秒)
// return 5000; // 5秒后重试
}
});
} catch (error) {
console.error('SSE连接异常:', error);
throw error;
}
// 返回一个取消函数
return () => {
abortController?.abort();
};
}
这个工具函数提供了完整的错误处理、类型安全和可配置性。在实际项目中,我发现将错误分为可重试和不可重试两类非常重要,这能显著提升用户体验。
3. 构建完整的ChatGPT式对话组件
现在我们来创建一个完整的对话组件。我将使用Vue 3的<script setup>语法,但思路同样适用于React或其他框架。
3.1 组件基础结构 创建src/components/ChatInterface.vue:
<template>
<div class="chat-container">
<!-- 消息列表区域 -->
<div ref="messagesContainer" class="messages-container">
<div v-for="message in messages" :key="message.id"
:class="['message', message.role]">
<div class="avatar">
{
{ message.role === 'user' ? '👤' : '🤖' }}
</div>
<div class="content">
<!-- 对于AI的流式响应,使用单独的渲染 -->
<template v-if="message.role === 'assistant' && message.isStreaming">
<div class="streaming-text">
{
{ message.content }}
<span class="cursor">▋</span>
</div>
</template>
<template v-else>
{
{ message.content }}
</template>
<div class="timestamp">
{
{ formatTime(message.timestamp) }}
</div>
</div>
</div>
<!-- 加载指示器 -->
<div v-if="isLoading" class="thinking-indicator">
<div class="dot-flashing"></div>
<span>AI正在思考...</span>
</div>
</div>
<!-- 输入区域 -->
<div class="input-area">
<textarea
v-model="inputText"
placeholder="输入您的问题..."
:disabled="isLoading"
@keydown.enter.exact.prevent="handleSubmit"
rows="3"
></textarea>
<button
@click="handleSubmit"
:disabled="isLoading || !inputText.trim()"
class="send-button"
>
{
{ isLoading ? '发送中...' : '发送' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">

&spm=1001.2101.3001.5002&articleId=154370034&d=1&t=3&u=99942658b0a44ea6ac38a1b9ee4e7c74)
483

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



