
业务规则的沉淀方法在 Vue3 项目里接入大模型流式对话功能时不少前端研发容易陷入两个极端的误区。一种是直接把 API 当普通 HTTP 接口用等待十多秒后端全返回了才一次性渲染另一种则是想在 MVP第一版就把全套富文本渲染、Markdown 实时编译、代码高亮、公式解析以及上下文管理全补齐。第二种做法往往会导致前端性能崩塌。当模型以 50 字符/秒的速度流式打字输出每一个 Chunk 都会触发响应式 Reactive 数据的更新进而引发页面高频重绘页面甚至会出现肉眼可见的卡顿与打字机抖动。第一版开发的核心应当集中在 SSEServer-Sent Events的稳定流解析、防抖式的 DOM 更新缓冲、以及最基础的会话打断机制上。1. 抓包剖析 SSE 流更新的 DOM 抖动在 Vue3 视图中如果我们把从fetch的ReadableStream读到的字符直接做message.value chunk操作可以通过 Chrome DevTools 的 Performance 面板清晰看到渲染开销使用 Chrome 性能分析模式记录大文本打字过程中的渲染帧率# 模拟后端连续发送高频 SSE 片段 node simulate_sse_stream.js --interval 5ms --chunk-size 2从面板中观察到的性能指标非常糟糕最长单帧渲染耗时达到了 48msFPS 直接掉到了 20 帧以下。主要原因在于 Vue3 收集到了高频的依赖变更不断触发虚拟 DOM 树的重新 Diff 与渲染。解决打字机流畅度的关键在于引入一层离屏缓冲缓冲区Buffer将高频的底层 Chunk 合并后再批量推送到 View 层。2. 流式 Message 缓冲与状态流设计为了在不牺牲流式体验的前提下减少 DOM 重绘我们设计了一套带有缓冲队列的 Vue3 状态流。底层 SSE 读取器只负责从 ReadableStream 中解析行数据并追加到内存的缓冲字符串中。上层通过一个requestAnimationFrame或者低频 Timer如每 30ms定时同步缓冲池里的字符到响应式ref中。如果用户在打字过程中点击了“停止生成”能够立即中断 Fetch 请求并冻结缓冲队列。这种架构使响应式更新频率脱离了网络 Chunk 的不稳定推送锁死在人体肉眼最舒适的 30fps~60fps 频率内。3. Vue3 TypeScript 生产级useStreamChat实现下面是在 Vue3 生态下用 Composition API 实现的完整可复用逻辑Composable。包含了 Fetch Abort 控制、SSE 规范解析、缓冲队列优化以及错误兜底。import { ref, shallowRef, onUnmounted } from vue; export interface ChatMessage { id: string; role: user | assistant; content: string; status: pending | streaming | completed | error; } export interface UseStreamChatOptions { apiEndpoint: string; bufferIntervalMs?: number; onError?: (err: Error) void; } export function useStreamChat(options: UseStreamChatOptions) { const messages refChatMessage[]([]); const isGenerating refboolean(false); privateAbortController: shallowRefAbortController | null(null); let bufferText ; let timerId: number | null null; let activeAssistantMessageId: string | null null; /** * 启动定时同步缓冲区到 Vue 响应式状态 */ const startBufferFlush () { if (timerId ! null) return; const interval options.bufferIntervalMs || 35; timerId window.setInterval(() { if (!activeAssistantMessageId || bufferText.length 0) return; const targetMsg messages.value.find(m m.id activeAssistantMessageId); if (targetMsg) { // 批量刷入缓冲区内容 targetMsg.content bufferText; bufferText ; // 清空已刷入的文本 } }, interval); }; /** * 停止缓冲区刷新 */ const stopBufferFlush () { if (timerId ! null) { clearInterval(timerId); timerId null; } // 刷盘剩余残留 if (activeAssistantMessageId bufferText.length 0) { const targetMsg messages.value.find(m m.id activeAssistantMessageId); if (targetMsg) { targetMsg.content bufferText; bufferText ; } } }; /** * 发送消息并读取 SSE 流 */ const sendMessage async (userPrompt: string) { if (!userPrompt.trim() || isGenerating.value) return; // 1. 追加用户消息 const userMsgId user_${Date.now()}; messages.value.push({ id: userMsgId, role: user, content: userPrompt, status: completed, }); // 2. 初始化 Assistant 消息 const assistantMsgId asst_${Date.now()}; activeAssistantMessageId assistantMsgId; messages.value.push({ id: assistantMsgId, role: assistant, content: , status: pending, }); isGenerating.value true; bufferText ; const controller new AbortController(); privateAbortController.value controller; try { startBufferFlush(); const response await fetch(options.apiEndpoint, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ prompt: userPrompt }), signal: controller.signal, }); if (!response.ok || !response.body) { throw new Error(HTTP 异常: status ${response.status}); } const targetMsg messages.value.find(m m.id assistantMsgId); if (targetMsg) targetMsg.status streaming; const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let leftover ; while (true) { const { done, value } await reader.read(); if (done) break; const chunk decoder.decode(value, { stream: true }); const lines (leftover chunk).split(\n); leftover lines.pop() || ; // 最后一个不完整行留给下一轮 for (const line of lines) { const trimmed line.trim(); if (trimmed.startsWith(data: )) { const dataStr trimmed.slice(6); if (dataStr [DONE]) { break; } try { const parsed JSON.parse(dataStr); const delta parsed.choices?.[0]?.delta?.content || parsed.text || ; bufferText delta; // 暂存至离屏缓冲区 } catch { // 适配纯文本流格式 bufferText dataStr; } } } } // 处理完毕 stopBufferFlush(); if (targetMsg) targetMsg.status completed; } catch (err: any) { stopBufferFlush(); const targetMsg messages.value.find(m m.id assistantMsgId); if (err.name AbortError) { if (targetMsg) targetMsg.status completed; } else { console.error(SSE 流解析发生错误:, err); if (targetMsg) { targetMsg.status error; targetMsg.content \n[网络连接异常生成中断]; } if (options.onError) options.onError(err); } } finally { isGenerating.value false; privateAbortController.value null; activeAssistantMessageId null; } }; /** * 中断当前流式输出 */ const stopGeneration () { if (privateAbortController.value) { privateAbortController.value.abort(); } }; onUnmounted(() { stopBufferFlush(); stopGeneration(); }); return { messages, isGenerating, sendMessage, stopGeneration, }; }4. 第一版的工程边界对于很多刚做 AI 增强前端的团队第一版的边界建议保持极简可用以下指标验证第一版边界增加离屏 Buffer 后在目标设备上比较输入渲染的 CPU 占用和帧率取消掉了臃肿的大库预加载初始包体积减少了 420KB记录手动 Interrupt 的覆盖情况并确认切页时能取消后端 SSE 响应。别在第一个版本就试图把渲染做到万无一失把流式交互的流畅感、打断控制和异常处理落稳剩下的功能完全可以等用户真实反馈来了再逐步加上去。