useSse 开源:如何把流式数据请求/处理简化到极致

ai-hooks 开源地址:github.com/Gijela/ai-h...

你是否也曾想在项目中轻松实现类似 ChatGPT 的流式对话界面,却被繁琐的状态管理、消息拼接、错误处理和生命周期控制所困扰?上期我们精读了 fetch-event-source 库的源码,其虽为我们提供了坚实的底层能力,但直接在业务组件中使用它,就如同给了你一台强大的引擎,却还需要自己动手组装车身、方向盘和仪表盘。

useSse hook 正是为此而生。它是一把精心打造的"瑞士军刀",专门用于在 Vue3/React 环境下,优雅地处理服务器推送事件 (SSE)。它不仅封装了 fetch-event-source 的所有功能,更在 Vue/React 的响应式系统之上,构建了一套完整的、专为对话式 AI 应用设计的状态管理和交互模型。

本文将以 Vue3 为例,介绍 useSse,你将深入理解:

  • 为何需要 useSse :了解它如何从 fetch-event-source 的底层能力中升华,解决在 Vue 应用中的实际痛点。
  • "响应式状态机"的设计 :剖析 useSse 如何巧妙地利用 ref 将连接状态 (connecting, streaming, completed, error 等) 与 UI 渲染无缝对接。
  • 消息流的生命周期管理 :学习 append, stop 等核心方法如何精巧地控制从用户发送消息到接收、渲染、完成或中断的整个流程。
  • 高阶抽象的威力 :通过对比原生 fetch-event-source 的用法,体会 useSse 这个高阶 Hook 如何大幅简化代码、提升开发效率和可维护性。
  • 最佳实践范例:获得一个即插即用的实战指南,快速在你的项目中集成 AI 对话能力。

准备好用一种更优雅的方式来驾驭 SSE 了吗?让我们开始吧!

缘起:直接用 fetch-event-source 不够香吗?

fetch-event-source 是一个出色的库,它解决了原生 EventSource 的诸多限制。然而,在构建一个功能完备的 Vue 对话界面时,我们面临的挑战远不止建立一个 SSE 连接那么简单:

  1. 状态管理之痛 :加载状态 (loading)、错误信息 (error)、消息列表 (messages),这些都需要在 Vue 的响应式系统中进行管理,并与组件的生命周期同步。
  2. 消息格式化之繁 :从服务器收到的原始数据块 (data chunk) 需要被解析、拼接,并转换成结构化的消息对象,才能被 UI 正确渲染。
  3. 用户交互之杂:需要处理用户输入、发送消息、中断流式响应、重试等多种交互逻辑。
  4. 生命周期之忧:当组件被卸载时,必须确保 SSE 连接被可靠地关闭,以防止内存泄漏和不必要的后台请求。

直接在组件中处理这些逻辑,会导致代码臃肿、状态分散、难以维护。useSse 的核心使命,就是将这些通用的、复杂的逻辑封装起来,提供一个干净、声明式且高度可复用的接口。

核心思想:一个 Hook,承包整个 AI 对话界面的状态管理

useSse 的设计哲学非常清晰:它不仅仅是一个网络请求的封装,而是一个完整的、面向对话场景的状态管理器。

它将一个典型的 AI 对话流程抽象为以下几个核心要素:

  • messages: 一个响应式的消息数组,是整个对话历史的"唯一事实来源"。
  • input: 一个响应式的字符串,用于双向绑定输入框。
  • loading: 一个响应式的布尔值,用于控制加载状态的 UI(如禁用发送按钮、显示加载动画)。
  • error: 一个响应式的错误对象,用于展示连接或解析过程中的异常。
  • append / stop / handleSubmit: 一系列精心设计的方法,用于驱动状态的流转和处理用户交互。

通过将这些状态和方法聚合在一个独立的 Hook 中,我们将复杂的业务逻辑从视图组件中剥离,使得组件只需关注如何渲染这些状态,从而实现了"逻辑"与"视图"的完美分离。

架构之美:响应式状态机与生命周期管理

useSse 的内部实现,可以看作一个围绕 SSE 连接生命周期构建的"响应式状态机"。其状态转换的核心,体现在 AssistantSseMessagestatus 字段上。

status (定义于 SseStatusEnum) 是驱动 UI 动态变化的关键。它精确地描述了助理消息从诞生到终结的每一个阶段:

  • idle: 初始状态。
  • connecting: 连接已发起,等待服务器响应。
  • streaming-content: 正在接收并渲染内容。
  • completed: 消息流正常结束。
  • aborted: 被用户手动中断。
  • error: 发生错误。

