阅读视图

发现新文章,点击刷新页面。

前端 SSE 流式请求实战:打造流畅的 AI 流式应答体验

一、引言:从需求到架构的清晰映射

在开发大模型应用或 AI Agent 界面时,流式响应(Streaming Response)已成为提升用户体验的关键。与一次性返回完整结果不同,流式响应允许数据分块、实时地传回前端。这带来了新的挑战:如何以友好、自然的方式呈现这些“涓涓细流”?

本文将深入探讨一套基于 Vue 3 的完整前端解决方案,旨在将原始的服务器发送事件(SSE)流,转化为具有“打字机”动效、并支持 Markdown 实时渲染的富文本交互界面。我们关注的不只是功能实现,更是代码的可维护性、模块的复用性以及交互的流畅性。


二、核心设计目标

在开始编码前,明确我们要达成的核心目标至关重要。这能帮助我们做出正确的技术决策。

  1. 功能完整性:稳定处理从建立 SSE 连接、接收数据、解析事件到处理完成、错误和中止的全生命周期。
  2. 表现层动效:实现逐字输出的“打字机”效果,让 AI 的思考过程更具临场感,避免内容突然全部涌现造成的跳跃感。
  3. 内容富文本化:后端流式返回的通常是 Markdown 源码,前端需要将其实时、准确地渲染为格式化的 HTML(支持加粗、列表、代码块等)。
  4. 架构清晰度:采用关注点分离(Separation of Concerns)原则,将网络通信、展示逻辑和渲染逻辑解耦,使每个部分都易于理解、测试和复用。

基于以上目标,我们选择了以下技术栈,它们在功能、成熟度和社区支持上达到了良好平衡:

能力维度 技术选型 选型理由
SSE 通信 @microsoft/fetch-event-source 在标准 EventSource基础上,提供了对 POST请求、自定义请求头、请求中止等关键功能的支持,更适合生产环境。
Markdown 渲染 markdown-it 一个高效、可配置性极高的 Markdown 解析器。其插件生态系统丰富,可以轻松扩展(如支持代码高亮、数学公式等)。
状态与响应式 Vue 3 Composition API (refcomputedwatch) 组合式 API 为我们提供了组织逻辑的极大灵活性,能够非常清晰地将不同关注点的代码封装在独立的 Hook 中。

三、架构全景:三层职责与数据流

一个清晰的架构是项目可维护性的基石。我们将整个流程抽象为三个层次,数据如同流水线一般依次经过:

[ 数据获取层 Data Layer ]
        ┌─────────────────────────┐
        │   useStreamRun Hook     │
        │ 职责:网络I/O,状态管理  │
        │ 产出:content, thoughts, │
        │       loading, error    │
        └───────────┬─────────────┘
                    │ (原始数据流)
                    ▼
        [ 表现层 Presentation Layer ]
        ┌─────────────────────────┐
        │   useTypewriter Hook    │
        │ 职责:控制文本展示节奏   │
        │ 产出:displayedText     │
        │      (动态字符串)       │
        └───────────┬─────────────┘
                    │ (待渲染文本)
                    ▼
        [ 渲染层 Rendering Layer ]
        ┌─────────────────────────┐
        │     MdRender 组件       │
        │ 职责:文本 → 富文本     │
        │ 产出:HTML (v-html)     │
        └─────────────────────────┘
                    │
                    ▼
            最终的用户界面

各层职责详解

  • useStreamRun(数据层) :这是与后端服务的唯一对话窗口。它负责发起 SSE 请求、管理连接生命周期、解析服务端推送的事件,并将原始数据更新到响应式状态。它不关心数据如何被展示。
  • useTypewriter(表现层) :这是一个纯粹的无副作用函数。它监听数据层提供的文本变化,并通过定时器模拟逐字打印的动画效果。它不关心数据从哪里来,也不关心数据最终被渲染成什么样,只负责“如何展示一段文本的变更过程”。
  • MdRender(渲染层) :这是一个纯展示组件。它接收一段文本(通常是打字机 Hook 输出的动态文本),利用 markdown-it将其转换为 HTML 并安全地插入到 DOM 中。它不关心文本是流式来的还是一次性来的。

