Vue3 + AI Agent 前端开发实战:一个 前端开发工程师的转型记录

Vue3 + AI Agent 前端开发实战:一个 前端开发工程师的转型记录

6年 前端开发经验,1 年 AI 产品实战。从 Vue2 到 Vue3,从传统 Web 到 AI Agent,本文记录了我作为 Vue 前端工程师在 AI 产品开发中的技术选型、核心难点、解决方案,以及那些踩过的坑。

前言

我是一名"老 Vue"------从 2019 年开始用 Vue2,经历过 Options API 到 Composition API 的变迁,参与过多个大型 Vue 项目的架构设计。

2025 年,公司决定做 AI 产品线,我主动请缨负责 AI Agent 的前端开发。

刚开始我信心满满:"Vue 我都玩得这么熟了,加个 AI 功能能有多难?"

结果第一个 sprint 就给了我当头一棒:

  • 流式响应和 Vue 的响应式系统怎么配合?
  • 对话状态用 Pinia 还是用 Composition API?
  • 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 AI Agent 前端
请求 - 响应模式 流式响应
状态变化可预测 AI 回复不确定
内容结构清晰 多模态内容(Markdown、代码、公式)
对话轮数有限 长对话性能优化
网络中断可重试 需要离线可用

1.3 最终技术栈

arduino 复制代码
Vue 3.4 + Vite 5 + Pinia + TypeScript
流式通信:SSE + WebSocket
Markdown 渲染:markdown-it + 自定义组件
虚拟滚动:vue-virtual-scroller
状态管理:Pinia + Composition API
本地存储:IndexedDB (idb-keyval)

二、核心难点与 Vue 解决方案

2.1 难点一:流式响应与 Vue 响应式配合

问题: AI 的流式响应是增量更新的,而 Vue 的响应式系统适合整体更新。初期代码:

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 + 手动触发更新

vue 复制代码
<!-- ✅ 正确做法 -->
<script setup lang="ts">
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 手动触发更新,控制渲染时机

性能对比:

方案 100 条消息 500 条消息
普通 ref 80ms 450ms
shallowRef + triggerRef 15ms 50ms

2.2 难点二:对话状态管理(Pinia vs Composable)

问题: 对话相关的状态很多:

  • 消息列表
  • 加载状态
  • 当前 Agent
  • Token 使用量
  • 连接状态

初期我用 Pinia 管理所有状态:

typescript 复制代码
// ❌ 问题: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 混合方案

typescript 复制代码
// ✅ 用 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
  }
}
typescript 复制代码
// ✅ 用 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
    }
  }
})
vue 复制代码
<!-- ✅ 组件中使用 -->
<script setup lang="ts">
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)

初期方案:

vue 复制代码
<!-- ❌ 问题:每次渲染都重新解析 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" />
</template>

问题:

  • 长文本解析慢
  • 代码块没有高亮
  • 没有 Vue 组件集成

最终方案:自定义 Markdown 组件

vue 复制代码
<!-- components/AIMarkdown.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import markdownit from 'markdown-it'
import hljs from 'highlight.js'
import DOMPurify from 'dompurify'

// 自定义代码块渲染
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(require('markdown-it-katex'))

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" />
</template>

<style scoped>
.ai-markdown {
  :deep(pre) {
    background: #1e1e1e;
    padding: 16px;
    border-radius: 8px;
    overflow-x: auto;
  }
  
  :deep(code) {
    font-family: 'JetBrains Mono', monospace;
    font-size: 14px;
  }
  
  :deep(table) {
    border-collapse: collapse;
    width: 100%;
  }
  
  :deep(th), :deep(td) {
    border: 1px solid #ddd;
    padding: 8px;
  }
}
</style>

性能优化:缓存解析结果

typescript 复制代码
// composables/useMarkdownCache.ts
import { ref, watch } from 'vue'
import { LRUCache } from 'lru-cache'

// LRU 缓存,最多存 100 个解析结果
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 条后,列表明显卡顿。

初期方案:

vue 复制代码
<!-- ❌ 问题:所有消息都渲染 -->
<template>
  <div class="message-list">
    <MessageItem
      v-for="msg in messages"
      :key="msg.id"
      :message="msg"
    />
  </div>
</template>

性能测试:

消息数量 渲染时间 FPS
50 条 25ms 60
200 条 150ms 30
500 条 500ms 15

解决方案:vue-virtual-scroller

vue 复制代码
<!-- ✅ 只渲染可见区域 -->
<template>
  <RecycleScroller
    class="message-list"
    :items="messages"
    :item-size="100"
    key-field="id"
  >
    <template #default="{ item }">
      <MessageItem :message="item" />
    </template>
  </RecycleScroller>
</template>

<script setup lang="ts">
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>

动态高度支持:

vue 复制代码
<!-- 如果消息高度不固定 -->
<template>
  <DynamicScroller
    :items="messages"
    :min-item-size="50"
    key-field="id"
  >
    <template #default="{ item, index, active }">
      <DynamicScrollerItem
        :item="item"
        :active="active"
        :size-dependencies="[item.content]"
      >
        <MessageItem :message="item" />
      </DynamicScrollerItem>
    </template>
  </DynamicScroller>
