🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

=========================================

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 就給了我當頭一棒:

  • 流式回應和 Vue 的響應式系統怎麼配合?
  • 對話狀態用 Pinia 還是用 Composable(組合式)?
  • AI 生成的 Markdown 內容怎麼高效渲染?
  • 長對話列表怎麼用 Vue 實現虛擬滾動?
  • WebSocket 連線怎麼在 Vue 元件中優雅管理?

這篇文章,就是我這 1 年來的實戰記錄。如果你也是 Vue 開發者,想進入 AI 產品開發領域,希望我的經驗能幫到你。


一、技術選型:為什麼是 Vue3?

1.1 團隊背景

我們團隊的技術棧一直是 Vue:

  • 舊專案:Vue2 + Vuex
  • 新專案:Vue3 + Pinia
  • UI 框架:Element Plus

如果為了 AI 產品專門換 React,學習成本太高。所以我決定:用 Vue3 做 AI Agent 前端

1.2 核心挑戰

AI 產品前端和傳統 Web 應用的最大差別:

  • 傳統 Web:請求 — 回應模式,狀態變化可預測,內容結構清晰,單一模態(HTML)
  • AI Agent 前端:流式回應、AI 回覆內容不確定,支援多模態內容(Markdown、程式碼、公式),對話輪數多,長對話需要效能優化,網路中斷需可重試,需要離線可用性

1.3 最終技術棧

Vue 3.4 + Vite 5 + Pinia + TypeScript
流式通訊:SSE + WebSocket
Markdown 渲染:markdown-it + 自訂元件
虛擬滾動:vue-virtual-scroller
狀態管理:Pinia + Composable(組合式 API)
本地儲存:IndexedDB(idb-keyval)

二、核心難點與 Vue 解決方案

2.1 難點一:流式回應與 Vue 響應式配合

問題: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>

問題:

  • 每次 chunk 都觸發陣列重新賦值
  • 導致整個訊息列表重新渲染
  • 對話多了之後明顯卡頓

解決方案:使用 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>

關鍵點:

  1. shallowRef 只會監聽第一層變化,避免對深層物件做遞迴監聽
  2. 原地修改陣列元素,減少不必要的複製
  3. triggerRef 手動觸發更新,控制渲染時機

性能對比(示意):

  • 普通 ref:100 條訊息 80ms,500 條訊息 450ms
  • shallowRef + triggerRef:100 條訊息 15ms,500 條訊息 50ms

2.2 難點二:對話狀態管理(Pinia vs Composable)

問題:對話相關狀態很多:

  • 訊息列表
  • 載入/串流狀態
  • 當前 Agent
  • Token 使用量
  • 連線狀態

初期我把所有狀態都放在 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() {
      // ...
    }
  }
})

問題:

  • Store 檔案超過 500 行,難以維護
  • WebSocket 邏輯和 UI 狀態混在一起
  • 難以重複使用(多個聊天視窗需要多個實例)

解決方案:Composable + Pinia 混合方案

  • 用 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>

架構原則:

  • Composable:元件內邏輯、複雜互動、外部連線(WebSocket/SSE)
  • Pinia:全域狀態、持久化資料、跨元件共享

2.3 難點三:Markdown 內容渲染

問題:AI 生成的內容包含 Markdown,需要:

  • 程式碼高亮
  • 數學公式(LaTeX)
  • 表格、列表
  • 安全的 HTML 渲染(防止 XSS)

初期方案:

<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>

問題:

  • 長文本解析慢
  • 程式碼區塊沒有高亮
  • 沒有與 Vue 元件整合(例如程式碼塊的交互、複製按鈕等)

最終方案:自訂 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 }
}

2.4 難點四:長對話列表虛擬滾動

問題:對話超過 100 條後,列表明顯卡頓。

初期方案(普通 v-for):

<template>
  <div class="message-list">
    <message-item
      v-for="msg in messages"
      :key="msg.id"
      :message="msg"
    />
  </div>
</template>

效能測試(示意):

  • 50 條:25ms,FPS 60
  • 200 條:150ms,FPS 30
  • 500 條:500ms,FPS 15

解決方案:使用 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>

效能提升(示意):

  • 500 條渲染:從 500ms 優化到 30ms(約 16 倍)
  • 滾動 FPS:從 15fps 提升到 60fps
  • 內存使用:從 300MB 降到 50MB

2.5 難點五:WebSocket 連線管理

