一、引言:从需求到架构的清晰映射
在开发大模型应用或 AI Agent 界面时,流式响应(Streaming Response)已成为提升用户体验的关键。与一次性返回完整结果不同,流式响应允许数据分块、实时地传回前端。这带来了新的挑战:如何以友好、自然的方式呈现这些“涓涓细流”?
本文将深入探讨一套基于 Vue 3 的完整前端解决方案,旨在将原始的服务器发送事件(SSE)流,转化为具有“打字机”动效、并支持 Markdown 实时渲染的富文本交互界面。我们关注的不只是功能实现,更是代码的可维护性、模块的复用性以及交互的流畅性。
二、核心设计目标
在开始编码前,明确我们要达成的核心目标至关重要。这能帮助我们做出正确的技术决策。
- 功能完整性:稳定处理从建立 SSE 连接、接收数据、解析事件到处理完成、错误和中止的全生命周期。
- 表现层动效:实现逐字输出的“打字机”效果,让 AI 的思考过程更具临场感,避免内容突然全部涌现造成的跳跃感。
- 内容富文本化:后端流式返回的通常是 Markdown 源码,前端需要将其实时、准确地渲染为格式化的 HTML(支持加粗、列表、代码块等)。
- 架构清晰度:采用关注点分离(Separation of Concerns)原则,将网络通信、展示逻辑和渲染逻辑解耦,使每个部分都易于理解、测试和复用。
基于以上目标,我们选择了以下技术栈,它们在功能、成熟度和社区支持上达到了良好平衡:
| 能力维度 | 技术选型 | 选型理由 |
|---|---|---|
| SSE 通信 | @microsoft/fetch-event-source | 在标准 EventSource基础上,提供了对 POST请求、自定义请求头、请求中止等关键功能的支持,更适合生产环境。 |
| Markdown 渲染 | markdown-it | 一个高效、可配置性极高的 Markdown 解析器。其插件生态系统丰富,可以轻松扩展(如支持代码高亮、数学公式等)。 |
| 状态与响应式 | Vue 3 Composition API (ref, computed, watch) | 组合式 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>
六、应对边缘情况与增强健壮性
一套完整的方案必须考虑各种边界条件。
- 处理空响应:当流式请求成功完成,但
content和thoughts均为空字符串时,应展示友好的“无结果”提示,而非空白。这需要在业务组件中增加对!loading && !error && !displayedContent状态的判断。 - 错误信息的友好化:后端返回的错误信息结构可能不统一。可以在
useStreamRun的onmessage或catch块中,实现一个辅助函数来从不同深度的响应结构中提取可读的错误信息,确保用户看到的是清晰提示,而非晦涩的 JSON。 - 参数变化与自动重试:当用户更改了查询条件(如问题或筛选器),我们需要自动发起新的请求。这可以通过
watch监听输入参数,并在变化时先调用abort()中止当前请求,再调用新的run()来实现。注意加入防抖(debounce)以避免过于频繁的请求。 - 滚动体验优化:在内容逐字输出时,页面自动滚动到底部以跟随最新内容,是一个提升体验的细节。这可以通过在
useTypewriter的tick函数中触发一个自定义事件,或在父组件中watch``displayedContent的变化并操作 DOM 来实现。
七、总结:方案价值与复用性
本方案提供了一套从数据接收到最终渲染的完整、模块化的前端流式处理链路。
| 模块 | 核心价值 | 可复用场景 |
|---|---|---|
**useStreamRun** | 提供了生产级的 SSE 请求管理,包含并发控制、错误处理与状态管理。 | 任何需要消费 text/event-stream的场合,如实时日志、通知推送、股票行情等。 |
**useTypewriter** | 将任何文本流或动态文本转化为具有节奏感的逐字输出动画。 | 游戏对话、产品介绍动画、命令行模拟器、任何需要逐步揭示文本的场景。 |
**MdRender** | 将 Markdown 文本安全、高效地转换为富文本 HTML。 | 博客系统、文档站点、评论区的富文本展示等。 |
| 组合模式 | 展示了如何将底层能力灵活组合,快速构建出复杂的业务功能(如 AI 对话块)。 | 任何需要“流式数据 + 动画展示 + 富文本渲染”的复合型功能。 |
核心优势在于其清晰的分层架构和关注点分离。每个模块职责单一,接口明确,使得它们不仅可以协同工作,更能轻松地独立测试、调试和被其他项目复用。可以通过此方案设计生成skill,你可以为你产品的 AI 特性或任何流式交互界面,提供一个流畅、专业且易于维护的前端实现基础。