最近在做一个智能客服项目,从零开始用 Vue3 搭了一套问答聊天系统。传统那种表单提交式的客服,用户等得着急,客服那边状态也容易乱,体验确实不太好。这次就想试试用现代前端技术栈,搞一个实时、流畅还有点“智能”的聊天界面。整个过程下来,对 Vue3 在复杂交互场景下的应用有了更深的理解,也踩了不少坑,这里把核心的架构设计和实现思路梳理一下,希望能给有类似需求的同学一些参考。

智能客服系统界面示意

1. 为什么选择 Vue3?聊聊技术选型

项目启动前,在 Vue3 和 React 之间纠结了一阵子。两者都能很好地完成这个任务,但侧重点不同。

对于实时聊天这种强交互、状态变化频繁的场景,Vue3 的响应式系统(特别是 refreactive)用起来非常顺手。声明一个响应式对象来管理聊天消息列表,任何增删改查都能自动触发视图更新,心智负担小。而 React 的 useStateuseEffect 需要更精细的控制,在管理 WebSocket 消息流这种异步、连续的状态更新时,代码结构可能会更复杂一些。

另一个重要因素是 TypeScript 支持。Vue3 对 TypeScript 的支持是“一等公民”级别的,从组件的 propsemitsComposition API 的返回值,都能获得非常完善的类型推断和提示。这对于构建一个需要集成后端 NLP 服务、数据结构相对复杂的聊天系统来说,能极大提升开发效率和代码可靠性,减少运行时错误。

综合来看,Vue3 的响应式直观性、优秀的 TS 集成以及相对平缓的学习曲线(对于团队中原有 Vue2 成员),让它成为了这个项目的首选。

2. 核心架构:状态管理与实时通信

聊天系统的核心是状态和通信。我使用 Pinia 来集中管理全局状态,用 WebSocket 来建立实时双向通信通道。

2.1 使用 Pinia 管理聊天状态

我创建了一个名为 useChatStore 的 Store,用来管理所有与聊天相关的状态和逻辑。

// stores/chat.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Message, ChatSession } from '@/types/chat'

export const useChatStore = defineStore('chat', () => {
  // 状态
  const currentSessionId = ref<string>('')
  const sessions = ref<Map<string, ChatSession>>(new Map())
  const messageList = ref<Message[]>([])
  const connectionStatus = ref<'connecting' | 'connected' | 'disconnected'>('disconnected')

  // Getter
  const currentSession = computed(() => sessions.value.get(currentSessionId.value))
  const unreadCount = computed(() => messageList.value.filter(msg => !msg.isRead).length)

  // Action
  function addMessage (message: Message) {
    messageList.value.push(message)
    // 如果消息列表过长,可以在这里触发虚拟滚动计算
  }

  function switchSession (sessionId: string) {
    const session = sessions.value.get(sessionId)
    if (session) {
      currentSessionId.value = sessionId
      messageList.value = session.messages
    }
  }

  function clearCurrentSession () {
    const session = currentSession.value
    if (session) {
      session.messages = []
      messageList.value = []
    }
  }

  return {
    // 状态
    currentSessionId,
    messageList,
    connectionStatus,
    // Getter
    currentSession,
    unreadCount,
    // Action
    addMessage,
    switchSession,
    clearCurrentSession
  }
})

这样设计的好处是,所有组件都通过这个 Store 来获取和修改聊天数据,状态流向清晰,避免了 props 的深层传递或混乱的事件总线。

2.2 稳健的 WebSocket 连接管理

实时聊天的生命线是 WebSocket 连接。我把它封装成了一个可复用的 useWebSocket Composition 函数,重点处理了连接、重连和生命周期。

// composables/useWebSocket.ts
import { ref, onUnmounted } from 'vue'
import { useChatStore } from '@/stores/chat'

interface UseWebSocketOptions {
  url: string
  maxReconnectAttempts?: number
  reconnectInterval?: number
}

