Vue 3 + Socket.io 实时聊天项目完整开发文档

Vue 3 + Socket.io 实时聊天项目完整开发文档

接上篇文档 别再用原生的 Socket 了!Nest.js 让你的实时聊天系统开发效率翻倍,这篇将展示前端界面及功能

完整项目地址:v-chat-pro

先看目标:

  • 完成注册、登录、通讯录、好友申请、私聊会话、未读数徽章。
  • 与后端 chat-server 接口与事件完全对齐。
    注册及登录展示:

添加好友:

效果展示:


多用户效果


前言

本文档旨在提供和后端文档同等粒度的前端开发步骤。我们将从环境搭建开始,逐步实现认证、会话、通讯录与实时聊天。前端采用 Vue 3 + Vite + TypeScript + Vant + Pinia + Socket.io-client

前端项目目录:v-chat

后端项目目录:chat-server


第一阶段:环境搭建与基础配置

1.1 安装并启动项目

bash 复制代码
cd v-chat
pnpm install
pnpm dev

默认访问:http://localhost:5173

1.2 配置后端地址

v-chat 下新建 .env.development

bash 复制代码
VITE_API_BASE_URL=http://localhost:3000

说明:HTTP 请求与 Socket 连接都会读取该变量。

1.3 配置入口文件

文件路径src/main.ts

ts 复制代码
import 'vant/lib/index.css'
import './assets/main.css'

import { createApp } from 'vue'
import { createPinia } from 'pinia'

import App from './App.vue'
import router from './router'

const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

第二阶段:请求层与 Socket 层封装

2.1 封装 Axios 请求实例

文件路径src/utils/request.ts

ts 复制代码
import axios from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { showFailToast } from 'vant'

const service: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000',
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json'
  }
})

service.interceptors.response.use(
  (response: AxiosResponse) => {
    const { code, message, data } = response.data
    if (code && code >= 400) {
      showFailToast(message || '业务逻辑错误')
      return Promise.reject(new Error(message || 'Error'))
    }
    return data
  },
  (error) => {
    const message = error.response?.data?.message || error.message || '网络请求错误'
    showFailToast(message)
    return Promise.reject(error)
  }
)

const request = <T = any>(config: AxiosRequestConfig): Promise<T> => {
  return service.request(config)
}

export default request

2.2 封装 Socket 单例

文件路径src/utils/socket.ts

ts 复制代码
import { io, Socket } from 'socket.io-client'
import { ref } from 'vue'

const socket = ref<Socket | null>(null)
let currentUserId: number | null = null
const eventListeners = new Map<string, Set<(data: any) => void>>()

export function useSocket() {
  const connect = (userId: number) => {
    if (socket.value?.connected && currentUserId === userId) return

    if (socket.value) {
      socket.value.disconnect()
    }

    currentUserId = userId
    const apiBase = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'
    const newSocket = io(apiBase, {
      query: { userId }
    })

    eventListeners.forEach((callbacks, event) => {
      callbacks.forEach((callback) => {
        newSocket.on(event, callback)
      })
    })

    socket.value = newSocket
  }

  const emit = (event: string, data: any) => {
    socket.value?.emit(event, data)
  }

  const on = (event: string, callback: (data: any) => void) => {
    if (!eventListeners.has(event)) {
      eventListeners.set(event, new Set())
    }
    const callbacks = eventListeners.get(event)!
    if (!callbacks.has(callback)) {
      callbacks.add(callback)
      socket.value?.on(event, callback)
    }
  }

  const off = (event: string, callback: (data: any) => void) => {
    const callbacks = eventListeners.get(event)
    if (callbacks) {
      callbacks.delete(callback)
      socket.value?.off(event, callback)
    }
  }

  return { socket, connect, emit, on, off }
}

第三阶段:状态管理与路由鉴权

3.1 配置用户全局状态

文件路径src/stores/user.ts

ts 复制代码
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useUserStore = defineStore('user', () => {
  const userInfo = ref<{ id: number; username: string } | null>(null)
  const token = ref(localStorage.getItem('token') || '')
  const pendingFriendCount = ref(0)
  const sessions = ref<any[]>([])

  const totalUnreadCount = computed(() => {
    return sessions.value.reduce((sum, s) => sum + (s.unreadCount || 0), 0)
  })

  const isLoggedIn = computed(() => !!userInfo.value?.id)

  function setUser(user: { id: number; username: string }) {
    userInfo.value = user
    localStorage.setItem('currentUser', JSON.stringify(user))
  }

  function setToken(val: string) {
    token.value = val
    localStorage.setItem('token', val)
  }

  function initFromStorage() {
    const saved = localStorage.getItem('currentUser')
    if (saved) userInfo.value = JSON.parse(saved)
  }

  function logout() {
    userInfo.value = null
    token.value = ''
    pendingFriendCount.value = 0
    localStorage.removeItem('currentUser')
    localStorage.removeItem('token')
  }

  return {
    userInfo, token, pendingFriendCount, sessions, totalUnreadCount,
    isLoggedIn, setUser, setToken, initFromStorage, logout
  }
})