問題:在 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>

三、實戰案例:AI 對話元件完整實作

3.1 元件結構

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 型別定義

3.2 核心元件程式碼

<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>

四、效能優化總結

4.1 關鍵優化手段

  • 響應式優化:shallowRef + triggerRef(渲染時間大幅下降)
  • 列表渲染:vue-virtual-scroller(500 條訊息仍能保持高 FPS)
  • Markdown 解析:LRU 快取(重複內容不重新解析)
  • WebSocket:Composable 封裝(自動清理、無記憶體洩漏)
  • 狀態管理:Pinia + Composable 分離(提升程式可維護性)

4.2 效能指標對比(示意)

  • 首屏載入:2.5s -> 0.8s(3 倍)
  • 訊息渲染(100 條):80ms -> 15ms(5 倍)
  • 滾動 FPS:30fps -> 60fps(流暢)
  • 內存佔用:300MB -> 80MB(約 4 倍)
  • WebSocket 重連:手動 -> 自動指數退避(穩定)

五、踩過的坑與教訓

坑一:ref 和 shallowRef 混用

問題:

const messages = ref<Message[]>([]) // 深度監聽
const temp = shallowRef<Message[]>([]) // 淺監聽

// 混用導致響應式行為不一致

教訓:

  • 陣列/物件用 shallowRef(在大量寫入/頻繁原地修改時)
  • 基本型別用 ref
  • 統一團隊規範以避免混淆

坑二:Composable 中忘記 onUnmounted

問題:

// 忘記清理,導致記憶體洩漏
export function useWebSocket() {
  const ws = new WebSocket(url)
  // 沒有 onUnmounted 清理
}

教訓:

  • 所有外部資源(WebSocket、EventSource、定時器、AbortController 等)都要在 onUnmounted 中清理
  • 用 ESLint 規則或專案規範強制檢查

坑三:Pinia Store 中直接修改 state

問題:

// 繞過 actions 直接修改,無法追蹤
chatStore.messages.push(newMessage)

教訓:

  • 所有狀態修改透過 actions(或透過明確方法)以便追蹤與測試
  • 可用 Pinia 插件加入日誌紀錄、快照等輔助工具

六、給 Vue 開發者的建議

建議 1:組合式 API(Composition API)更適合 AI 產品

Options API(選項式 API)適合傳統 CRUD,但 AI 產品的複雜互動用組合式 API 更靈活:

// Composition API 可以輕鬆組合多個邏輯
const { isConnected, send } = useWebSocket(...)
const { isStreaming, startStream } = useChatStream(...)
const { cachedHtml, parse } = useMarkdownCache(...)

建議 2:TypeScript 是必須的

AI 產品的資料結構複雜,TypeScript 能避免很多錯誤:

interface Message {
  id: string
  role: 'user' | 'assistant' | 'system'
  content: string
  timestamp: number
  metadata?: {
    tokenUsage?: number
    model?: string
  }
}

建議 3:不要過度優化

  • 簡單場景用 ref 就夠了
  • 虛擬滾動在訊息超過 100 條時再考慮
  • 先保證功能正確,再有目標地優化效能

七、總結

從傳統 Vue 開發到 AI 產品前端,我最大的收穫是:

技術層面:

  • Vue3 的組合式 API 非常適合 AI 產品的複雜互動
  • shallowRef + triggerRef 是流式回應的最佳搭檔
  • 虛擬滾動是長列表的必備技能

架構層面:

  • Pinia 管理全域狀態,Composable 管理元件邏輯
  • WebSocket/SSE 連線一定要封裝並自動清理
  • TypeScript 型別定義要盡早做

心態層面:

  • AI 前端開發 = 傳統前端 + 流式處理 + 狀態管理
  • 不要怕踩坑,每個坑都是學習機會
  • 保持學習,AI 技術迭代很快

互動話題

  1. 你在 Vue + AI 開發中遇到過哪些坑?
  2. 對於流式回應,你有什麼優化方案?
  3. 作為 Vue 開發者,你覺得 AI 產品最難的是什么?

歡迎在評論區交流!👇


參考資料:

作者: [你的暱稱] GitHub: [你的 GitHub 連結] 公眾號/知乎: [你的帳號]


原文出處:https://juejin.cn/post/7618492703295340598


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝16   💬10   ❤️2
460
🥈
我愛JS
📝2   💬9   ❤️2
83
🥉
💬1  
4
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付