Vue3+TypeScript组合式API实战:流式SSE对话组件开发全流程

为什么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接收apiUrlmodel等配置;暴露sendstopclear方法供父组件调用;通过@streaming@done@error事件通知状态变化;通过v-model:messages支持消息列表双向绑定。

整个流式对话组件的开发流程,核心在于理解SSE协议特性、合理利用Vue3响应式系统、以及处理渲染性能瓶颈。这三个维度做好,组件就能在生产环境中稳定运行。

原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/vue3typescript-zu-he-shi-api-shi-zhan-liu-shi-sse-dui-hua/

(0)
小编小编
上一篇 3小时前
下一篇 3小时前

相关推荐