useSse 内部的 _sseFetch 函数通过监听 fetch-event-sourceonopen, onmessage, onclose, onerror 等事件,在关键节点更新最后一条助理消息的 status,从而自动触发 Vue 组件的重新渲染。

此外,通过 onUnmounted 生命周期钩子,useSse 确保了当使用它的组件被销毁时,会自动调用 stop() 方法来中止任何正在进行的连接,有效防止了资源泄漏。

API 详解

useSse 的 API 设计简洁而强大,分为"配置选项"和"返回值"两部分。

useSse(options: UseSseOptions)

初始化 Hook 时,你需要传入一个配置对象。

参数 类型 必需 描述
initialInput string 输入框的初始值。
initialMessages SseMessage[] 初始的消息列表。
formatMessage (data, message) => AssistantSseMessage 核心函数 。用于将 SSE onmessage 事件中的原始 data 字符串,解析并合并到上一条助理消息中,返回更新后的消息对象。
onOpen (response: Response) => void 连接成功建立时的回调,可用于检查响应头。
onOpenJson (json: any) => void 当服务器返回非流式 JSON 响应(通常是错误信息)时的回调。
onMessage (data: string) => void 每次收到原始 data 字符串时的回调。
onFinish (message: SseMessage) => void 消息流正常结束 ([DONE]) 时的回调。
onClose () => void 连接关闭时的回调(无论正常或异常)。
onError (error: any) => void 发生任何错误时的回调。

返回值 (Returned Values)

调用 useSse 会返回一个包含响应式状态和方法的对象。

名称 类型 描述
messages Ref<SseMessage[]> 响应式的消息数组,可以直接在模板中 v-for 渲染。
error Ref<any> 响应式的错误对象。
loading Ref<boolean> 响应式的加载状态。
input Ref<string> 响应式的输入框内容,可使用 v-model 进行绑定。
append (prompt, options) => Promise<void> 核心方法。追加一条用户消息和一条空的助理消息,并立即开始 SSE 请求。
stop () => void 核心方法。手动中止当前的 SSE 连接。
setMessages (messages: SseMessage[]) => void 手动设置消息列表。
handleInputChange (e: Event) => void 用于原生 <input>onchange 事件处理器,与 input Ref 配合使用。
handleSubmit (e: Event, options) => void 便捷的表单提交处理器。它会阻止默认事件,调用 append,并清空输入框。

快速上手:构建一个简单的 AI 聊天机器人

下面是一个在 Vue 3 <script setup> 组件中使用 useSse 的完整示例。

vue 复制代码
<script setup lang="ts">
import { useSse } from '@/hooks/useSse'
import type { AssistantSseMessage } from '@/hooks/useSse/types'

// 1. 初始化 useSse Hook
const { messages, input, loading, handleSubmit, stop, handleInputChange } = useSse({
  // 2. 提供核心的消息格式化逻辑
  formatMessage: (data, lastMessage) => {
    try {
      // 假设服务器返回的是 JSON 字符串
      const jsonData = JSON.parse(data)

      // 将新的数据块追加到上一条消息的内容上
      const updatedContent = lastMessage.content + (jsonData.content || '')

      return {
        ...lastMessage,
        content: updatedContent,
        status: 'streaming-content', // 更新状态
      }
    } catch (e) {
      console.error('JSON parse error:', e)
      return {
        ...lastMessage,
        status: 'error',
      }
    }
  },
  // 可选:添加其他回调
  onError: (err) => {
    console.error('SSE Error:', err)
  },
})

// 3. 定义提交时需要传递给后端的参数
const getSseOptions = () => ({
  url: '/api/v1/chat/completions',
  method: 'POST',
  body: {
    // 假设你的 API 需要历史消息
    messages: messages.value.map(({ role, content }) => ({ role, content })),
    stream: true,
  },
})
</script>