3.2 配置路由与守卫

文件路径src/router/index.ts

ts 复制代码
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    { path: '/', name: 'index', component: () => import('@/views/IndexView.vue'), meta: { requiresAuth: true } },
    { path: '/auth/login', name: 'login', component: () => import('@/views/auth/LoginView.vue') },
    { path: '/auth/register', name: 'register', component: () => import('@/views/auth/RegisterView.vue') },
    { path: '/contact', name: 'contact', component: () => import('@/views/contact/ContactView.vue'), meta: { requiresAuth: true } },
    { path: '/contact/search', name: 'search', component: () => import('@/views/contact/SearchView.vue'), meta: { requiresAuth: true } },
    { path: '/contact/pending', name: 'pending', component: () => import('@/views/contact/PendingRequestsView.vue'), meta: { requiresAuth: true } },
    { path: '/chat/:id', name: 'chat', component: () => import('@/views/chat/ChatView.vue'), meta: { requiresAuth: true } },
    { path: '/me', name: 'me', component: () => import('@/views/me/MeView.vue'), meta: { requiresAuth: true } },
  ]
})

router.beforeEach((to) => {
  const userStore = useUserStore()
  userStore.initFromStorage()

  if (to.meta.requiresAuth && !userStore.isLoggedIn) {
    return { name: 'login' }
  }

  if (userStore.isLoggedIn && (to.name === 'login' || to.name === 'register')) {
    return { name: 'index' }
  }
})

export default router

第四阶段:接口模块封装

4.1 用户接口

文件路径src/api/user.ts

ts 复制代码
import request from '@/utils/request'

export const registerApi = (data: any) => request({ url: '/user/register', method: 'post', data })
export const loginApi = (data: any) => request({ url: '/auth/login', method: 'post', data })
export const getUserInfoApi = (id: number) => request({ url: '/user/info', method: 'get', params: { id } })
export const searchUserApi = (username: string) => request({ url: '/user/search', method: 'get', params: { username } })

4.2 好友接口

文件路径src/api/friend.ts

ts 复制代码
import request from '@/utils/request'

export const sendFriendRequestApi = (data: { requesterId: number; addresseeId: number }) =>
  request({ url: '/user/friend/request', method: 'post', data })

export const acceptFriendRequestApi = (data: { userId: number; requestId: number }) =>
  request({ url: '/user/friend/accept', method: 'post', data })

export const rejectFriendRequestApi = (data: { userId: number; requestId: number }) =>
  request({ url: '/user/friend/reject', method: 'post', data })

export const getFriendListApi = (userId: number) =>
  request({ url: '/user/friend/list', method: 'get', params: { userId } })

export const getPendingRequestsApi = (userId: number) =>
  request({ url: '/user/friend/pending', method: 'get', params: { userId } })

4.3 聊天接口

文件路径src/api/chat.ts

ts 复制代码
import request from '@/utils/request'

export const getSessionsApi = (userId: number) =>
  request({ url: '/chat/sessions', method: 'get', params: { userId } })

export const getChatHistoryApi = (user1Id: number, user2Id: number) =>
  request({ url: '/chat/history', method: 'get', params: { user1Id, user2Id } })

export const markAsReadApi = (userId: number, friendId: number) =>
  request({ url: '/chat/read', method: 'post', data: { userId, friendId } })

第五阶段:全局监听与页面实现

5.1 全局监听入口(App.vue)

文件路径src/App.vue

核心职责:

  1. 恢复登录态后建立连接。
  2. 监听 friendRequest/friendAccepted/friendRejected/message
  3. 在非当前聊天页收到消息时刷新会话并提醒。

5.2 登录页

文件路径src/views/auth/LoginView.vue

核心逻辑:

ts 复制代码
const res = await loginApi({ username: username.value, password: password.value })
userStore.setUser({ id: res.user.id, username: res.user.username })
userStore.setToken(res.access_token)
connect(res.user.id)
router.replace('/')