export function useWebSocket(options: UseWebSocketOptions) {
  const { url, maxReconnectAttempts = 5, reconnectInterval = 3000 } = options
  const socket = ref<WebSocket | null>(null)
  const reconnectCount = ref(0)
  const isManualClose = ref(false)
  const chatStore = useChatStore()

  function connect() {
    if (socket.value?.readyState === WebSocket.OPEN) {
      return
    }

    try {
      socket.value = new WebSocket(url)
      chatStore.connectionStatus = 'connecting'

      socket.value.onopen = () => {
        console.log('WebSocket connected')
        chatStore.connectionStatus = 'connected'
        reconnectCount.value = 0 // 连接成功,重置重连计数
      }

      socket.value.onmessage = (event) => {
        try {
          const data = JSON.parse(event.data) as Message
          chatStore.addMessage(data) // 收到消息,存入状态库
        } catch (error) {
          console.error('Failed to parse message:', error)
        }
      }

      socket.value.onerror = (error) => {
        console.error('WebSocket error:', error)
        chatStore.connectionStatus = 'disconnected'
      }

      socket.value.onclose = (event) => {
        console.log(`WebSocket closed: ${event.code} - ${event.reason}`)
        chatStore.connectionStatus = 'disconnected'

        // 非手动关闭且未超过重试次数,则尝试重连
        if (!isManualClose.value && reconnectCount.value < maxReconnectAttempts) {
          reconnectCount.value++
          console.log(`Reconnecting... (attempt ${reconnectCount.value})`)
          setTimeout(connect, reconnectInterval)
        }
      }
    } catch (error) {
      console.error('Failed to create WebSocket:', error)
    }
  }

  function sendMessage(content: string) {
    if (socket.value?.readyState === WebSocket.OPEN) {
      const message = {
        type: 'user',
        content,
        timestamp: Date.now()
      }
      socket.value.send(JSON.stringify(message))
    } else {
      console.warn('WebSocket is not open. Message not sent.')
      // 可以在这里将消息加入发送队列,待连接恢复后发送
    }
  }

  function disconnect() {
    isManualClose.value = true
    if (socket.value) {
      socket.value.close(1000, 'Manual closure')
      socket.value = null
    }
  }

  // 组件卸载时自动断开连接
  onUnmounted(() => {
    disconnect()
  })

  return {
    socket,
    connect,
    sendMessage,
    disconnect
  }
}

在组件中,可以这样使用:

<!-- components/ChatContainer.vue -->
<script setup lang="ts">
import { onMounted } from 'vue'
import { useWebSocket } from '@/composables/useWebSocket'

const { connect, sendMessage, disconnect } = useWebSocket({
  url: 'wss://your-chat-server.com/ws'
})

onMounted(() => {
  connect()
})

// 发送消息示例
function handleSend(text: string) {
  sendMessage(text)
}
</script>

3. 性能优化:消息列表与虚拟滚动

当聊天记录越来越多时,渲染所有消息气泡会导致页面卡顿。虚拟滚动是解决这个问题的标准方案。我使用了 vue-virtual-scroller 这个库。

首先,安装依赖:npm install vue-virtual-scroller

然后,在聊天消息列表组件中应用:

<!-- components/MessageList.vue -->
<template>
  <RecycleScroller
    class="message-scroller"
    :items="messageList"
    :item-size="80" <!-- 预估的每条消息高度 -->
    key-field="id"
    v-slot="{ item: message }"
  >
    <MessageBubble :message="message" />
  </RecycleScroller>
</template>

<script setup lang="ts">
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import MessageBubble from './MessageBubble.vue'
import { useChatStore } from '@/stores/chat'
import { computed } from 'vue'

const chatStore = useChatStore()
const messageList = computed(() => chatStore.messageList)
</script>

<style scoped>
.message-scroller {
  height: 500px; /* 容器需要固定高度 */
  overflow-y: auto;
}
</style>

