为什么AI对话组件需要流式渲染
前端开发中实现AI对话界面时,如果等待完整响应再渲染,用户面对空白屏幕的等待体验极差——大模型生成一条200字回复可能耗时5-10秒。流式SSE(Server-Sent Events)传输让Token逐块到达前端,配合打字机效果即时呈现,P95感知延迟从完整等待降至首Token到达时间,通常在200毫秒以内。Vue3组合式API的响应式系统天然适合处理这种增量数据更新场景,配合TypeScript的类型约束可以构建出健壮的流式对话组件。
SSE流式传输协议与API对接
SSE是HTML5标准中的单向实时通信协议,相比WebSocket更轻量,天然支持断线重连和事件分发。大模型推理服务普遍采用SSE推送Token流:
// SSE响应格式示例
// data: {"choices":[{"delta":{"content":"你"},"index":0}]}
// data: {"choices":[{"delta":{"content":"好"},"index":0}]}
// data: [DONE]
// 原生EventSource连接(仅支持GET请求)
const eventSource = new EventSource('/api/chat/stream?prompt=hello');
eventSource.onmessage = (event) => {
if (event.data === '[DONE]') {
eventSource.close();
return;
}
const data = JSON.parse(event.data);
appendToken(data.choices[0].delta.content);
};
// fetch + ReadableStream(支持POST请求,推荐方案)
async function streamChat(messages: Message[]) {
const response = await fetch('/api/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, stream: true }),
});
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') return;
const parsed = JSON.parse(data);
yield parsed.choices[0].delta.content;
}
}
}
}
使用fetch而非EventSource的原因在于:EventSource只支持GET请求,无法传递复杂的消息体;且EventSource不支持自定义Header(如Authorization),难以携带认证信息。
Vue3组合式API核心组件实现
用TypeScript定义类型并实现核心组合式函数:
// types.ts
export interface Message {
id: string;
role: 'system' | 'user' | 'assistant';
content: string;
timestamp: number;
status: 'pending' | 'streaming' | 'done' | 'error';
}
export interface ChatOptions {
model?: string;
temperature?: number;
maxTokens?: number;
signal?: AbortSignal;
}
// useStreamChat.ts
import { ref, shallowRef } from 'vue';
export function useStreamChat(apiUrl: string) {
const messages = ref<Message[]>([]);
const isStreaming = ref(false);
const currentContent = ref('');
const abortController = shallowRef<AbortController | null>(null);
async function sendMessage(content: string, options?: ChatOptions) {
const userMessage: Message = {
id: crypto.randomUUID(),
role: 'user',
content,
timestamp: Date.now(),
status: 'done',
};
messages.value = [...messages.value, userMessage];
const assistantMessage: Message = {
id: crypto.randomUUID(),
role: 'assistant',
content: '',
timestamp: Date.now(),
status: 'streaming',
};
messages.value = [...messages.value, assistantMessage];
isStreaming.value = true;
currentContent.value = '';
abortController.value = new AbortController();
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify({
messages: messages.value
.filter(m => m.status !== 'pending')
.map(m => ({ role: m.role, content: m.content })),
stream: true,
temperature: options?.temperature ?? 0.7,
max_tokens: options?.maxTokens ?? 2048,
}),
signal: abortController.value.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6).trim();
if (data === '[DONE]') {
assistantMessage.status = 'done';
break;
}
try {
const parsed = JSON.parse(data);
const token = parsed.choices?.[0]?.delta?.content;
if (token) {
currentContent.value += token;
assistantMessage.content = currentContent.value;
messages.value = [...messages.value];
}
} catch {
// 跳过无法解析的行
}
}
}
assistantMessage.status = 'done';
} catch (error) {
if ((error as Error).name === 'AbortError') {
assistantMessage.status = 'done';
assistantMessage.content = currentContent.value || '(已取消)';
} else {
assistantMessage.status = 'error';
assistantMessage.content = `请求失败: ${(error as Error).message}`;
}
} finally {
isStreaming.value = false;
abortController.value = null;
}
}
function stopGeneration() {
abortController.value?.abort();
}
function clearMessages() {
messages.value = [];
currentContent.value = '';
}
return {
messages,
isStreaming,
currentContent,
sendMessage,
stopGeneration,
clearMessages,
};
}
对话界面组件与Markdown渲染
AI回复内容通常包含Markdown格式(代码块、列表、加粗等),需要实时渲染。使用markdown-it配合highlight.js实现代码高亮:
// ChatMessage.vue
<template>
<div :class="['chat-message', `chat-message--${message.role}`]">
<div class="chat-message__avatar">
{{ message.role === 'user' ? 'U' : 'AI' }}
</div>
<div class="chat-message__body">
<div
v-if="message.role === 'assistant'"
class="chat-message__content markdown-body"
v-html="renderedContent"
/>
<div v-else class="chat-message__content">
{{ message.content }}
</div>
<span v-if="message.status === 'streaming'" class="cursor-blink">|</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import MarkdownIt from 'markdown-it';
import hljs from 'highlight.js';
import type { Message } from './types';
const props = defineProps<{ message: Message }>();
const md = new MarkdownIt({
html: false,
linkify: true,
highlight(str: string, lang: string) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value;
} catch { /* fallback */ }
}
return '';
},
});
const renderedContent = computed(() => {
return md.render(props.message.content || '');
});
</script>
<style scoped>
.chat-message {
display: flex;
gap: 12px;
padding: 16px;
margin-bottom: 8px;
}
.chat-message--assistant {
background: #f7f8fa;
}
.cursor-blink {
animation: blink 0.8s infinite;
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
</style>
Web性能优化:虚拟滚动与渲染节流
长对话场景下,DOM节点数量快速增长会导致渲染卡顿。虚拟滚动只渲染可视区域内的消息节点,保持DOM数量恒定:
// 使用requestAnimationFrame节流Token渲染
let pendingTokens: string[] = [];
let rafId: number | null = null;
function throttledAppend(token: string) {
pendingTokens.push(token);
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
const batch = pendingTokens.join('');
pendingTokens = [];
rafId = null;
currentContent.value += batch;
});
}
// 虚拟列表核心逻辑
const ITEM_HEIGHT = 120;
const scrollTop = ref(0);
const containerHeight = ref(600);
const visibleStart = computed(() =>
Math.floor(scrollTop.value / ITEM_HEIGHT)
);
const visibleCount = computed(() =>
Math.ceil(containerHeight.value / ITEM_HEIGHT) + 2
);
流式更新时对渲染频率做节流,避免每个Token到达都触发Vue重新渲染。将多个Token在一个动画帧内批量追加,减少DOM操作次数,这是前端工程化中处理高频数据更新的标准做法。
跨端适配与响应式布局
对话组件需要适配桌面、平板和移动端三种屏幕宽度。结合CSS Grid和媒体查询实现响应式布局:
/* 响应式布局 */
.chat-container {
display: grid;
grid-template-rows: auto 1fr auto;
height: 100vh;
max-width: 960px;
margin: 0 auto;
}
.chat-sidebar {
width: 280px;
border-right: 1px solid #e5e7eb;
}
/* 移动端隐藏侧边栏 */
@media (max-width: 768px) {
.chat-sidebar {
position: fixed;
left: -280px;
transition: left 0.3s ease;
z-index: 50;
}
.chat-sidebar--open {
left: 0;
}
}
/* 小屏幕输入区域调整 */
@media (max-width: 480px) {
.chat-input {
padding: 8px 12px;
}
.chat-input textarea {
font-size: 16px; /* 防止iOS自动缩放 */
}
}
组件库设计与API暴露
将流式对话功能封装为独立组件库,对外暴露清晰的Props和事件:
// StreamChat/index.ts 组件库入口
import type { App, Plugin } from 'vue';
import StreamChat from './StreamChat.vue';
import ChatMessage from './ChatMessage.vue';
import ChatInput from './ChatInput.vue';
export const StreamChatPlugin: Plugin = {
install(app: App) {
app.component('StreamChat', StreamChat);
app.component('ChatMessage', ChatMessage);
app.component('ChatInput', ChatInput);
},
};
export { StreamChat, ChatMessage, ChatInput };
export type { Message, ChatOptions } from './types';
// 使用方式
// app.use(StreamChatPlugin);
// <StreamChat api-url="/api/chat/stream" @send="onSend" @error="onError" />
组件库设计要点:Props接收apiUrl、model等配置;暴露send、stop、clear方法供父组件调用;通过@streaming、@done、@error事件通知状态变化;通过v-model:messages支持消息列表双向绑定。
整个流式对话组件的开发流程,核心在于理解SSE协议特性、合理利用Vue3响应式系统、以及处理渲染性能瓶颈。这三个维度做好,组件就能在生产环境中稳定运行。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3typescript-zu-he-shi-api-shi-zhan-liu-shi-sse-dui-hua/