5.3 注册页

文件路径src/views/auth/RegisterView.vue

核心逻辑:

ts 复制代码
if (password.value !== confirmPassword.value) return
await registerApi({ username: username.value, password: password.value })
router.push('/auth/login')

5.4 首页会话页

文件路径src/views/IndexView.vue

核心职责:

  1. 展示 /chat/sessions 返回的会话数据。
  2. 显示会话未读数。
  3. TabBar 显示总未读和待处理申请红点。

5.5 通讯录页

文件路径src/views/contact/ContactView.vue

核心职责:

  1. 展示好友列表。
  2. 进入"新的朋友"页处理申请。
  3. 点击好友跳转聊天页。

5.6 搜索好友页

文件路径src/views/contact/SearchView.vue

核心逻辑:

ts 复制代码
const res = await searchUserApi(keyword.value)
searchResult.value = res || []
await sendFriendRequestApi({ requesterId: userStore.user.id, addresseeId: targetId })

5.7 新的朋友页

文件路径src/views/contact/PendingRequestsView.vue

核心逻辑:

ts 复制代码
await acceptFriendRequestApi({ userId: userStore.user.id, requestId })
emit('sendMessage', {
  senderId: userStore.user.id,
  receiverId: requesterId,
  content: '我通过了你的朋友验证请求,现在我们可以开始聊天了'
})

5.8 聊天页

文件路径src/views/chat/ChatView.vue

核心职责:

  1. 拉历史消息。
  2. 发送 sendMessage 事件。
  3. 监听 message 并仅渲染当前会话数据。
  4. 进入页面立即 markAsRead

核心代码:

ts 复制代码
await getChatHistoryApi(userStore.user.id, targetId)
emit('sendMessage', { senderId: userStore.user.id, receiverId: targetId, content })
await markAsReadApi(userStore.user.id, targetId)
userStore.fetchSessions()

第六阶段:联调测试步骤

  1. 启动后端:
bash 复制代码
cd chat-server
pnpm install
pnpm run start:dev
  1. 启动前端:
bash 复制代码
cd v-chat
pnpm install
pnpm dev
  1. 准备两个账号 A/B,分别登录。
  2. A 搜索 B 并发送好友申请。
  3. B 在"新的朋友"点击通过。
  4. 验证:
    • A 收到通过提示。
    • 会话出现首条打招呼消息。
    • 双方实时收发正常。
    • 非聊天页收消息时会话红点增加。
    • 进入聊天页后红点清零。

第七阶段:常见问题排查

7.1 登录后收不到消息

排查:

  1. 登录成功后是否调用 connect(userId)
  2. query.userId 是否传给后端。
  3. 后端网关是否记录用户连接日志。

7.2 红点不准

排查:

  1. /chat/sessionsunreadCount 是否正确。
  2. 收到新消息和标记已读后是否调用 fetchSessions
  3. 总未读是否来自 computed,而不是手工加减。

7.3 好友通过后发不出消息

排查:

  1. 好友关系是否已 accepted
  2. senderId/receiverId 是否传错。
  3. 是否监听后端返回的 error 事件。

相关推荐
修己xj14 分钟前
告别手动存图!这款叫 Fatkun 的浏览器插件,简直是素材收集神器
前端
袋鼠云数栈1 小时前
从前端到基础设施,ACOS 如何打通企业全链路可观测
运维·前端·人工智能·数据治理·数据智能
AskHarries1 小时前
系统提示词、开发者指令和用户输入的优先级
java·前端·数据库
Moment1 小时前
长上下文会最终杀死 Rag 吗?
前端·javascript·后端
qcx232 小时前
【系统学AI】25 论文导读 ①:两篇改变 AI 的开山之作——Attention Is All You Need & ReAct
前端·人工智能·react.js·transformer
kyriewen3 小时前
大文件上传最全指南:分片、断点续传、秒传,一篇就够了
前端·javascript·面试
我叫黑大帅3 小时前
解决聊天页内部滚轮改为页面滚动问题
javascript·后端·面试
郑洁文3 小时前
基于Python的Web命令执行漏洞自动化检测系统
前端·python·网络安全·自动化
新酱爱学习4 小时前
手搓 10 个 Skill 后,我把重复劳动收敛成了一套零依赖 CLI 工具
前端·javascript·人工智能
罗超驿4 小时前
13.JavaScript 新手入门指南:语法、变量、流程控制全解析
开发语言·javascript