性能对比: 在本地测试中,渲染 1000 条简单文本消息:

  • 无虚拟滚动:初始渲染耗时 ~1200ms,滚动时明显卡顿,内存占用较高。
  • 启用虚拟滚动:初始渲染耗时 ~150ms(仅渲染可视区域内的十几条),滚动极其流畅,内存占用稳定。

虚拟滚动通过只渲染可视区域内的元素,大幅提升了长列表的性能。

性能优化示意图

4. 集成“智能”:调用 NLP 服务

客服的“智能”体现在回答能力上。我接入了阿里云的智能语音交互服务(当然,你也可以用其他家的,比如百度UNIT、腾讯云智聆等)。关键是要做好 API 的封装和错误处理。

我创建了一个服务层文件来封装所有与后端 AI 服务的通信:

// services/nlpService.ts
import axios, { type AxiosInstance, type AxiosResponse, type AxiosError } from 'axios'
import { ElMessage } from 'element-plus' // 使用了 Element Plus 的提示组件

// 定义请求和响应类型
interface NLPRequest {
  query: string
  sessionId: string
  userId?: string
}

interface NLPResponse {
  code: number
  message: string
  data: {
    answer: string
    confidence: number
    intent?: string
    entities?: Array<{ type: string; value: string }>
  }
}

class NLPService {
  private httpClient: AxiosInstance
  private baseURL: string

  constructor(baseURL: string = import.meta.env.VITE_NLP_API_BASE) {
    this.baseURL = baseURL
    this.httpClient = axios.create({
      baseURL: this.baseURL,
      timeout: 10000, // 10秒超时
      headers: {
        'Content-Type': 'application/json',
        // 这里可以添加认证头,例如从 store 中获取的 token
        // 'Authorization': `Bearer ${token}`
      }
    })

    // 响应拦截器,统一处理错误
    this.httpClient.interceptors.response.use(
      (response: AxiosResponse<NLPResponse>) => {
        // 业务逻辑错误处理(例如,后端定义的 code 不为 0)
        if (response.data.code !== 0) {
          ElMessage.error(`NLP服务错误: ${response.data.message}`)
          throw new Error(response.data.message)
        }
        return response
      },
      (error: AxiosError) => {
        // 网络或服务器错误处理
        let errorMessage = '请求NLP服务失败'
        if (error.response) {
          // 服务器返回了错误状态码
          errorMessage = `服务器错误: ${error.response.status}`
        } else if (error.request) {
          // 请求发出但没有收到响应
          errorMessage = '网络错误,请检查连接'
        }
        ElMessage.error(errorMessage)
        return Promise.reject(error)
      }
    )
  }

  async getAnswer(params: NLPRequest): Promise<NLPResponse['data']> {
    try {
      // 阿里云服务可能需要特定的参数格式,这里只是示例
      const response = await this.httpClient.post<NLPResponse>('/chat', params)
      return response.data.data
    } catch (error) {
      // 这里可以记录日志或执行降级策略,例如返回一个默认回答
      console.error('NLP query failed:', error)
      // 降级策略:返回一个友好的默认回答
      return {
        answer: '抱歉,我现在有点忙,请稍后再试或联系人工客服。',
        confidence: 0,
        intent: 'fallback'
      }
    }
  }
}

// 导出一个单例实例
export const nlpService = new NLPService()

在 Vue 组件中调用:

<script setup lang="ts">
import { nlpService } from '@/services/nlpService'
import { useChatStore } from '@/stores/chat'

const chatStore = useChatStore()

async function handleUserInput(query: string) {
  // 1. 先将用户消息添加到本地列表
  chatStore.addMessage({
    id: Date.now().toString(),
    type: 'user',
    content: query,
    timestamp: Date.now()
  })

  // 2. 调用 NLP 服务
  try {
    const response = await nlpService.getAnswer({
      query,
      sessionId: chatStore.currentSessionId
    })

    // 3. 将 AI 回复添加到本地列表
    chatStore.addMessage({
      id: (Date.now() + 1).toString(),
      type: 'assistant',
      content: response.answer,
      timestamp: Date.now(),
      meta: { confidence: response.confidence, intent: response.intent }
    })
  } catch (error) {
    // 错误已在 service 层统一处理,这里可以补充一些 UI 状态更新
    console.error('处理用户输入失败:', error)
  }
}
</script>

