即时通讯轻松实现: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

相关推荐
雯0609~17 分钟前
网页F12:缓存的使用(设值、取值、删除)
前端·缓存
℘团子এ20 分钟前
vue3中如何上传文件到腾讯云的桶(cosbrowser)
前端·javascript·腾讯云
学习前端的小z26 分钟前
【前端】深入理解 JavaScript 逻辑运算符的优先级与短路求值机制
开发语言·前端·javascript
星星会笑滴29 分钟前
vue+node+Express+xlsx+emements-plus实现导入excel,并且将数据保存到数据库
vue.js·excel·express
彭世瑜1 小时前
ts: TypeScript跳过检查/忽略类型检查
前端·javascript·typescript
FØund4041 小时前
antd form.setFieldsValue问题总结
前端·react.js·typescript·html
Backstroke fish1 小时前
Token刷新机制
前端·javascript·vue.js·typescript·vue
小五Five1 小时前
TypeScript项目中Axios的封装
开发语言·前端·javascript
小曲程序1 小时前
vue3 封装request请求
java·前端·typescript·vue
临枫5411 小时前
Nuxt3封装网络请求 useFetch & $fetch
前端·javascript·vue.js·typescript