这种“高内聚、低耦合”的设计带来了巨大优势:每一层都可以独立演进、测试和复用。例如,useTypewriter不仅可以用于流式 AI 响应,任何需要逐字动画的场景都可以使用它。


四、模块深度解析与最佳实践

4.1 useStreamRun: 构建稳健的数据通道

此 Hook 是系统的基石,其健壮性直接决定了用户体验的上限。以下是我们实现中重点考虑的几个方面及其价值:

  • 并发请求与状态防覆盖:通过引入 runId机制,确保了只有最新发起的请求能够更新全局的 loading和 error状态。这彻底解决了用户快速连续点击“发送”按钮时,旧请求的“完成”信号覆盖新请求“进行中”状态的问题。
  • 优雅的请求中止:利用 AbortController,我们能够在发起新请求或组件卸载时,主动中止未完成的旧请求。这不仅节省了用户流量和服务器资源,也避免了潜在的内存泄漏。在错误处理中,我们特别识别 AbortError并不将其视为真正的错误,使逻辑更清晰。
  • 精细化的错误处理:错误被分为不同层级处理。网络错误在 onerror回调中捕获;服务器端业务逻辑错误(如工作流执行失败)在 onmessage中通过解析特定事件(如 workflow_finished且状态为 failed)来捕获;其他未预料异常在顶层的 try...catch中兜底。这种分级处理使得错误提示可以更精确。

4.2 useTypewriter: 赋予文字生命力与节奏感

打字机效果的核心是控制“时间”和“内容”的映射关系。

  • 核心机制:该 Hook 通过一个 setInterval定时器,定期将 displayedText向目标的 sourceRef值“追赶”一个字符。当两者长度相等时,定时器停止。
  • 状态同步:通过 watch监听源文本变化。当新文本到来时,如果当前没有活跃的定时器,则启动一个新的。这确保了文本流能连续、平滑地动画下去。
  • 用户体验优化catchUp函数是点睛之笔。在流式传输结束时(loading变为 false),一次性将剩余文字全部补齐。这避免了一个尴尬场景:当最后一段文字很长时,用户需要等待漫长的“表演”时间。catchUp确保了信息获取的效率与动画效果的趣味性取得了平衡。

4.3 MdRender: 安全高效的内容渲染

渲染 Markdown 时,安全和性能是首要考虑。

  • 单例模式:在组件内部,markdown-it实例只创建一次(通过 new MarkdownIt()),而不是在每次渲染时创建。这通过 Vue 的 computed属性或直接在 setup顶部声明来实现,避免了不必要的性能开销。
  • 安全性:虽然示例中启用了 html: true以支持 Markdown 中的原生 HTML 标签,但这在部分场景下可能存在 XSS 风险。如果你的内容完全可信,可以保留;如果内容来自不可完全信任的源,建议将其设置为 false,或使用 markdown-it的相应插件进行白名单过滤。
  • 样式与高亮:通过 .markdown-body类名,可以方便地接入现有的 CSS 样式库(如 GitHub Markdown 样式)来实现一致美观的排版。通过集成 highlight.js等库,可以轻松为代码块添加语法高亮,这对技术类 AI 助手的回答呈现至关重要。

五、在业务中组合:创建可复用的对话块

在真实业务组件中,我们将上述模块像乐高积木一样组合起来。

// 业务组合 Hook: useStreamBlock
import { computed, watch } from 'vue';
import { useStreamRun } from './useStreamRun';
import { useTypewriter } from './useTypewriter';