5. 避坑指南与优化思考

5.1 跨端兼容性 我们的客服系统可能需要嵌入到 H5、小程序或桌面端。WebSocket 在主流浏览器中支持良好,但在一些特殊环境(如某些企业内网防火墙限制)下可能无法建立连接。为此,我准备了一个降级方案:当 WebSocket 连接失败超过一定次数后,自动切换到长轮询(Long Polling)模式。

// 在 useWebSocket 的 connect 函数失败后,可以触发降级逻辑
function fallbackToLongPolling() {
  console.log('降级到长轮询模式')
  // 实现一个 setInterval 定期拉取消息的逻辑
  // 同时,发送消息改为普通的 HTTP POST 请求
}

5.2 大流量下的消息削峰 如果客服同时服务大量用户,消息可能瞬间涌入。为了避免前端处理不过来或频繁渲染导致卡顿,我引入了简单的消息队列。

// utils/messageQueue.ts
class MessageQueue {
  private queue: any[] = []
  private isProcessing = false
  private readonly processInterval: number

  constructor(processInterval: number = 100) { // 每100ms处理一次
    this.processInterval = processInterval
  }

  enqueue(message: any) {
    this.queue.push(message)
    if (!this.isProcessing) {
      this.startProcessing()
    }
  }

  private startProcessing() {
    this.isProcessing = true
    const process = () => {
      if (this.queue.length > 0) {
        const message = this.queue.shift()
        // 这里是实际处理消息的地方,例如调用 chatStore.addMessage
        // handleMessage(message)
        setTimeout(process, this.processInterval) // 控制处理频率
      } else {
        this.isProcessing = false
      }
    }
    setTimeout(process, this.processInterval)
  }
}

export const messageQueue = new MessageQueue()

在 WebSocket 的 onmessage 回调中,不直接操作状态,而是先入队:

socket.value.onmessage = (event) => {
  // ... 解析 data
  messageQueue.enqueue(data) // 削峰,平滑处理
}

6. 延伸思考:GraphQL 与消息订阅

目前我们用的是 RESTful API + WebSocket 的组合。RESTful 用于获取历史记录、用户信息等,WebSocket 用于实时对话。这已经很高效了。

但我在想,如果系统更复杂,比如需要根据消息类型(文本、图片、订单状态变更)实时订阅不同的数据流,GraphQL 的订阅(Subscription)功能可能会让架构更清晰。用 Apollo Client 或 urql 这类 GraphQL 客户端,可以统一用 GraphQL 查询语言来管理所有的数据获取和实时订阅,后端用一套 Schema 定义,前端的数据需求也更声明式。这对于未来功能扩展(比如增加客服坐席状态看板、实时数据分析)可能是一个更优雅的方向。有兴趣的同学可以尝试用 @vue/apollo-composable 这个库来集成 GraphQL 订阅,替换掉原生的 WebSocket 管理部分。

写在最后

从零搭建这个 Vue3 智能客服聊天项目,让我对现代前端框架处理复杂实时应用的能力有了新的认识。Composition API 让逻辑关注点分离和复用变得非常自然,Pinia 提供了清晰的状态管理,再加上 TypeScript 的保驾护航,整个开发过程虽然遇到不少挑战,但代码结构和可维护性都很好。

特别是性能优化和异常处理部分,是线上稳定运行的关键,需要提前考虑。希望这篇笔记里提到的架构设计、代码片段和踩坑经验,能帮你更快地搭建起自己的聊天应用。前端技术迭代很快,但解决问题的思路是相通的——理解需求、选择合适的技术、设计清晰的架构、编写健壮的代码。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