=========================================
Vue3 + AI Agent 前端開發實戰:一個 前端開發工程師的轉型記錄
6年 前端開發經驗,1 年 AI 產品實戰。從 Vue2 到 Vue3,從傳統 Web 到 AI Agent,本文記錄了我作為 Vue 前端工程師在 AI 產品開發中的技術選型、核心難點、解決方案,以及那些踩過的坑。
我是一名「老 Vue」——從 2019 年開始用 Vue2,經歷過 Options API 到 組合式 API(Composition API)的變遷,參與過多個大型 Vue 專案的架構設計。
2025 年,公司決定做 AI 產品線,我主動請纓負責 AI Agent 的前端開發。
剛開始我信心滿滿:「Vue 我都玩得這麼熟了,加個 AI 功能能有多難?」
結果第一個 sprint 就給了我當頭一棒:
這篇文章,就是我這 1 年來的實戰記錄。如果你也是 Vue 開發者,想進入 AI 產品開發領域,希望我的經驗能幫到你。
我們團隊的技術棧一直是 Vue:
如果為了 AI 產品專門換 React,學習成本太高。所以我決定:用 Vue3 做 AI Agent 前端。
AI 產品前端和傳統 Web 應用的最大差別:
Vue 3.4 + Vite 5 + Pinia + TypeScript
流式通訊:SSE + WebSocket
Markdown 渲染:markdown-it + 自訂元件
虛擬滾動:vue-virtual-scroller
狀態管理:Pinia + Composable(組合式 API)
本地儲存:IndexedDB(idb-keyval)
問題:AI 的流式回應是增量更新的,而 Vue 的響應式系統習慣整體更新。初期的寫法可能會像這樣:
<script setup>
import { ref } from 'vue'
const messages = ref([])
const currentContent = ref('')
function handleStreamChunk(chunk) {
currentContent.value += chunk
// 問題:每次都建立新陣列,效能差
messages.value = [...messages.value.slice(0, -1), {
role: 'assistant',
content: currentContent.value
}]
}
</script>
問題:
解決方案:使用 shallowRef + 手動觸發更新
<script lang="ts" setup>
import { ref, shallowRef, triggerRef } from 'vue'
interface Message {
id: string
role: 'user' | 'assistant'
content: string
isStreaming?: boolean
}
// 用 shallowRef 避免深度監聽
const messages = shallowRef<Message[]>([])
const streamingMessageId = ref<string | null>(null)
function handleStreamChunk(chunk: string) {
const msgs = messages.value
const lastMsg = msgs[msgs.length - 1]
if (lastMsg && lastMsg.id === streamingMessageId.value) {
// 原地修改,不觸發深度響應
lastMsg.content += chunk
// 手動觸發更新
triggerRef(messages)
} else {
// 新增訊息
messages.value = [
...msgs,
{
id: streamingMessageId.value!,
role: 'assistant',
content: chunk,
isStreaming: true
}
]
}
}
function handleStreamComplete() {
const msgs = messages.value
const lastMsg = msgs[msgs.length - 1]
if (lastMsg) {
lastMsg.isStreaming = false
triggerRef(messages)
}
streamingMessageId.value = null
}
</script>
關鍵點:
性能對比(示意):
問題:對話相關狀態很多:
初期我把所有狀態都放在 Pinia:
// ❌ 問題:Store 變得很臃腫
import { defineStore } from 'pinia'
export const useChatStore = defineStore('chat', {
state: () => ({
messages: [] as Message[],
isLoading: false,
currentAgent: null as Agent | null,
tokenCount: 0,
connectionStatus: 'disconnected' as ConnectionStatus,
// ... 還有更多狀態
}),
actions: {
async sendMessage(content: string) {
// 邏輯越來越複雜
},
handleStreamChunk(chunk: string) {
// ...
},
connectWebSocket() {
// ...
}
}
})
問題:
解決方案:Composable + Pinia 混合方案
範例 Composable(負責流式):
// composables/useChatStream.ts
import { ref, shallowRef, onUnmounted } from 'vue'
interface UseChatStreamOptions {
onChunk: (chunk: string) => void
onComplete: () => void
onError: (error: Error) => void
}
export function useChatStream(options: UseChatStreamOptions) {
const isConnected = ref(false)
const isStreaming = ref(false)
const error = ref<Error | null>(null)
const abortController = shallowRef<AbortController | null>(null)
let eventSource: EventSource | null = null
function startStream(url: string, payload: unknown) {
abortController.value = new AbortController()
eventSource = new EventSource(url)
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.done) {
options.onComplete()
eventSource?.close()
} else {
options.onChunk(data.content)
}
}
eventSource.onerror = () => {
error.value = new Error('流式連線錯誤')
options.onError(error.value)
eventSource?.close()
}
isConnected.value = true
isStreaming.value = true
}
function stopStream() {
abortController.value?.abort()
eventSource?.close()
isStreaming.value = false
isConnected.value = false
}
// 元件卸載時清理
onUnmounted(() => {
stopStream()
})
return {
isConnected,
isStreaming,
error,
startStream,
stopStream
}
}
範例 Pinia(負責全域狀態):
// stores/chat.ts
import { defineStore } from 'pinia'
interface ChatState {
conversations: Conversation[]
currentConversationId: string | null
agents: Agent[]
tokenUsage: TokenUsage
}
export const useChatStore = defineStore('chat', {
state: (): ChatState => ({
conversations: [],
currentConversationId: null,
agents: [],
tokenUsage: { total: 0, used: 0, remaining: 0 }
}),
getters: {
currentConversation: (state) => {
return state.conversations.find(c => c.id === state.currentConversationId)
}
},
actions: {
async loadConversations() {
const res = await fetch('/api/conversations')
this.conversations = await res.json()
},
async createConversation(title: string) {
const res = await fetch('/api/conversations', {
method: 'POST',
body: JSON.stringify({ title })
})
const conversation = await res.json()
this.conversations.push(conversation)
this.currentConversationId = conversation.id
}
}
})
在元件中結合使用:
<script lang="ts" setup>
import { useChatStore } from '@/stores/chat'
import { useChatStream } from '@/composables/useChatStream'
const chatStore = useChatStore()
// 流式邏輯用 Composable
const { startStream, stopStream, isStreaming } = useChatStream({
onChunk: (chunk) => {
// 更新訊息內容
},
onComplete: () => {
// 更新 Store
chatStore.updateTokenUsage(...)
},
onError: (error) => {
console.error(error)
}
})
// 全域狀態用 Pinia
const conversations = computed(() => chatStore.conversations)
const currentConversation = computed(() => chatStore.currentConversation)
</script>
架構原則:
問題:AI 生成的內容包含 Markdown,需要:
初期方案:
<script setup>
import { marked } from 'marked'
import DOMPurify from 'dompurify'
const props = defineProps({
content: String
})
const html = computed(() => {
const md = marked.parse(props.content)
return DOMPurify.sanitize(md)
})
</script>
<template>
<div v-html="html"></div>
</template>
問題:
最終方案:自訂 Markdown 元件,使用 markdown-it + highlight.js,並加入 DOMPurify 安全過濾及支援 KaTeX(數學公式)
<script lang="ts" setup>
import { computed } from 'vue'
import markdownit from 'markdown-it'
import hljs from 'highlight.js'
import DOMPurify from 'dompurify'
import markdownItKatex from 'markdown-it-katex' // 假設已安裝
// 自訂程式碼區塊渲染
const md = markdownit({
highlight: (str, lang) => {
if (lang && hljs.getLanguage(lang)) {
try {
return `<pre class="hljs"><code>${hljs.highlight(str, { language: lang }).value}</code></pre>`
} catch {}
}
return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`
}
})
// 支援數學公式
md.use(markdownItKatex)
const props = defineProps<{ content: string }>()
const html = computed(() => {
const rendered = md.render(props.content)
return DOMPurify.sanitize(rendered, {
ADD_TAGS: ['iframe'],
ADD_ATTR: ['src', 'allow', 'allowfullscreen']
})
})
</script>
<template>
<div class="ai-markdown" v-html="html"></div>
</template>
<style scoped>
.ai-markdown :deep(pre) {
background: #1e1e1e;
padding: 16px;
border-radius: 8px;
overflow-x: auto;
}
.ai-markdown :deep(code) {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
}
.ai-markdown :deep(table) {
border-collapse: collapse;
width: 100%;
}
.ai-markdown :deep(th),
.ai-markdown :deep(td) {
border: 1px solid #ddd;
padding: 8px;
}
</style>
性能優化:快取解析結果
// composables/useMarkdownCache.ts
import { ref } from 'vue'
import { LRUCache } from 'lru-cache'
import markdownit from 'markdown-it'
const md = markdownit()
const cache = new LRUCache<string, string>({ max: 100 })
export function useMarkdownCache() {
const cachedHtml = ref('')
const cacheKey = ref('')
function parse(content: string) {
// 檢查快取
if (cache.has(content)) {
cachedHtml.value = cache.get(content)!
return
}
// 解析並快取
const html = md.render(content)
cache.set(content, html)
cachedHtml.value = html
cacheKey.value = content
}
return { cachedHtml, parse }
}
問題:對話超過 100 條後,列表明顯卡頓。
初期方案(普通 v-for):
<template>
<div class="message-list">
<message-item
v-for="msg in messages"
:key="msg.id"
:message="msg"
/>
</div>
</template>
效能測試(示意):
解決方案:使用 vue-virtual-scroller
<template>
<RecycleScroller
class="message-list"
:items="messages"
:item-size="100"
key-field="id"
>
<template #default="{ item }">
<message-item :message="item" />
</template>
</RecycleScroller>
</template>
<script lang="ts" setup>
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
</script>
<style scoped>
.message-list {
height: 600px;
overflow-y: auto;
}
</style>
動態高度支援(Dynamic Scroller):
<template>
<DynamicScroller
:items="messages"
:min-item-size="50"
key-field="id"
>
<template #default="{ item, active }">
<DynamicScrollerItem :item="item" :active="active" :size-dependencies="[item.content]">
<message-item :message="item" />
</DynamicScrollerItem>
</template>
</DynamicScroller>
</template>
<script lang="ts" setup>
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
</script>
效能提升(示意):
問題:在 Vue 元件中直接使用 WebSocket,容易忘記清理,造成連線在元件銷毀後仍存在。
錯誤寫法:
// ❌ 問題:元件銷毀後連線還在
const ws = new WebSocket('ws://localhost:8080')
ws.onmessage = (event) => {
// 處理訊息
}
解決方案:用 Composable 封裝連線行為,並在 onUnmounted 中自動清理與重連策略
// composables/useWebSocket.ts
import { ref, onUnmounted } from 'vue'
interface UseWebSocketOptions {
url: string
onMessage?: (data: any) => void
onOpen?: () => void
onClose?: () => void
onError?: (error: Event) => void
reconnectDelay?: number
maxReconnectAttempts?: number
}
export function useWebSocket(options: UseWebSocketOptions) {
const {
url,
onMessage,
onOpen,
onClose,
onError,
reconnectDelay = 1000,
maxReconnectAttempts = 5
} = options
const isConnected = ref(false)
const isConnecting = ref(false)
const error = ref<Event | null>(null)
let ws: WebSocket | null = null
let reconnectAttempts = 0
let reconnectTimer: number | null = null
function connect() {
if (isConnecting.value) return
isConnecting.value = true
ws = new WebSocket(url)
ws.onopen = () => {
isConnected.value = true
isConnecting.value = false
reconnectAttempts = 0
onOpen?.()
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
onMessage?.(data)
} catch {
onMessage?.(event.data)
}
}
ws.onclose = () => {
isConnected.value = false
isConnecting.value = false
onClose?.()
// 自動重連
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++
reconnectTimer = window.setTimeout(connect, reconnectDelay * reconnectAttempts)
}
}
ws.onerror = (e) => {
error.value = e
onError?.(e)
}
}
function disconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
if (ws) {
ws.close()
ws = null
}
}
function send(data: any) {
if (ws && isConnected.value) {
ws.send(JSON.stringify(data))
}
}
// 元件卸載時自動斷開
onUnmounted(() => {
disconnect()
})
return {
isConnected,
isConnecting,
error,
connect,
disconnect,
send
}
}
元件中使用範例:
<script lang="ts" setup>
const { isConnected, send, error } = useWebSocket({
url: 'ws://localhost:8080/chat',
onMessage: (data) => {
console.log('收到訊息:', data)
},
onError: (e) => {
console.error('連線錯誤:', e)
}
})
function sendMessage(content: string) {
send({ type: 'message', content })
}
</script>
src/
├── components/
│ ├── chat/
│ │ ├── ChatContainer.vue # 主容器
│ │ ├── MessageList.vue # 訊息列表(虛擬滾動)
│ │ ├── MessageItem.vue # 單則訊息
│ │ ├── ChatInput.vue # 輸入框
│ │ └── AIMarkdown.vue # Markdown 渲染
│ └── common/
│ ├── LoadingSpinner.vue # 載入動畫
│ └── ErrorBanner.vue # 錯誤提示
├── composables/
│ ├── useChatStream.ts # 流式回應
│ ├── useWebSocket.ts # WebSocket 連線
│ └── useMarkdownCache.ts # Markdown 快取
├── stores/
│ └── chat.ts # Pinia Store
└── types/
└── chat.ts # TypeScript 型別定義
<script lang="ts" setup>
import { ref, computed, nextTick } from 'vue'
import { useChatStore } from '@/stores/chat'
import { useChatStream } from '@/composables/useChatStream'
import MessageList from './MessageList.vue'
import ChatInput from './ChatInput.vue'
import AIMarkdown from './AIMarkdown.vue'
const chatStore = useChatStore()
const messagesContainer = ref<HTMLElement | null>(null)
// 流式回應
const { startStream, stopStream, isStreaming, error } = useChatStream({
onChunk: (chunk) => {
// 更新最後一條訊息
updateLastMessage(chunk)
},
onComplete: () => {
// 更新 Token 使用量
chatStore.updateTokenUsage()
},
onError: (err) => {
console.error('流式錯誤:', err)
}
})
// 送出訊息
async function handleSend(content: string) {
// 新增使用者訊息
chatStore.addMessage({
role: 'user',
content,
timestamp: Date.now()
})
// 開始流式請求
startStream('/api/chat/stream', {
message: content,
conversationId: chatStore.currentConversationId
})
// 滾動到底部
await nextTick()
scrollToBottom()
}
// 停止生成
function handleStop() {
stopStream()
}
// 滾動到底部
function scrollToBottom() {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
// 計算屬性
const messages = computed(() => chatStore.currentMessages)
const isLoading = computed(() => isStreaming.value)
</script>
<template>
<div class="chat-container">
<MessageList ref="messagesContainer" :messages="messages" />
<ErrorBanner :message="error?.message" v-if="error" />
<ChatInput
:disabled="isLoading"
@send="handleSend"
@stop="handleStop"
:show-stop="isLoading"
/>
</div>
</template>
<style scoped>
.chat-container {
display: flex;
flex-direction: column;
height: 100%;
max-width: 900px;
margin: 0 auto;
}
</style>
問題:
const messages = ref<Message[]>([]) // 深度監聽
const temp = shallowRef<Message[]>([]) // 淺監聽
// 混用導致響應式行為不一致
教訓:
問題:
// 忘記清理,導致記憶體洩漏
export function useWebSocket() {
const ws = new WebSocket(url)
// 沒有 onUnmounted 清理
}
教訓:
問題:
// 繞過 actions 直接修改,無法追蹤
chatStore.messages.push(newMessage)
教訓:
Options API(選項式 API)適合傳統 CRUD,但 AI 產品的複雜互動用組合式 API 更靈活:
// Composition API 可以輕鬆組合多個邏輯
const { isConnected, send } = useWebSocket(...)
const { isStreaming, startStream } = useChatStream(...)
const { cachedHtml, parse } = useMarkdownCache(...)
AI 產品的資料結構複雜,TypeScript 能避免很多錯誤:
interface Message {
id: string
role: 'user' | 'assistant' | 'system'
content: string
timestamp: number
metadata?: {
tokenUsage?: number
model?: string
}
}
從傳統 Vue 開發到 AI 產品前端,我最大的收穫是:
技術層面:
架構層面:
心態層面:
歡迎在評論區交流!👇
參考資料:
作者: [你的暱稱] GitHub: [你的 GitHub 連結] 公眾號/知乎: [你的帳號]