export function useStreamBlock({ url, inputs } = {}) {
  // 1. 建立数据通道
  const { run, loading, error, content, thoughts, abort } = useStreamRun({ url, inputs });

  // 2. 为内容和思考过程分别创建打字机实例
  const { displayedText: displayedContent, catchUp: contentCatchUp } = useTypewriter({
    sourceRef: content,
    speed: 25, // 主内容速度
  });
  const { displayedText: displayedThoughts, catchUp: thoughtsCatchUp } = useTypewriter({
    sourceRef: thoughts,
    speed: 15, // 思考过程可以更快
  });

  // 3. 流结束时,瞬间补齐文字
  watch(loading, (isLoading) => {
    if (!isLoading) {
      thoughtsCatchUp();
      contentCatchUp();
    }
  });

  // 4. 暴露组合后的状态与方法
  return {
    // 控制方法
    run,
    abort,
    // 状态
    loading,
    error,
    // 用于渲染的动态文本
    displayedContent,
    displayedThoughts,
  };
}

在 Vue 组件中,使用变得极其简洁:

<template>
  <div>
    <button @click="run" :disabled="loading">发送问题</button>
    <div v-if="loading">AI 正在思考...</div>
    <div v-if="error" class="error-message">{{ error }}</div>
    <MdRender v-if="displayedThoughts" :content="displayedThoughts" class="thoughts" />
    <MdRender v-if="displayedContent" :content="displayedContent" class="main-content" />
  </div>
</template>

<script setup>
import { useStreamBlock } from '@/composables/useStreamBlock';
import MdRender from '@/components/MdRender.vue';

const { run, loading, error, displayedContent, displayedThoughts } = useStreamBlock({
  url: '/api/chat/completions',
  inputs: { message: '你好,请用 Vue 写一个计数器组件。' }
});
</script>

六、应对边缘情况与增强健壮性

一套完整的方案必须考虑各种边界条件。

  1. 处理空响应:当流式请求成功完成,但 content和 thoughts均为空字符串时,应展示友好的“无结果”提示,而非空白。这需要在业务组件中增加对 !loading && !error && !displayedContent状态的判断。
  2. 错误信息的友好化:后端返回的错误信息结构可能不统一。可以在 useStreamRun的 onmessage或 catch块中,实现一个辅助函数来从不同深度的响应结构中提取可读的错误信息,确保用户看到的是清晰提示,而非晦涩的 JSON。
  3. 参数变化与自动重试:当用户更改了查询条件(如问题或筛选器),我们需要自动发起新的请求。这可以通过 watch监听输入参数,并在变化时先调用 abort()中止当前请求,再调用新的 run()来实现。注意加入防抖(debounce)以避免过于频繁的请求。
  4. 滚动体验优化:在内容逐字输出时,页面自动滚动到底部以跟随最新内容,是一个提升体验的细节。这可以通过在 useTypewriter的 tick函数中触发一个自定义事件,或在父组件中 watch``displayedContent的变化并操作 DOM 来实现。

七、总结:方案价值与复用性

本方案提供了一套从数据接收到最终渲染的完整、模块化的前端流式处理链路。

模块 核心价值 可复用场景
**useStreamRun** 提供了生产级的 SSE 请求管理,包含并发控制、错误处理与状态管理。 任何需要消费 text/event-stream的场合,如实时日志、通知推送、股票行情等。
**useTypewriter** 将任何文本流或动态文本转化为具有节奏感的逐字输出动画。 游戏对话、产品介绍动画、命令行模拟器、任何需要逐步揭示文本的场景。
**MdRender** 将 Markdown 文本安全、高效地转换为富文本 HTML。 博客系统、文档站点、评论区的富文本展示等。
组合模式 展示了如何将底层能力灵活组合,快速构建出复杂的业务功能(如 AI 对话块)。 任何需要“流式数据 + 动画展示 + 富文本渲染”的复合型功能。

核心优势在于其清晰的分层架构关注点分离。每个模块职责单一,接口明确,使得它们不仅可以协同工作,更能轻松地独立测试、调试和被其他项目复用。可以通过此方案设计生成skill,你可以为你产品的 AI 特性或任何流式交互界面,提供一个流畅、专业且易于维护的前端实现基础。

❌