前端流式渲染实战:用 SSE + Vue 3 打造 ChatGPT 式实时对话界面

2748 字
14 分钟
前端流式渲染实战:用 SSE + Vue 3 打造 ChatGPT 式实时对话界面

为什么你需要流式渲染?#

如果你用过 ChatGPT、Claude 或任何大模型产品,你一定见过那个”逐字蹦出”的效果——文字像打字机一样一个个出现,而不是等半天突然甩你一大段。这不是花活,这是刚需

大模型的推理时间从几秒到几十秒不等。如果用传统的请求-响应模式,用户得盯着一个 loading 转圈几十秒,体验极差。而流式渲染让用户在第一个 token 生成的瞬间就能看到内容,感知延迟从 10 秒降到 200ms

今天我们用 Vue 3 + SSE(Server-Sent Events)从零实现一个生产级的流式对话界面,涵盖所有你会踩的坑。

SSE vs WebSocket:选哪个?#

先说结论:AI 对话场景,SSE 是更好的选择。

维度SSEWebSocket
方向服务端 → 客户端(单向)双向
协议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 手动实现:

composables/useSSE.ts
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 状态管理#

composables/useChat.ts
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 节流 + 增量渲染

composables/useStreamRenderer.ts
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 个,它们会被合并成一次渲染。

第四步:组件实现#

components/ChatView.vue
<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 = null
watch(
() => 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 渲染#

流式输出时,代码块经常处于”打开但未闭合”的状态:

这是一段代码:
```python
def 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.2sN/A12MB
SSE + 每 token 渲染180ms24fps(卡顿)45MB
SSE + rAF 节流渲染180ms58fps18MB
SSE + 分段渲染180ms60fps15MB

可以看到,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 应用的标配。核心要点回顾:

  1. 选 SSE 不选 WebSocket——AI 对话是单向推送,SSE 更简单、更可靠
  2. 用 fetch + ReadableStream 替代 EventSource——支持 POST 和自定义 Header
  3. rAF 节流渲染——每帧最多渲染一次,解决性能问题
  4. 处理边界情况——未闭合代码块、断网重连、并发竞态
  5. Nginx 关闭 proxy_buffering——不然你的流式效果全白搭

这套方案已经在多个生产项目中跑了大半年,日活用户过万,没出过大问题。如果你正在做 AI 相关的前端项目,直接拿去用就行。

有问题欢迎评论区交流,我会逐一回复。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

前端流式渲染实战:用 SSE + Vue 3 打造 ChatGPT 式实时对话界面
https://boke.hackerdream.xyz/posts/vue3-sse-streaming-chat-ui/
作者
晴天
发布于
2026-05-01
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
125
分类
17
标签
287
总字数
257,955
运行时长
0
最后活动
0 天前

目录