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 事件。

相关推荐
甄心爱学习2 小时前
【项目实训(个人4)】
前端·vue.js·python
轮子大叔2 小时前
HTML入门
前端·html
skilllite作者2 小时前
SkillLite 技术演进笔记:Workspace、沙箱与进化
java·开发语言·前端·笔记·安全·agentskills
qq_419854052 小时前
clip-path绘制倾斜角裁剪的矩形占比条;基于svg实现仪表盘弧线占比图。
前端·javascript·vue.js
m0_738120722 小时前
渗透基础知识ctfshow——Web应用安全与防护(完结:第八章)
前端·python·sql·安全·web安全·网络安全
克里斯蒂亚诺更新2 小时前
uniapp适配H5和Android-apk实现获取当前位置经纬度并调用接口
android·前端·uni-app
宁&沉沦2 小时前
前端开发专用的 Cursor 四大模式「快捷切换 + 指令模板」,直接复制就能用,覆盖 90% 日常场景
前端·编辑器
Cloud Traveler2 小时前
用Calibre-Web把NAS上的电子书管起来:部署、配置与远程访问实战
前端
神明不懂浪漫2 小时前
【第一章】HTML(一)——HTML简述及常用标签
前端·javascript·css·html·css3