即时通讯轻松实现:WebSocket、Vue 3 和 Node.js 缔造的多人实时交流平台

前言

主题: 本文基于Vue3、Websocket、Nodejs实现多人即时通讯

内容:本文分为三个部分:首先是介绍及使用,然后是代码实现,最后总结及注意事项

目的:对websocket的实践😎

周末去四姑娘山的两日游梦想破灭了(天气+旅游社团行程安排满了),没办法只能含泪狂省几百大洋。

因为最近在写vitepress搭建的博客文档,,就偷个懒,在这上面演示了。样式略丑,忽略哈!!!

外面热,大哥们里边请👻👻👻!!

WebSocket介绍

  • WebSocket 是一种在单个 TCP 连接上提供全双工通信的网络协议,它允许客户端和服务器之间进行实时的双向通信。相比于传统的 HTTP 请求-响应模式,WebSocket 在实时性和效率方面提供了更好的性能

特点和优势

  • 全双工: WebSocket 允许客户端和服务器在同一个连接上同时发送和接收数据,实现实时的双向通信。这与传统的 HTTP 请求-响应模式不同,HTTP 请求通常需要等待服务器的响应。
  • 低延迟: 由于 WebSocket 建立了一次连接后可以持续保持,不需要频繁地进行连接和关闭,因此具有较低的延迟,适用于实时性要求较高的应用场景。
  • 节省资源: 与轮询或长轮询相比,WebSocket 通过在单个连接上实现多次请求和响应,节省了网络带宽和服务器资源。
  • 跨域支持: WebSocket 具备跨域通信的能力,允许从不同域名下的客户端与服务器进行通信。

项目架构

  1. 后端部分主要用到了 ws express http
  2. 前端部分 使用了vue3+antdv 来搭建

整体实现思路

  1. 前端部分:
    • UI: 弹窗、按钮、房间页面
    • 消息模块: 连接、监听、接收、发送、展示
    • 房间:创建、加入、离开
  1. 后端部分:
    • 创建服务器
    • 监听连接、断开、创建房间
    • 建立房间与成员的映射关系
    • 数据(这里没有使用数据库进行存储)

一、页面搭建

1.先安装一下 antdv

csharp 复制代码
yarn add ant-design-vue

2.创建、加入房间按钮、及弹窗

两个按钮,一个创建房间、一个加入房间;然后提交时校验+接口校验

3.房间页面、离开房间、消息发送接收及展示

进行websocket连接

ini 复制代码
const connectWebSocket = () => {
  const socketUrl = 'ws://localhost:3000';
  socket = new WebSocket(socketUrl);
  socket.onopen = () => {
    Message.success('连接接成功!!!')
    connected.value = true;
    sendMessage(true)
  };
};

关闭连接

ini 复制代码
  socket.onclose = () => {
    Message.error('关闭连接!!!')
    connected.value = false;
    socket = null;
  };

退出房间

ini 复制代码
const exit = (state = {} ) => {
  if (socket) {
    socket.close();
    const type = 'leave'
  }
}

发送消息

ini 复制代码
const sendMessage = (type = '') => {
  if (!socket) return;
  const state = {...props.state}
  const messageObj = {
    ...state,
    text: message.value,
    id: Date.now(),

  };
  socket.send(JSON.stringify(messageObj));
};

接收消息

ini 复制代码
socket.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  const { name, roomId } = props.state
  users.value = msg.users || 0
  if (msg?.code == 200 && msg.roomId == roomId) {
    receivedMessages.value.push(msg);
  } else {
    if (msg.name == name && roomId == msg.roomId ) {
      Message.error(msg.text)
      exit()
    }
  }
};

对消息进行渲染