</template>

<script setup lang="ts">
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'
</script>

性能提升:

指标 优化前 优化后 提升
500 条渲染 500ms 30ms 16 倍
滚动 FPS 15fps 60fps 流畅
内存占用 300MB 50MB 6 倍

2.5 难点五:WebSocket 连接管理

问题: 在 Vue 组件中直接使用 WebSocket,容易忘记清理:

typescript 复制代码
// ❌ 问题:组件销毁后连接还在
const ws = new WebSocket('ws://localhost:8080')
ws.onmessage = (event) => {
  // 处理消息
}

解决方案:用 Composable 封装

typescript 复制代码
// 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
  }
}

组件中使用:

vue 复制代码
<script setup lang="ts">
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 组件结构

bash 复制代码
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 核心组件代码

vue 复制代码
<!-- components/chat/ChatContainer.vue -->
<script setup lang="ts">
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 v-if="error" :message="error.message" />

    <!-- 输入框 -->
    <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 渲染时间减少 70%
列表渲染 vue-virtual-scroller 500 条消息 60fps
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 混用

问题:

typescript 复制代码
const messages = ref<Message[]>([]) // 深度监听
const temp = shallowRef<Message[]>([]) // 浅监听

// 混用导致响应式行为不一致

教训:

  • 数组/对象用 shallowRef
  • 基本类型用 ref
  • 统一团队规范

坑二:Composable 中忘记 onUnmounted

问题:

typescript 复制代码
// 忘记清理,导致内存泄漏
export function useWebSocket() {
  const ws = new WebSocket(url)
  // 没有 onUnmounted 清理
}

教训:

  • 所有外部资源都要在 onUnmounted 中清理
  • 用 ESLint 规则强制检查

坑三:Pinia Store 中直接修改 state

问题:

typescript 复制代码
// 绕过 actions 直接修改,无法追踪
chatStore.messages.push(newMessage)

教训:

  • 所有状态修改通过 actions
  • 用 Pinia 插件添加调试日志

六、给 Vue 开发者的建议

建议 1:Composition API 更适合 AI 产品

Options API 适合传统 CRUD,但 AI 产品的复杂交互用 Composition API 更灵活:

typescript 复制代码
// Composition API 可以轻松组合多个逻辑
const { isConnected, send } = useWebSocket(...)
const { isStreaming, startStream } = useChatStream(...)
const { cachedHtml, parse } = useMarkdownCache(...)

建议 2:TypeScript 是必须的

AI 产品的数据结构复杂,TypeScript 能避免很多错误:

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

建议 3:不要过度优化

  • 简单场景用 ref 就够了
  • 虚拟滚动在消息超过 100 条时再考虑
  • 先保证功能正确,再优化性能

七、总结

从传统 Vue 开发到 AI 产品前端,我最大的收获是:

技术层面:

  • Vue3 的 Composition API 非常适合 AI 产品的复杂交互
  • shallowRef + triggerRef 是流式响应的最佳搭档
  • 虚拟滚动是长列表的必备技能

架构层面:

  • Pinia 管理全局状态,Composable 管理组件逻辑
  • WebSocket/SSE 连接一定要封装,自动清理
  • TypeScript 类型定义要尽早做

心态层面:

  • AI 前端开发 = 传统前端 + 流式处理 + 状态管理
  • 不要怕踩坑,每个坑都是学习机会
  • 保持学习,AI 技术迭代很快

互动话题

  1. 你在 Vue + AI 开发中遇到过哪些坑?
  2. 对于流式响应,你有什么优化方案?
  3. 作为 Vue 开发者,你觉得 AI 产品最难的是什么?

欢迎在评论区交流!👇


参考资料:

作者: [你的昵称] GitHub: [你的 GitHub 链接] 公众号/知乎: [你的账号]

如果本文对你有帮助,欢迎点赞、收藏、转发!

相关推荐
miss1 小时前
AI Agent 前端开发:一个初级工程师的踩坑成长之路
前端
清水寺小和尚1 小时前
如何用400行代码构建OpenClaw
前端
锦木烁光1 小时前
Flowable 实战:从架构解耦到多状态动态查询的高性能重构方案
前端·后端
子淼8122 小时前
HTML入门指南:构建网页的基石
前端·html
农夫山泉不太甜2 小时前
Electron离屏渲染技术详
前端
深念Y2 小时前
Chrome MCP Server 配置失败全记录:一场历时数小时的“fetch failed”排查之旅
前端·自动化测试·chrome·http·ai·agent·mcp
一个有故事的男同学2 小时前
从零打造专业级前端 SDK (四):错误监控与生产发布
前端
2601_948606182 小时前
从 jQuery → V/R → Lit:前端架构的 15 年轮回
前端·架构·jquery
wuhen_n2 小时前
Vite 核心原理:ESM 带来的开发时“瞬移”体验
前端·javascript·vue.js