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 安装并启动项目
cdv-chatpnpminstallpnpmdev默认访问:http://localhost:5173
1.2 配置后端地址
在v-chat下新建.env.development:
VITE_API_BASE_URL=http://localhost:3000说明:HTTP 请求与 Socket 连接都会读取该变量。
1.3 配置入口文件
文件路径:src/main.ts
import'vant/lib/index.css'import'./assets/main.css'import{createApp}from'vue'import{createPinia}from'pinia'importAppfrom'./App.vue'importrouterfrom'./router'constapp=createApp(App)app.use(createPinia())app.use(router)app.mount('#app')第二阶段:请求层与 Socket 层封装
2.1 封装 Axios 请求实例
文件路径:src/utils/request.ts
importaxiosfrom'axios'importtype{AxiosInstance,AxiosRequestConfig,AxiosResponse}from'axios'import{showFailToast}from'vant'constservice: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.dataif(code&&code>=400){showFailToast(message||'业务逻辑错误')returnPromise.reject(newError(message||'Error'))}returndata},(error)=>{constmessage=error.response?.data?.message||error.message||'网络请求错误'showFailToast(message)returnPromise.reject(error)})constrequest=<T=any>(config:AxiosRequestConfig):Promise<T>=>{returnservice.request(config)}exportdefaultrequest2.2 封装 Socket 单例
文件路径:src/utils/socket.ts
import{io,Socket}from'socket.io-client'import{ref}from'vue'constsocket=ref<Socket|null>(null)letcurrentUserId:number|null=nullconsteventListeners=newMap<string,Set<(data:any)=>void>>()exportfunctionuseSocket(){constconnect=(userId:number)=>{if(socket.value?.connected&¤tUserId===userId)returnif(socket.value){socket.value.disconnect()}currentUserId=userIdconstapiBase=import.meta.env.VITE_API_BASE_URL||'http://localhost:3000'constnewSocket=io(apiBase,{query:{userId}})eventListeners.forEach((callbacks,event)=>{callbacks.forEach((callback)=>{newSocket.on(event,callback)})})socket.value=newSocket}constemit=(event:string,data:any)=>{socket.value?.emit(event,data)}conston=(event:string,callback:(data:any)=>void)=>{if(!eventListeners.has(event)){eventListeners.set(event,newSet())}constcallbacks=eventListeners.get(event)!if(!callbacks.has(callback)){callbacks.add(callback)socket.value?.on(event,callback)}}constoff=(event:string,callback:(data:any)=>void)=>{constcallbacks=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
import{defineStore}from'pinia'import{ref,computed}from'vue'exportconstuseUserStore=defineStore('user',()=>{constuserInfo=ref<{id:number;username:string}|null>(null)consttoken=ref(localStorage.getItem('token')||'')constpendingFriendCount=ref(0)constsessions=ref<any[]>([])consttotalUnreadCount=computed(()=>{returnsessions.value.reduce((sum,s)=>sum+(s.unreadCount||0),0)})constisLoggedIn=computed(()=>!!userInfo.value?.id)functionsetUser(user:{id:number;username:string}){userInfo.value=user localStorage.setItem('currentUser',JSON.stringify(user))}functionsetToken(val:string){token.value=val localStorage.setItem('token',val)}functioninitFromStorage(){constsaved=localStorage.getItem('currentUser')if(saved)userInfo.value=JSON.parse(saved)}functionlogout(){userInfo.value=nulltoken.value=''pendingFriendCount.value=0localStorage.removeItem('currentUser')localStorage.removeItem('token')}return{userInfo,token,pendingFriendCount,sessions,totalUnreadCount,isLoggedIn,setUser,setToken,initFromStorage,logout}})3.2 配置路由与守卫
文件路径:src/router/index.ts
import{createRouter,createWebHistory}from'vue-router'import{useUserStore}from'@/stores/user'constrouter=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)=>{constuserStore=useUserStore()userStore.initFromStorage()if(to.meta.requiresAuth&&!userStore.isLoggedIn){return{name:'login'}}if(userStore.isLoggedIn&&(to.name==='login'||to.name==='register')){return{name:'index'}}})exportdefaultrouter第四阶段:接口模块封装
4.1 用户接口
文件路径:src/api/user.ts
importrequestfrom'@/utils/request'exportconstregisterApi=(data:any)=>request({url:'/user/register',method:'post',data})exportconstloginApi=(data:any)=>request({url:'/auth/login',method:'post',data})exportconstgetUserInfoApi=(id:number)=>request({url:'/user/info',method:'get',params:{id}})exportconstsearchUserApi=(username:string)=>request({url:'/user/search',method:'get',params:{username}})4.2 好友接口
文件路径:src/api/friend.ts
importrequestfrom'@/utils/request'exportconstsendFriendRequestApi=(data:{requesterId:number;addresseeId:number})=>request({url:'/user/friend/request',method:'post',data})exportconstacceptFriendRequestApi=(data:{userId:number;requestId:number})=>request({url:'/user/friend/accept',method:'post',data})exportconstrejectFriendRequestApi=(data:{userId:number;requestId:number})=>request({url:'/user/friend/reject',method:'post',data})exportconstgetFriendListApi=(userId:number)=>request({url:'/user/friend/list',method:'get',params:{userId}})exportconstgetPendingRequestsApi=(userId:number)=>request({url:'/user/friend/pending',method:'get',params:{userId}})4.3 聊天接口
文件路径:src/api/chat.ts
importrequestfrom'@/utils/request'exportconstgetSessionsApi=(userId:number)=>request({url:'/chat/sessions',method:'get',params:{userId}})exportconstgetChatHistoryApi=(user1Id:number,user2Id:number)=>request({url:'/chat/history',method:'get',params:{user1Id,user2Id}})exportconstmarkAsReadApi=(userId:number,friendId:number)=>request({url:'/chat/read',method:'post',data:{userId,friendId}})第五阶段:全局监听与页面实现
5.1 全局监听入口(App.vue)
文件路径:src/App.vue
核心职责:
- 恢复登录态后建立连接。
- 监听
friendRequest/friendAccepted/friendRejected/message。 - 在非当前聊天页收到消息时刷新会话并提醒。
5.2 登录页
文件路径:src/views/auth/LoginView.vue
核心逻辑:
constres=awaitloginApi({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
核心逻辑:
if(password.value!==confirmPassword.value)returnawaitregisterApi({username:username.value,password:password.value})router.push('/auth/login')5.4 首页会话页
文件路径:src/views/IndexView.vue
核心职责:
- 展示
/chat/sessions返回的会话数据。 - 显示会话未读数。
- TabBar 显示总未读和待处理申请红点。
5.5 通讯录页
文件路径:src/views/contact/ContactView.vue
核心职责:
- 展示好友列表。
- 进入“新的朋友”页处理申请。
- 点击好友跳转聊天页。
5.6 搜索好友页
文件路径:src/views/contact/SearchView.vue
核心逻辑:
constres=awaitsearchUserApi(keyword.value)searchResult.value=res||[]awaitsendFriendRequestApi({requesterId:userStore.user.id,addresseeId:targetId})5.7 新的朋友页
文件路径:src/views/contact/PendingRequestsView.vue
核心逻辑:
awaitacceptFriendRequestApi({userId:userStore.user.id,requestId})emit('sendMessage',{senderId:userStore.user.id,receiverId:requesterId,content:'我通过了你的朋友验证请求,现在我们可以开始聊天了'})5.8 聊天页
文件路径:src/views/chat/ChatView.vue
核心职责:
- 拉历史消息。
- 发送
sendMessage事件。 - 监听
message并仅渲染当前会话数据。 - 进入页面立即
markAsRead。
核心代码:
awaitgetChatHistoryApi(userStore.user.id,targetId)emit('sendMessage',{senderId:userStore.user.id,receiverId:targetId,content})awaitmarkAsReadApi(userStore.user.id,targetId)userStore.fetchSessions()第六阶段:联调测试步骤
- 启动后端:
cdchat-serverpnpminstallpnpmrun start:dev- 启动前端:
cdv-chatpnpminstallpnpmdev- 准备两个账号 A/B,分别登录。
- A 搜索 B 并发送好友申请。
- B 在“新的朋友”点击通过。
- 验证:
- A 收到通过提示。
- 会话出现首条打招呼消息。
- 双方实时收发正常。
- 非聊天页收消息时会话红点增加。
- 进入聊天页后红点清零。
第七阶段:常见问题排查
7.1 登录后收不到消息
排查:
- 登录成功后是否调用
connect(userId)。 query.userId是否传给后端。- 后端网关是否记录用户连接日志。
7.2 红点不准
排查:
/chat/sessions的unreadCount是否正确。- 收到新消息和标记已读后是否调用
fetchSessions。 - 总未读是否来自
computed,而不是手工加减。
7.3 好友通过后发不出消息
排查:
- 好友关系是否已
accepted。 senderId/receiverId是否传错。- 是否监听后端返回的
error事件。