xml 复制代码
<template>
  <div class="chat-container">
    <Button @click="exit(state)">退出房间</Button>
    <p class="tc title">{{connected ? `房间号:${state.roomId}`: '加入房间失败'}}</p>
    <p>当前房间人数:{{ users }}</p>
    <div class="message">
      <div class="item" :class="item.name==state.name? '':'item1'" v-for="item in receivedMessages" :key="item.id">
        <p class="msg">
          {{ item.text }}
        </p>
        <div class="user">
          <div class="time">{{ new Date(item.id).toLocaleTimeString() }}</div>
          <div class="user">{{ item.name }}</div>
        </div>
      </div>
    </div>
    <div v-if="connected">
      <textarea maxlength="100"  class="message-input" v-model="message" placeholder="输入消息..." />
      <div class="submit" @click="sendMessage">发送</div>
    </div>
  </div>
</template>

完整的前端代码

组件入口

xml 复制代码
// Chat.vue 整组件
<template>
    <CreateChat v-if="!state.roomId" @changeRoom="changeRoom"></CreateChat>
    <ChatRoom v-else :state="state" @changeRoom="changeRoom"></ChatRoom>
</template>

<script lang="ts" setup>
import { reactive, watch } from 'vue'
import ChatRoom from './ChatRoom.vue'
import CreateChat  from './CreateChat.vue'

const state = reactive({
    name: '',
    roomId: 0,
    type: ''
})
const changeRoom = (newInfo = {}) => {
    // roomId.value =  num
    console.log(newInfo, 'asdsadsadasd')
    state.name = newInfo.name
    state.roomId = newInfo.roomId
    state.type = newInfo.type
}
</script>

房间组件

xml 复制代码
// ChatRoom.vue 房间组件
<template>
  <div class="chat-container">
    <Button @click="exit(state)">退出房间</Button>
    <p class="tc title">{{connected ? `房间号:${state.roomId}`: '加入房间失败'}}</p>
    <p>当前房间人数:{{ users }}</p>
    <div class="message">
      <div class="item" :class="item.name==state.name? '':'item1'" v-for="item in receivedMessages" :key="item.id">
        <p class="msg">
          {{ item.text }}
        </p>
        <div class="user">
          <div class="time">{{ new Date(item.id).toLocaleTimeString() }}</div>
          <div class="user">{{ item.name }}</div>
        </div>
      </div>
    </div>
    <div v-if="connected">
      <textarea maxlength="100"  class="message-input" v-model="message" placeholder="输入消息..." />
      <div class="submit" @click="sendMessage">发送</div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { message as Message, Button } from 'ant-design-vue'
const props = defineProps(['state'])
const emit = defineEmits(['changeRoom'])
const connected = ref(false);
const message = ref('');
const users = ref(0)
const receivedMessages = ref([]);
let socket = null;

const connectWebSocket = () => {
  const socketUrl = 'ws://localhost:3000'; // Replace with your WebSocket server URL
  socket = new WebSocket(socketUrl);

  socket.onopen = () => {
    connected.value = true;
    sendMessage(true)
  };

  socket.onmessage = (event) => {
    const msg = JSON.parse(event.data);
    const { name, roomId } = props.state
    users.value = msg.users || 0
    if (msg?.code == 200 && msg.roomId == roomId) {
      receivedMessages.value.push(msg);
    } else {
      if (msg.name == name && roomId == msg.roomId ) {
        Message.error(msg.text)
        exit()
      }
    }
  };

  socket.onclose = () => {
    connected.value = false;
    socket = null;
  };
};
let flag = false
const sendMessage = (type = '') => {
  if (!socket) return;
  const state = {...props.state}
  const messageObj = {
    ...state,
    text: message.value,
    id: Date.now(),
    
  };
  if (!flag) {
    // 首次进入
    flag =true
  } else {
    messageObj.type = ''
    type && (messageObj.type = type)
    
  }

  socket.send(JSON.stringify(messageObj));
  message.value = '';
};

const exit = (state = {} ) => {
  if (socket) {
    const type = 'leave'
    state.name && sendMessage(type)
    socket.close();
    emit('changeRoom', {})
  }
}