<template>
  <div class="chat-container">
    <div class="message-list">
      <div v-for="msg in messages" :key="msg.id" class="message" :class="`message--${msg.role}`">
        <p>{{ msg.content }}</p>
        <!-- 根据 status 显示不同的 UI -->
        <div
          v-if="
            msg.role === 'assistant' && (msg as AssistantSseMessage).status === 'streaming-content'
          "
          class="typing-indicator"
        ></div>
      </div>
    </div>

    <!-- 用户可以随时中止 -->
    <button v-if="loading" @click="stop">Stop</button>

    <form @submit="(e) => handleSubmit(e, getSseOptions())">
      <input
        :value="input"
        @input="handleInputChange"
        placeholder="Type your message..."
        :disabled="loading"
      />
      <button type="submit" :disabled="loading">Send</button>
    </form>
  </div>
</template>

<style scoped>
/* ... 添加一些美化样式 ... */
.message {
  padding: 8px;
  margin-bottom: 8px;
  border-radius: 4px;
}
.message--user {
  background-color: #e1f5fe;
  text-align: right;
}
.message--assistant {
  background-color: #f1f1f1;
}
.typing-indicator {
  width: 8px;
  height: 8px;
  background-color: #333;
  border-radius: 50%;
  animation: typing 1s infinite;
}
@keyframes typing {
  0% {
    opacity: 0.2;
  }
  50% {
    opacity: 1;
  }
  100% {
    opacity: 0.2;
  }
}
</style>

流程图示

下面是调用 append 方法后,useSse 内部的典型工作流程图,帮助你更直观地理解其运行机制。

sequenceDiagram participant Component as Vue组件 participant UseSse as useSse Hook participant FetchEventSource as fetch-event-source participant Server as 服务器 Component->>UseSse: 调用 append(prompt, sseOptions) activate UseSse UseSse->>UseSse: 更新 messages (添加用户和空的助理消息) UseSse->>UseSse: 设置 loading.value = true Note right of UseSse: 触发UI更新 UseSse->>FetchEventSource: 调用 fetchEventSource(url, options) activate FetchEventSource FetchEventSource->>Server: 发起 HTTP 请求 Server-->>FetchEventSource: 响应流 (Response Stream) FetchEventSource->>UseSse: onopen 回调 UseSse->>UseSse: 更新最后一条消息 status = 'connecting' Note right of UseSse: 触发UI更新 loop 接收数据流 Server-->>FetchEventSource: 推送数据块 (chunk) FetchEventSource->>UseSse: onmessage 回调 (携带 data) UseSse->>UseSse: 调用 formatMessage(data, lastMessage) UseSse->>UseSse: 更新 messages (合并内容并更新 status) Note right of UseSse: 实时打字机效果 end alt 正常结束 Server-->>FetchEventSource: 推送 [DONE] FetchEventSource->>UseSse: onmessage 回调 UseSse->>UseSse: 更新最后一条消息 status = 'completed' UseSse->>UseSse: 调用 onFinish 回调 else 异常/中断 alt 用户中断 Component->>UseSse: 调用 stop() UseSse->>FetchEventSource: controller.abort() FetchEventSource->>UseSse: onerror/onclose 回调 UseSse->>UseSse: 更新最后一条消息 status = 'aborted' end alt 服务器/网络错误 FetchEventSource->>UseSse: onerror 回调 UseSse->>UseSse: 更新 error.value UseSse->>UseSse: 更新最后一条消息 status = 'error' end end UseSse->>UseSse: 设置 loading.value = false deactivate FetchEventSource deactivate UseSse Note over Component: UI 最终状态渲染

通过这个精心设计的 useSse Hook,我们可以用一种极其声明式和可维护的方式,在 Vue 应用中构建出功能强大、体验流畅的 AI 对话产品。

相关推荐
布列瑟农的星空2 小时前
从webpack到vite——配置与特性全面对比
前端
程序员鱼皮2 小时前
我代表编程导航,向大家道歉!
前端·后端·程序员
车前端2 小时前
极致灵活:如何用一个输入框,满足后台千变万化的需求
前端
用户11481867894842 小时前
Rollup构建JavaScript核验库,并发布到NPM
前端
肥晨2 小时前
前端私有化变量还只会加前缀嘛?保姆级教程教你4种私有化变量方法
前端·javascript
小高0072 小时前
前端 Class 不是花架子!3 个大厂常用场景,告诉你它有多实用
前端·javascript·面试
不喝奶茶哦喝奶茶长胖2 小时前
CSS 文本换行控制:text-wrap、white-space 和 word-break 详解
前端
傅里叶3 小时前
Flutter用户体验之01-避免在 build() 或 initState() 内直接做耗时 blocking
前端·flutter
namehu3 小时前
搞定 iOS App 测试包分发,也就这么简单!😎
前端·ios·app