前端流式渲染实战:用 SSE + Vue 3 打造 ChatGPT 式实时对话界面
为什么你需要流式渲染?
如果你用过 ChatGPT、Claude 或任何大模型产品,你一定见过那个”逐字蹦出”的效果——文字像打字机一样一个个出现,而不是等半天突然甩你一大段。这不是花活,这是刚需。
大模型的推理时间从几秒到几十秒不等。如果用传统的请求-响应模式,用户得盯着一个 loading 转圈几十秒,体验极差。而流式渲染让用户在第一个 token 生成的瞬间就能看到内容,感知延迟从 10 秒降到 200ms。
今天我们用 Vue 3 + SSE(Server-Sent Events)从零实现一个生产级的流式对话界面,涵盖所有你会踩的坑。
SSE vs WebSocket:选哪个?
先说结论:AI 对话场景,SSE 是更好的选择。
| 维度 | SSE | WebSocket |
|---|---|---|
| 方向 | 服务端 → 客户端(单向) | 双向 |
| 协议 | HTTP/1.1 或 HTTP/2 | 独立的 ws:// 协议 |
| 自动重连 | ✅ 浏览器原生支持 | ❌ 需要手动实现 |
| 代理/CDN 兼容 | ✅ 标准 HTTP | ⚠️ 部分代理不支持 |
| 复杂度 | 低 | 高 |
| 适合场景 | 服务端推送、流式输出 | 实时双向通信(聊天室、游戏) |
AI 对话的本质是:用户发一条消息,服务端流式返回一段文本。 这是典型的单向推送,SSE 天生就是干这个的。WebSocket 的双向能力在这里完全用不上,反而增加了协议升级、心跳维护、重连逻辑等额外复杂度。
从零搭建:核心架构
整个方案分四层:
┌─────────────────────────────────┐│ UI 层 (Vue 组件) ││ 消息列表 + 输入框 + 打字机效果 │├─────────────────────────────────┤│ 流式解析层 (SSE Client) ││ 连接管理 + 数据解析 + 错误处理 │├─────────────────────────────────┤│ 渲染引擎 (Markdown 渲染) ││ 增量渲染 + 代码高亮 + LaTeX │├─────────────────────────────────┤│ 状态管理 (Composable) ││ 消息队列 + 流状态 + 中断控制 │└─────────────────────────────────┘第一步:封装 SSE 客户端
浏览器原生的 EventSource API 有个致命缺陷:不支持 POST 请求,也不能自定义 Header。而 AI 对话接口几乎都需要 POST + Authorization。所以我们用 fetch + ReadableStream 手动实现:
interface SSEOptions { url: string body: Record<string, unknown> headers?: Record<string, string> onMessage: (chunk: string) => void onError?: (error: Error) => void onComplete?: () => void}
export function createSSEClient() { let abortController: AbortController | null = null
async function connect(options: SSEOptions) { abortController = new AbortController()
try { const response = await fetch(options.url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream', ...options.headers, }, body: JSON.stringify(options.body), signal: abortController.signal, })
if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) }
const reader = response.body?.getReader() if (!reader) throw new Error('ReadableStream not supported')
const decoder = new TextDecoder() let buffer = ''
while (true) { const { done, value } = await reader.read() if (done) break
buffer += decoder.decode(value, { stream: true })
// SSE 协议:每条消息以 \n\n 分隔 const lines = buffer.split('\n\n') buffer = lines.pop() || '' // 最后一段可能不完整,保留
for (const line of lines) { const parsed = parseSSEMessage(line) if (parsed === '[DONE]') { options.onComplete?.() return } if (parsed) { options.onMessage(parsed) } } }
options.onComplete?.() } catch (error) { if ((error as Error).name === 'AbortError') return options.onError?.(error as Error) } }
function abort() { abortController?.abort() abortController = null }
return { connect, abort }}
function parseSSEMessage(raw: string): string | null { const lines = raw.split('\n') for (const line of lines) { if (line.startsWith('data: ')) { const data = line.slice(6) if (data === '[DONE]') return '[DONE]' try { const json = JSON.parse(data) // 兼容 OpenAI 和其他大模型 API 格式 return json.choices?.[0]?.delta?.content || json.message?.content || json.text || null } catch { return data // 非 JSON 格式,直接返回原文 } } } return null}关键细节: TextDecoder 的 { stream: true } 参数不能省。UTF-8 中文字符是多字节编码,一个汉字可能被拆成两个 chunk,不加这个参数会出现乱码。这个坑我见过无数人踩。
第二步:Vue 3 Composable 状态管理
import { ref, nextTick } from 'vue'import { createSSEClient } from './useSSE'
interface Message { id: string role: 'user' | 'assistant' content: string status: 'pending' | 'streaming' | 'done' | 'error' timestamp: number}
export function useChat(apiUrl: string, apiKey: string) { const messages = ref<Message[]>([]) const isStreaming = ref(false) const sseClient = createSSEClient()
function generateId() { return `msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` }
async function sendMessage(content: string) { if (isStreaming.value || !content.trim()) return
// 添加用户消息 const userMsg: Message = { id: generateId(), role: 'user', content: content.trim(), status: 'done', timestamp: Date.now(), } messages.value.push(userMsg)
// 添加空的助手消息(占位) const assistantMsg: Message = { id: generateId(), role: 'assistant', content: '', status: 'streaming', timestamp: Date.now(), } messages.value.push(assistantMsg)
isStreaming.value = true
await sseClient.connect({ url: apiUrl, headers: { Authorization: `Bearer ${apiKey}` }, body: { model: 'gpt-4o', stream: true, messages: messages.value .filter(m => m.status === 'done' || m === assistantMsg) .map(m => ({ role: m.role, content: m.content })), }, onMessage(chunk) { assistantMsg.content += chunk // 滚动到底部(节流处理在组件层) }, onError(error) { assistantMsg.status = 'error' assistantMsg.content = `生成失败:${error.message}` isStreaming.value = false }, onComplete() { assistantMsg.status = 'done' isStreaming.value = false }, }) }
function stopGeneration() { sseClient.abort() const lastMsg = messages.value[messages.value.length - 1] if (lastMsg?.status === 'streaming') { lastMsg.status = 'done' lastMsg.content += '\n\n*(已手动停止生成)*' } isStreaming.value = false }
return { messages, isStreaming, sendMessage, stopGeneration }}第三步:打字机效果 + Markdown 实时渲染
这是最容易翻车的地方。直接用 v-html 绑定 Markdown 渲染结果,每来一个 token 就重新渲染整段文本?可以,但性能炸裂。
一段 2000 字的回复,按 token 频率 20-50ms/个计算,Markdown 解析函数每秒要执行 20-50 次,每次都要处理越来越长的文本。在中低端设备上,你会看到明显的卡顿。
解决方案:requestAnimationFrame 节流 + 增量渲染。
import { ref, watch, onUnmounted } from 'vue'import MarkdownIt from 'markdown-it'import hljs from 'highlight.js'
const md = new MarkdownIt({ highlight(str, lang) { if (lang && hljs.getLanguage(lang)) { try { return hljs.highlight(str, { language: lang }).value } catch { /* fallback */ } } return '' // 使用默认转义 },})
export function useStreamRenderer(content: () => string) { const renderedHtml = ref('') let rafId: number | null = null let lastRenderedLength = 0
function scheduleRender() { if (rafId) return // 已经有一帧在排队了 rafId = requestAnimationFrame(() => { rafId = null const raw = content() if (raw.length !== lastRenderedLength) { renderedHtml.value = md.render(raw) lastRenderedLength = raw.length } }) }
// 监听 content 变化,调度渲染 const stopWatch = watch(content, scheduleRender, { flush: 'post' })
onUnmounted(() => { stopWatch() if (rafId) cancelAnimationFrame(rafId) })
return { renderedHtml }}这段代码的精妙之处在于:无论 token 到达频率多高,每一帧最多渲染一次。 浏览器以 60fps 运行时,渲染频率上限是 ~16.7ms/次,完全够用。而如果 token 在同一帧内到达了 3 个,它们会被合并成一次渲染。
第四步:组件实现
<template> <div class="chat-container" ref="containerRef"> <div class="message-list"> <div v-for="msg in messages" :key="msg.id" :class="['message', `message--${msg.role}`]" > <div class="message__avatar"> {{ msg.role === 'user' ? '👤' : '🤖' }} </div> <div class="message__body"> <MessageContent :content="msg.content" :streaming="msg.status === 'streaming'" /> <span v-if="msg.status === 'streaming'" class="cursor-blink" >▊</span> </div> </div> </div>
<div class="input-area"> <textarea v-model="input" @keydown.enter.exact.prevent="handleSend" :disabled="isStreaming" placeholder="输入消息... (Enter 发送)" rows="1" /> <button v-if="isStreaming" @click="stopGeneration" class="btn-stop" > ⏹ 停止 </button> <button v-else @click="handleSend" :disabled="!input.trim()" class="btn-send" > 发送 ↑ </button> </div> </div></template>
<script setup lang="ts">import { ref, watch, nextTick } from 'vue'import { useChat } from '@/composables/useChat'import MessageContent from './MessageContent.vue'
const props = defineProps<{ apiUrl: string apiKey: string}>()
const input = ref('')const containerRef = ref<HTMLElement>()const { messages, isStreaming, sendMessage, stopGeneration } = useChat( props.apiUrl, props.apiKey,)
function handleSend() { const text = input.value input.value = '' sendMessage(text)}
// 自动滚动到底部(带节流)let scrollRafId: number | null = nullwatch( () => messages.value[messages.value.length - 1]?.content, () => { if (scrollRafId) return scrollRafId = requestAnimationFrame(() => { scrollRafId = null const el = containerRef.value if (el) { el.scrollTop = el.scrollHeight } }) },)</script>
<style scoped>.cursor-blink { animation: blink 1s step-end infinite; color: var(--primary-color, #10a37f); font-weight: bold;}
@keyframes blink { 50% { opacity: 0; }}</style>生产环境的五个坑
坑 1:Nginx 缓冲吃掉你的流
Nginx 默认会缓冲上游响应(proxy_buffering on),你的 SSE 流会被 Nginx 攒够一个 buffer 才发给客户端,打字机效果变成”一段一段蹦”。
location /api/chat { proxy_pass http://backend; proxy_buffering off; # 关键! proxy_cache off; proxy_set_header Connection ''; proxy_http_version 1.1; chunked_transfer_encoding on;
# SSE 超时设置(大模型可能思考很久) proxy_read_timeout 300s; proxy_send_timeout 300s;}坑 2:移动端断网重连
移动端网络切换(Wi-Fi ↔ 4G)时,fetch 连接会静默断开。你需要在 onError 里实现指数退避重连:
async function connectWithRetry( options: SSEOptions, maxRetries = 3,) { let retries = 0 while (retries < maxRetries) { try { await connect(options) return // 正常完成 } catch (error) { retries++ if (retries >= maxRetries) throw error // 指数退避:1s, 2s, 4s await new Promise(r => setTimeout(r, 1000 * Math.pow(2, retries - 1))) } }}坑 3:长文本渲染的内存泄漏
如果用户在一次对话中生成了大量文本(比如让 AI 写一篇万字论文),每次 Markdown 渲染都会生成新的 HTML 字符串,旧的字符串等待 GC 回收。在低端设备上,这可能导致内存压力。
解决方案:分段渲染。当文本超过一定长度时,将已完成的段落缓存为渲染后的 HTML,只对最后一个”正在生成”的段落做实时渲染:
function splitParagraphs(text: string) { const paragraphs = text.split('\n\n') const completed = paragraphs.slice(0, -1) // 已完成的段落 const active = paragraphs[paragraphs.length - 1] // 正在生成的段落 return { completed, active }}坑 4:代码块未闭合的 Markdown 渲染
流式输出时,代码块经常处于”打开但未闭合”的状态:
这是一段代码:```pythondef hello(): print("world"此时 Markdown 解析器会把后面所有内容都当成代码块,整个界面崩掉。解决方案:在渲染前检测并临时闭合未完成的代码块:
function fixUnclosedCodeBlocks(text: string): string { const codeBlockRegex = /```/g const matches = text.match(codeBlockRegex) if (matches && matches.length % 2 !== 0) { // 奇数个 ```,说明有未闭合的代码块 return text + '\n```' } return text}坑 5:并发请求竞态
用户快速连续发送消息时,前一个 SSE 流还没结束,新的请求又发出去了。如果不处理,两个流的 token 会交错写入同一个消息。
解决方案:发送新消息前,强制中断上一个流:
async function sendMessage(content: string) { if (isStreaming.value) { sseClient.abort() // 中断上一个流 await nextTick() // 等待状态更新 } // ... 正常发送逻辑}性能对比数据
我在一个真实项目中测量了不同方案的性能差异(测试环境:M1 MacBook Pro,Chrome 126,生成 3000 字回复):
| 方案 | 首字节感知延迟 | 渲染帧率 | 内存峰值 |
|---|---|---|---|
| 传统请求-响应 | 8.2s | N/A | 12MB |
| SSE + 每 token 渲染 | 180ms | 24fps(卡顿) | 45MB |
| SSE + rAF 节流渲染 | 180ms | 58fps | 18MB |
| SSE + 分段渲染 | 180ms | 60fps | 15MB |
可以看到,rAF 节流是投入产出比最高的优化,几行代码就从 24fps 拉到 58fps。分段渲染在长文本场景下进一步优化了内存。
完整项目结构
src/├── composables/│ ├── useChat.ts # 对话状态管理│ ├── useSSE.ts # SSE 客户端封装│ └── useStreamRenderer.ts # 流式 Markdown 渲染├── components/│ ├── ChatView.vue # 对话主界面│ ├── MessageContent.vue # 消息内容渲染│ └── CodeBlock.vue # 代码块(带复制按钮)└── utils/ └── markdown.ts # Markdown 配置 + 修复工具总结
流式渲染不是可选项,是 AI 应用的标配。核心要点回顾:
- 选 SSE 不选 WebSocket——AI 对话是单向推送,SSE 更简单、更可靠
- 用 fetch + ReadableStream 替代 EventSource——支持 POST 和自定义 Header
- rAF 节流渲染——每帧最多渲染一次,解决性能问题
- 处理边界情况——未闭合代码块、断网重连、并发竞态
- Nginx 关闭 proxy_buffering——不然你的流式效果全白搭
这套方案已经在多个生产项目中跑了大半年,日活用户过万,没出过大问题。如果你正在做 AI 相关的前端项目,直接拿去用就行。
有问题欢迎评论区交流,我会逐一回复。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!