onMounted(() => {
  connectWebSocket();

});

onBeforeUnmount(() => {
  exit()
});
</script>


<style scoped lang="less">
/* 样式可以根据您的需要进行自定义 */
.chat-container {
  max-width: 45vw;
  margin: 0 auto;
  padding: 1vw;
  height: 100%;
  .title {
    font-size: 20px;
    font-weight: 900;
  }
}

.message-input {
  width: 100%;
  display: block;
  min-height: 15vh;
  padding: 10px;
  border: 1px solid #ccc;
  border-radius: 5px;
  margin-top: 1vw;
}

.message {
  overflow-y: auto;
  margin-top: 2vh;
  padding: 10px;
  border: 1px solid #ccc;
  border-radius: 1vh;
}

.item {
  margin-bottom: 10px;
  padding: 5px 10px;

  display: flex;
  justify-content: flex-end;
  align-items: center;
  .msg {
    color: #fff;
    padding: 1vw;
    margin-right: 1vh;
    max-width: 60vw;
    height: 100%;
    overflow: hidden;
    background-color: #5d46da;
    border: 1px solid #eee;
    border-radius: 2vh;
    order: 1;
  }
  .user {
    display: flex;
    justify-content: flex-end;
    align-items: end;
    flex-direction: column;
    order: 2;
    
  }
}
.item1 {
  justify-content: flex-start;
  .user {
    order: 2;
    align-items: flex-start;
  }
  .msg {
    order: 3;
    margin-left: 1vh;
    margin-right: 0;
    background-color: #7dad6a;

  }
}

.submit {
  padding: 10px;
  background-color: #3e3cd4;
  width: 15vh;
  display: flex;
  align-items: center;
  justify-content: center;
  margin: auto;
  margin-top: 2vh;
  font-weight: 700;
  font-size: 16px;
  color: #fff;
  border-radius: 1vh;
  &:hover {
    background-color: #1410eb;
  }
}
.tc {
  text-align: center;
}
.time {
  font-size: 12px;
}
</style>

弹窗组件

xml 复制代码
// CreateChat.vue
<template>
  <Button type="primary" @click="newRoom('create')">{{ title.create }}</Button>
  <Button @click="newRoom('join')">{{ title.join }}</Button>

  <Modal v-model:open="state.roomShow" :title="title[state.type]" @ok="handleOk">
    <input
      type="number"
      v-model.trim="state.room"
      @keyup.enter="handleOk"
      placeholder="请输入4位数字房间号"
    />
    <input
      style="margin-top: 2vh; display: block;"
      v-model.trim="state.name"
      @keyup.enter="handleOk"
      placeholder="请输入姓名"
    />
  </Modal>
</template>
<script lang="ts" setup>
type DataType = {
  newMessage: string;
  room: number | '';
  roomShow: boolean;
  messages: {
    id: Date;
    text: string;
    name: string;
  }[];
  socket: any;
  name: string;
  type: string
};

import "ant-design-vue/dist/reset.css";
import { Button, Input, message, Modal } from "ant-design-vue";
import { reactive } from "vue";

const emit = defineEmits(['changeRoom'])
const state: DataType = reactive({
  newMessage: "",
  roomShow: false,
  room: '',
  name: '',
  messages: [],
  socket: null,
  type: ''
});
const title = {
  create: '创建房间',
  join: '加入房间'
}

const newRoom = (type) => {
  state.roomShow = true;
  state.type = type
};

const handleOk = () => {
  const reg = /^\d{4}$/
  if (!state.room || !state.name) {
    message.error('请输入正确的房间号和名字')
    return
  }
  if (!reg.test(String(state.room))) {
    message.error('请输入正确4位数字的房间号')
    return
  }
  state.roomShow = false;
  emit('changeRoom', {name: state.name, roomId: state.room, type: state.type})
}
  
</script>
  
<style scoped lang="less">
@import "ant-design-vue/dist/reset.css";
.chat-room {
  max-width: 500px;
  margin: 0 auto;
  padding: 20px;
  border: 1px solid #ccc;
  border-radius: 5px;
  box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.1);
}

.message-list {
  max-height: 300px;
  overflow-y: auto;
  border: 1px solid #ccc;
  border-radius: 5px;
  padding: 10px;
}

.message {
  margin-bottom: 10px;
  padding: 5px 10px;
  border: 1px solid #eee;
  border-radius: 5px;
  background-color: #f8f8f8;
}

.username {
  font-weight: bold;
  color: #007bff;
}

input {
  width: 100%;
  padding: 10px;
  border: 1px solid #ccc;
  border-radius: 5px;
}
</style>

二、Node部分的实现

新建server.js

ini 复制代码
const WebSocket = require("ws");
const http = require("http");
const express = require("express");
const app = express();

const roomMap = new Map()
// 创建 HTTP 服务器
const server = http.createServer(app);
// 创建 WebSocket 服务器
const wss = new WebSocket.Server({ server });
// 监听 WebSocket 连接
wss.on("connection", (socket) => {
  console.log("WebSocket 连接已建立");
  // 监听客户端发送的消息
  socket.on("message", (message) => {
    message = message.toString()
    const msg =  JSON.parse(message)
    msg.code = 200
    const { type, roomId, name, id } = msg ||{}
    const room = roomMap.get(roomId)
    if (type == 'create') {
      if (!room) {
        const roomInfo = {
          roomId, 
          createUser: name, 
          createTime: id, 
          serverTime: new Date().now,
          userList: [{name, jionTime: +new Date()}]
        }
        roomMap.set(roomId, roomInfo)
        msg.text = '您已加入房间!!!'
      } else {
        // 房间号已存在
        msg.text = '房间号已存在'
        msg.code = 5001
      }
    } else if(type == 'join') {
      // 加入房间
      if (!room) {
        msg.code = 5004
        msg.text = '房间不存在'
      } else {
        let hasUser = false
        if (Array.isArray(room.userList) && room.userList.length) {
          hasUser = room.userList.some(item =>item.name == name)
        }
        if (hasUser) {
          msg.text = '用户名已存在'
          msg.code = 5002
        } else {
          room.userList.push({name, jionTime: +new Date()})
          msg.text = name + '已进入房间'
        }
      }
    } else if (type=='leave') {
      if (Array.isArray(room.userList) && room.userList.length) {
        const index = room.userList.findIndex(item =>item.name === name)
        console.log(index)
        index !=-1 && room.userList.splice(index, 1)
        msg.text = name + '离开了房间'
        if (roomMap.get(roomId)?.userList?.length == 0) {
          roomMap.delete(roomId)
        }
      }
    }
    msg.users = roomMap.get(roomId)?.userList?.length || 0 
    // 广播消息给所有连接的客户端
    wss.clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify(msg));
      }
    });
  });
});
wss.on('error', socket => {
  console.log('报错了')
})
// 启动服务器
const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
  console.log(`服务器正在运行,端口:${PORT}`);
});

总结及避坑:

  • websocket的连接、断开、消息广播
  • 房间如果没有成员 应该销毁
  • 离开房间、 新建房间 加入房间 应该实时跟新
  • 消息实时广播出去

避坑:写开发时可以多写打印,当然生产代码尽量不要有打印,开发阶段的打印有助于联调和排查问题(在写加入房间的逻辑就排查了好久!!!)

注意:server.js 需要用node跑起来!!!

源码位置

xiaoyi1255

相关推荐
FungLeo4 小时前
成为全栈·Node 后端篇·全文搜索:从 LIKE 到全文索引
node.js·全文索引·模糊搜索·全文搜索·成为全栈·like 搜索
mldong6 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排7 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60618 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角9 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!9 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰9 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi10 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen11 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马12 小时前
AI源码分析:拆解模型底层实现逻辑
前端