uniapp项目实践总结(十五)使用websocket实现简易聊天室

导语:在一些社交软件中,经常可以看到各种聊天室的界面,接下来就总结一下聊天室的原理个实现方法,最后做一个简易的聊天室,包括登录/登出、加入/离开房间、发送接收聊天消息等功能。

目录

  • 准备工作
  • 原理分析
  • 组件实现
  • 实战演练
  • 服务端搭建
  • 案例展示

准备工作

  • pages/index文件夹下面新建一个名叫chat的组件;
  • 按照前一篇所说的页面结构,编写好预定的聊天页面;

原理分析

前端部分

此聊天室前端方面使用了 uniapp 提供的几个 API 实现包括:

  • uni.connectSocket:连接到 websocket 服务器;
  • SocketTask.onOpen:监听服务端连接打开;
  • SocketTask.onClose:监听服务端连接关闭;
  • SocketTask.onError:监听服务端连接错误;
  • SocketTask.onMessage:监听服务端的消息;
  • SocketTask.send:向服务端发送消息;
  • SocketTask.close:关闭服务端连接;

后端部分

此聊天室服务端使用 npm 库ws搭建,另外头像上传部分使用原生node实现,待会儿会详细介绍实现方法。

组件实现

准备工作和原理分析完成后,接下来写一个简单的页面,下面主要是展示主要的内容。

模板部分

  • 登录部分

包括输入用户名和上传头像的页面和功能。

html 复制代码
<view class="chat-login" v-if="!wsInfo.isLogin">
  <view class="chat-login-item">
    <input
      v-model="userInfo.name"
      class="chat-login-ipt"
      type="text"
      :maxlength="10"
      placeholder="请输入用户名" />
  </view>
  <view class="chat-login-item">
    <button class="chat-login-ipt" @click="uploadAvatar">上传头像</button>
  </view>
  <view class="chat-login-item">
    <button class="chat-login-btn" type="primary" @click="wsLogin">用户登录</button>
  </view>
</view>
  • 加入房间部分

包括选择房间的退出登录的页面和功能。

html 复制代码
<view class="chat-login" v-if="wsInfo.isLogin && !wsInfo.isJoin">
  <view class="chat-login-item">
    <picker mode="selector" :range="roomInfo.list" :value="roomInfo.id" @change="changeRoom">
      请选择房间号:{{roomInfo.name}}
    </picker>
  </view>
  <view class="chat-login-item">
    <button class="chat-login-btn" type="primary" @click="joinRoom">进入房间</button>
  </view>
  <view class="chat-login-item">
    <button type="warn" @click="wsLogout">退出登录</button>
  </view>
</view>
  • 聊天室界面

包括展示房间号,退出房间,在线用户列表,聊天消息区域以及输入聊天内容和发送消息的页面和功能。

html 复制代码
<view class="chat-room" v-if="wsInfo.isLogin && wsInfo.isJoin">
  <view class="chat-room-set">
    <text class="chat-room-name">房间{{ roomInfo.id }}</text>
    <button class="chat-room-logout" size="mini" type="warn" @click="leaveRoom">退出房间</button>
  </view>
  <view class="chat-room-users">
    在线人数({{userInfo.users.length}}人):{{ userInfo.usersText }}</view
  >
  <scroll-view
    :scroll-y="true"
    :scroll-top="roomInfo.scrollTop"
    @scroll="handlerScroll"
    class="chat-room-area">
    <view
      :class="{'chat-room-area-item': true, 'active': item.name == userInfo.name}"
      v-for="(item, index) in msg.list"
      :key="`msg${index+1}`">
      <view class="chat-room-user">
        <text
          v-if="roomInfo.mode == 'name'"
          :class="{'chat-room-username': true, 'active': item.name == userInfo.name}"
          >{{ item.name }}</text
        >
        <text v-if="roomInfo.mode == 'name'" class="chat-room-time"> ({{item.createTime}}): </text>
      </view>
      <image
        v-if="roomInfo.mode == 'avatar'"
        class="chat-room-avatar"
        :src="item.name == userInfo.name ? userInfo.avatar : item.avatar"></image>
      <view class="chat-room-content"> {{item.content}} </view>
    </view>
    <view id="chat-room-area-pos"></view>
  </scroll-view>
  <view class="chat-room-bot">
    <input
      class="chat-room-bot-ipt"
      type="text"
      placeholder="请输入内容"
      :maxlength="100"
      v-model="msg.current" />
    <button class="chat-room-bot-btn" size="mini" type="primary" @click="sendMsg">发送</button>
  </view>
</view>

样式部分

  • 登录和加入房间样式
scss 复制代码
.chat-login {
  .chat-login-item {
    margin-bottom: 20rpx;
    height: 80rpx;
    .chat-login-ipt,
    uni-picker {
      box-sizing: border-box;
      padding: 10rpx 30rpx;
      height: 100%;
      font-size: 26rpx;
      border: 1rpx solid $e;
      border-radius: 10rpx;
      color: var(--UI-BG-4);
    }
    uni-picker {
      display: flex;
      align-items: center;
    }
    .chat-login-btn {
      background: $mainColor;
    }
  }
}
  • 聊天房间页面样式
scss 复制代码
.chat-room {
  display: flex;
  flex-direction: column;
  height: 100%;
  .chat-room-set {
    display: flex;
    justify-content: space-between;
    align-items: center;
    width: 100%;
    font-size: 31rpx;
    font-weight: bold;
    .chat-room-name {
      padding: 10rpx;
    }
    .chat-room-logout {
      margin: 0;
    }
  }
  .chat-room-users {
    margin: 20rpx 0;
    padding: 20rpx 0;
    font-size: 28rpx;
    line-height: 1.5;
    border-bottom: 2rpx solid $e;
    word-break: break-all;
  }
  .chat-room-area {
    box-sizing: border-box;
    padding: 20rpx;
    height: calc(100% - 280rpx);
    background: $f8;
    .chat-room-area-item {
      display: flex;
      flex-direction: row;
      justify-content: flex-start;
      align-items: flex-start;
      margin-bottom: 20rpx;
      padding-bottom: 20rpx;
      .chat-room-user {
        .chat-room-username {
          color: $iptBorColor;
          font-size: 31rpx;
          line-height: 2;
          &.active {
            color: $mainColor;
          }
        }
      }
      .chat-room-avatar {
        width: 68rpx;
        height: 68rpx;
        background: $white;
        border-radius: 10rpx;
      }
      .chat-room-time {
        font-size: 26rpx;
        color: $iptBorColor;
      }
      .chat-room-content {
        box-sizing: border-box;
        margin-left: 12rpx;
        padding: 15rpx;
        max-width: calc(100% - 80rpx);
        text-align: left;
        font-size: 24rpx;
        color: $white;
        border-radius: 10rpx;
        word-break: break-all;
        background: $mainColor;
      }
      &.active {
        flex-direction: row-reverse;
        .chat-room-content {
          margin-left: 0;
          margin-right: 12rpx;
          text-align: right;
          color: $uni-text-color;
          background: $white;
        }
      }
    }
  }
  .chat-room-bot {
    position: fixed;
    bottom: 0;
    left: 0;
    display: flex;
    justify-content: space-between;
    align-items: center;
    box-sizing: border-box;
    padding: 0 30rpx;
    width: 100%;
    height: 100rpx;
    background: $white;
    border-top: 3rpx solid $f8;
    .chat-room-bot-ipt {
      flex: 1;
      box-sizing: border-box;
      padding: 0 30rpx;
      height: 70rpx;
      font-size: 24rpx;
      border: 2rpx solid $iptBorColor;
      border-radius: 10rpx;
    }
    .chat-room-bot-btn {
      margin-left: 50rpx;
      width: 150rpx;
      height: 70rpx;
      line-height: 70rpx;
      font-size: 26rpx;
      color: $white;
      background: $mainColor;
    }
  }
}

脚本部分

引入依赖包和属性设置

  • websocket 信息
js 复制代码
// ws
let wsInfo = reactive({
  ws: null, // ws对象
  alive: false, // 是否连接
  isLogin: false, // 是否登录
  isJoin: false, // 是否加入
  lock: false, // 锁住重连
  reconnectTimer: null, // 重连计时
  reconnectTime: 5000, // 重连计时间隔
  clientTimer: null, // 客户端计时
  clientTime: 10000, // 客户端计时间隔
  serverTimer: null, // 服务端计时
  serverTime: 30000, // 服务端计时间隔
});
  • 用户信息
js 复制代码
// 用户信息
const userInfo = reactive({
  id: "1", // 用户id
  name: "", // 用户名称
  avatar: "", // 用户头像
  users: [], // 用户在线列表
  usersText: "", // 用户在线列表文本
});
  • 房间信息
js 复制代码
// 房间信息
const roomInfo = reactive({
  id: 1, // 房间id
  name: "房间1", // 房间名称
  list: ["房间1", "房间2", "房间3"], // 房间列表
  mode: "avatar", // 模式:avatar头像
  scrollTop: 0, // 滚动到顶部距离
  scrollHei: 0, // 滚动高度
  goBottomTimer: null, // 到底部计时
  goBottomTime: 500, // 到顶部计时间隔
});
  • 聊天信息
js 复制代码
// 聊天信息
const msg = reactive({
  current: "", // 输入框内容
  list: [], // 聊天消息列表
});

实战演练

基本工作准备完毕后,接下来就开始实现功能。

连接 websocket

进入页面后,首先连接上 websocket 服务端。

  • 连接 websocket
js 复制代码
// 连接ws
function connectWs() {
  wsInfo.ws = null;
  wsInfo.ws = uni.connectSocket({
    url: proxy.$apis.urls.wsUrl,
    success() {
      wsInfo.alive = true;
      console.log("ws连接成功!");
    },
    fail() {
      wsInfo.alive = false;
      console.log("ws连接失败!");
    },
  });
  console.log("ws信息:", wsInfo.ws);
  // ws打开
  wsInfo.ws.onOpen((res) => {
    wsInfo.alive = true;
    // 开启心跳
    heartBeat();
    console.log("ws开启成功!");
  });
  // ws消息
  wsInfo.ws.onMessage((res) => {
    heartBeat();
    // 处理消息
    let data = JSON.parse(res.data);
    handlerMessage(data);
    console.log("ws接收消息:", data);
  });
  // ws关闭
  wsInfo.ws.onClose((res) => {
    wsInfo.alive = false;
    reConnect();
    console.log("ws连接关闭:", res);
  });
  // ws错误
  wsInfo.ws.onError((err) => {
    wsInfo.alive = false;
    reConnect();
    console.log("ws连接错误:", res);
  });
}
  • 处理各种类型的消息
js 复制代码
function handlerMessage(data) {
  let { code } = data;
  let { type } = data.data;
  // 用户登录
  if (type == "login") {
    wsInfo.isLogin = code === 200;
    uni.showToast({
      title: data.data.info,
      icon: code === 200 ? "success" : "error",
    });
  }
  // 退出登录
  if (code === 200 && type == "logout") {
    wsInfo.isLogin = false;
    userInfo.name = "";
    uni.showToast({
      title: "退出登录成功!",
      icon: "success",
    });
  }
  // 加入房间成功
  if (code === 200 && type == "join") {
    if (data.data.user.id == userInfo.id) {
      wsInfo.isJoin = true;
    }
    if (data.data.roomId == roomInfo.id) {
      let users = data.data.users,
        list = [];
      for (let item of users) {
        list.push(item.name);
      }
      userInfo.users = list;
      userInfo.usersText = list.join(",");
      if (data.data.user.id == userInfo.id) {
        msg.current = "";
        msg.list = [];
      }
    }
  }
  // 加入房间失败
  if (code === 101 && type == "join") {
    uni.showToast({
      title: data.data.info,
      icon: "error",
    });
  }
  // 离开房间
  if (code === 200 && type == "leave") {
    if (data.data.user.id == userInfo.id) {
      wsInfo.isJoin = false;
    }
    if (data.data.roomId == roomInfo.id) {
      let users = data.data.users,
        list = [];
      for (let item of users) {
        list.push(item.name);
      }
      userInfo.users = list;
      userInfo.usersText = list.join(",");
      if (data.data.user.id == userInfo.id) {
        msg.current = "";
        msg.list = [];
      }
    }
  }
  // 聊天内容
  if (code === 200 && type == "chat") {
    if (data.data.roomId == roomInfo.id) {
      let list = data.data.msg;
      msg.list = list;
      roomInfo.goBottomTimer = setTimeout(() => {
        goBottom();
      }, roomInfo.goBottomTime);
    }
  }
}
  • 心跳检测
js 复制代码
// 心跳检测
function heartBeat() {
  clearTimeout(wsInfo.clientTimer);
  clearTimeout(wsInfo.serverTimer);
  wsInfo.clientTimer = setTimeout(() => {
    if (wsInfo.ws) {
      let pong = {
        type: "ping",
      };
      wsInfo.ws.send({
        data: JSON.stringify(pong),
        fail() {
          wsInfo.serverTimer = setTimeout(() => {
            closeWs();
          }, wsInfo.serverTime);
        },
      });
    }
  }, wsInfo.clientTime);
}
  • 断线重连
js 复制代码
// 断线重连
function reConnect() {
  if (wsInfo.lock) return;
  wsInfo.lock = true;
  wsInfo.reconnectTimer = setTimeout(() => {
    connectWs();
    wsInfo.lock = false;
  }, wsInfo.reconnectTime);
}
  • 断开 websocket 连接
js 复制代码
// 断开连接
function closeWs() {
  if (!wsInfo.alive) {
    uni.showToast({
      title: "请先连接!",
      icon: "error",
    });
    return;
  }
  leaveRoom();
  wsLogout();
  wsInfo.ws.close();
}
  • 上传头像

这个就用到之前封装的文件操作方法。

js 复制代码
// 上传操作
async function uploadAvatarSet(filePath) {
  let opts = {
    url: proxy.$apis.urls.upload,
    filePath,
  };
  let data = await proxy.$http.upload(opts);
  if (data.code == 200) {
    let url = data.data.url;
    userInfo.avatar = url;
  } else {
    uni.showToast({
      title: data.data.info,
      icon: "error",
    });
  }
}
  • 用户登录
js 复制代码
// ws登录
function wsLogin() {
  if (!wsInfo.alive) {
    uni.showToast({
      title: "请先连接!",
      icon: "error",
    });
    return;
  }
  if (!userInfo.name) {
    uni.showToast({
      title: "请输入用户名!",
      icon: "error",
    });
    return;
  }
  if (!userInfo.avatar) {
    uni.showToast({
      title: "请上传头像!",
      icon: "error",
    });
    return;
  }
  let id = proxy.$apis.utils.uuid();
  userInfo.id = id;
  let { name, avatar } = userInfo;
  let authInfo = {
    type: "login",
    data: {
      id,
      name,
      avatar,
    },
  };
  wsInfo.ws.send({
    data: JSON.stringify(authInfo),
  });
}
  • 用户退出
js 复制代码
// ws退出
function wsLogout() {
  if (!wsInfo.alive) {
    uni.showToast({
      title: "请先连接!",
      icon: "error",
    });
    return;
  }

  let { id, name, avatar } = userInfo;
  let chatInfo = {
    type: "logout",
    data: {
      id,
      name,
      avatar,
    },
  };
  chatInfo.data.roomId = roomInfo.id;
  wsInfo.ws.send({
    data: JSON.stringify(chatInfo),
  });
}
  • 加入房间
js 复制代码
// ws加入房间
function joinRoom() {
  if (!wsInfo.alive) {
    uni.showToast({
      title: "请先连接!",
      icon: "error",
    });
    return;
  }
  if (!roomInfo.id) {
    uni.showToast({
      title: "请选择房间号!",
      icon: "error",
    });
    return;
  }

  let { id, name, avatar } = userInfo;
  let room = {
    type: "join",
    data: {
      id,
      name,
      avatar,
    },
  };
  room.data.roomId = roomInfo.id;
  wsInfo.ws.send({
    data: JSON.stringify(room),
  });
}
  • 离开房间
js 复制代码
// ws离开房间
function leaveRoom() {
  if (!wsInfo.alive) {
    uni.showToast({
      title: "请先连接!",
      icon: "error",
    });
    return;
  }
  let { id, name, avatar } = userInfo;
  let room = {
    type: "leave",
    data: {
      id,
      name,
      avatar,
    },
  };
  room.data.roomId = roomInfo.id;
  wsInfo.ws.send({
    data: JSON.stringify(room),
  });
}
  • 发送消息
js 复制代码
// 发送消息
function sendMsg() {
  if (!wsInfo.alive) {
    uni.showToast({
      title: "请先连接!",
      icon: "error",
    });
    return;
  }
  if (msg.current == "") {
    uni.showToast({
      title: "请输入内容!",
      icon: "error",
    });
    return;
  }
  let { id, name, avatar } = userInfo;
  let { current } = msg;
  let chatInfo = {
    type: "chat",
    data: {
      id,
      name,
      avatar,
      message: current,
    },
  };
  chatInfo.data.roomId = roomInfo.id;
  wsInfo.ws.send({
    data: JSON.stringify(chatInfo),
  });
  msg.current = "";
}

服务端搭建

上述聊天室需要用到静态文件服务和 ws 服务,下面讲解一下搭建过程。

静态文件服务搭建

这个就用原生的 node 知识搭建一下即可。

  • 准备文件夹和文件

    1.新建一个文件夹static,npm 初始化 npm init -y

    2.在static文件夹下面新建一个文件index.js

    3.在static文件夹下面新建一个文件夹public

    4.在public文件夹下面新建一个文件index.html

html 复制代码
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Node Static File Server</title>
  </head>
  <body>
    <h1>Node Static File Server</h1>
    <p>Welcome to Node Static File Server!</p>
  </body>
</html>
  • 引入依赖包
js 复制代码
const fs = require("fs");
const http = require("http");
const path = require("path");
const { argv } = require("process");
  • 定义域名和端口号

这里使用 script 命令传参来指定域名,在 APP 端不能使用本地 IP 地址,比如localhost127.0.0.1,所以要判断一下。

js 复制代码
// 域名地址
const dev = "127.0.0.1";
const pro = "192.168.1.88";
const base = argv[2] && argv[2] == "pro" ? pro : dev;
const port = 3000;
  • 定义 MINE 格式类型列表

这一部分是为了准确返回对应的文件格式。

js 复制代码
const types = {
  html: "text/html",
  css: "text/css",
  txt: "text/plain",
  png: "image/png",
  jpg: "image/jpeg",
  jpeg: "image/jpeg",
  gif: "image/gif",
  ico: "image/x-icon",
  js: "application/javascript",
  json: "application/json",
  xml: "application/xml",
  zip: "application/zip",
  rar: "application/x-rar-compressed",
  apk: "application/vnd.android.package-archive",
  ipa: "application/iphone",
  webm: "application/webm",
  mp4: "application/mp4",
  mp3: "application/mpeg",
  pdf: "application/pdf",
  docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
  doc: "application/msword",
  ppt: "application/vnd.ms-powerpoint",
  xls: "application/vnd.ms-excel",
};
  • 定义根目录

这一部分是为了定义根目录,所有的静态文件都在这个文件夹下面存放。

js 复制代码
const directoryName = "./public";
const root = path.normalize(path.resolve(directoryName));
  • 开启 HTTP 服务
js 复制代码
// http服务
const server = http.createServer((req, res) => {
  console.log(`${req.method} ${req.url}`);

  // 判断文件MINE类型
  const extension = path.extname(req.url).slice(1);
  const type = extension ? types[extension] : types.html;
  const supportedExtension = Boolean(type);

  if (!supportedExtension) {
    res.writeHead(404, { "Content-Type": "text/html" });
    res.end("404: File not found.");
    return;
  }
  let fileName = req.url;
  if (req.url === "/") {
    fileName = "index.html";
  } else if (!extension) {
    try {
      fs.accessSync(path.join(root, req.url + ".html"), fs.constants.F_OK);
      fileName = req.url + ".html";
    } catch (e) {
      fileName = path.join(req.url, "index.html");
    }
  }

  // 判断文件路径
  const filePath = path.join(root, fileName);
  const isPathUnderRoot = path.normalize(path.resolve(filePath)).startsWith(root);

  if (!isPathUnderRoot) {
    res.writeHead(404, { "Content-Type": "text/html" });
    res.end("404: File not found.");
    return;
  }

  //读取文件并返回
  fs.readFile(filePath, (err, data) => {
    if (err) {
      res.writeHead(404, { "Content-Type": "text/html" });
      res.end("404: File not found.");
    } else {
      res.writeHead(200, { "Content-Type": type });
      res.end(data);
    }
  });
});
  • 监听端口
js 复制代码
// 监听端口
server.listen(port, () => {
  console.log(`Server is listening on port http://${base}:${port} !`);
});

写好以后在package.json文件的scripts中,写入以下命令。

json 复制代码
{
  "scripts": {
    "dev": "node index.js dev",
    "pro": "node index.js pro",
    "test": "echo \"Error: no test specified\" && exit 1"
  }
}

运行一下npm run dev

cmd 复制代码
> [email protected] dev
> node index.js dev

Server is listening on port http://127.0.0.1:3000 !

上传文件服务实现

上传文件这里主要是使用multiparty来上传,md5来防止文件名重复上传,造成资源浪费。

  • 安装依赖包
shell 复制代码
npm i multiparty md5
  • 引入依赖包

依旧是在index.js文件里面写入,有些内容有省略,就不重复写了。

js 复制代码
// ...
const multiparty = require("multiparty");
const md5 = require("md5");
// ...
  • 设置响应头

这部分主要是为了解决跨域访问的问题。

js 复制代码
// 设置响应头
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader(
  "Access-Control-Allow-Headers",
  "Content-Type,Access-Control-Allow-Headers,Authorization,X-Requested-With"
);
res.setHeader("Access-Control-Expose-Headers", "Content-Disposition");
res.setHeader("Access-Control-Allow-Methods", "POST,GET,PUT,OPTIONS,DELETE");

if (req.method === "OPTIONS") {
  res.writeHead(200);
  res.end("ok");
  return;
}
  • 文件上传

这部分就是解析文件,并且返回文件信息数据。

其中的重命名文件是为了不浪费资源,存储相同的图片,统一使用 md5 来命名文件。

js 复制代码
// 文件上传
if (req.url === "/upload") {
  let formdata = new multiparty.Form({
    uploadDir: "./public/upload",
  });
  formdata.parse(req, (err, fields, files) => {
    if (err) {
      let data = {
        code: 102,
        msg: "get_fail",
        data: {
          info: "上传失败!",
        },
      };
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify(data));
    } else {
      let file = files.file[0];
      let fileName = file.path.slice(14);
      let ext = file.originalFilename.split(".");
      ext = ext[ext.length - 1];
      let md5File = md5(file.originalFilename);
      let oldPath = `./public/upload/${fileName}`,
        newPath = `./public/upload/${md5File}.${ext}`;
      let statRes = fs.statSync(file.path);
      let isFile = statRes.isFile();
      if (isFile) {
        fs.renameSync(oldPath, newPath);
        let data = {
          code: 200,
          msg: "get_succ",
          data: {
            url: `http://${base}:${port}/upload/${md5File}.${ext}`,
            filename: md5File,
            ext,
          },
        };
        res.end(JSON.stringify(data));
      } else {
        let data = {
          code: 102,
          msg: "get_fail",
          data: {
            info: "文件不存在!",
          },
        };
        res.writeHead(200, { "Content-Type": "application/json" });
        res.end(JSON.stringify(data));
      }
    }
  });
  return;
}

ws 服务器的搭建

  • 初始化服务

新建一个文件夹socket,初始化npm,安装依赖包。

shell 复制代码
mkdir socket
cd socket
npm init -y
npm i ws
  • 新建一个文件index.js

  • 引入依赖

js 复制代码
const webSocket = require("ws");
const { createServer } = require("http");
const { argv } = require("process");
  • 创建服务
js 复制代码
// http服务
const server = createServer((res) => {
  res.end("welcome to WS!");
});

// ws服务
const wss = new webSocket.WebSocketServer({
  server,
});
  • 初始化数据
js 复制代码
const domainDev = "127.0.0.1"; // 开发域名
const domainPro = "192.168.1.88"; // 生产域名
const domain = argv[2] && argv[2] == "pro" ? domainPro : domainDev;
const port = 3001; // 端口
const MAX_ROOM_NUM = 10; // 每个房间最大用户数
const MAX_USER_NUM = 30; // 用户登录总数
let users = [], // 用户列表
  rooms = [
    // 房间列表
    {
      id: 1, // 房间id
      name: "房间1", // 房间名称
      users: [], // 房间的用户列表
      messages: [], // 房间的消息列表
    },
    {
      id: 2,
      name: "房间2",
      users: [],
      messages: [],
    },
    {
      id: 3,
      name: "房间3",
      users: [],
      messages: [],
    },
  ];
  • 监听端口
js 复制代码
// 监听端口
server.listen(port, () => {
  console.log(`WebSocket is running on http://${domain}:${port} !`);
});
  • 监听 ws 连接
js 复制代码
// wss连接
wss.on("connection", (ws, req) => {
  // 获取ip
  const ip = req.socket.remoteAddress;
  console.log("ip:", ip);

  // 连接状态
  ws.isAlive = true;

  // 监听错误
  ws.on("error", (err) => {
    console.log("error:", err);
    ws.send(err);
  });

  // 监听消息
  ws.on("message", (data) => {
    let res = new TextDecoder().decode(data),
      info = JSON.parse(Object.assign(res)),
      type = info.type;
    info.ip = ip;
    switch (type) {
      case "ping":
        heartbeat(ws);
        break;
      case "login":
        userLogin(ws, info);
        break;
      case "logout":
        userLogout(ws, info);
        break;
      case "join":
        joinRoom(ws, info);
        break;
      case "leave":
        leaveRoom(ws, info);
        break;
      case "chat":
        sendMSg(ws, info);
        break;
      default:
        break;
    }
    console.log("message:", info);
    console.log("users:", users);
    console.log("rooms:", rooms);
  });

  // 断开连接
  ws.on("close", (data) => {
    console.log("close:", data);
  });
});
  • 心跳检测
js 复制代码
// 心跳检测
function heartbeat(ws) {
  let result = {
    code: 200,
    msg: "system",
    data: {
      info: "connected",
      type: "ping",
    },
  };
  returnMsg(ws, result);
}
  • 用户登录
js 复制代码
// 用户登录
function userLogin(ws, info) {
  let ip = info.ip;
  let { id, name, avatar } = info.data,
    result = null;
  if (users.length >= MAX_USER_NUM) {
    result = {
      code: 101,
      msg: "system",
      data: {
        info: "服务器用户已满!",
        type: info.type,
      },
    };
  } else if (users.length === 0) {
    users.push({
      id,
      name,
      avatar,
      ip,
      status: 1, // 1.在线 2.离线
      roomId: 1,
    });
    result = {
      code: 200,
      msg: "system",
      data: {
        info: "登录成功!",
        type: info.type,
      },
    };
  } else {
    let userInfo = users.find((s) => name === s.name);
    if (userInfo && userInfo.id) {
      let index = users.findIndex((s) => name == s.name);
      if (users[index].status === 2) {
        users[index].status = 1;
        result = {
          code: 200,
          msg: "system",
          data: {
            info: "登录成功!",
            type: info.type,
          },
        };
      } else {
        result = {
          code: 101,
          msg: "system",
          data: {
            info: "用户已登录!",
            type: info.type,
          },
        };
      }
    } else {
      users.push({
        id,
        name,
        avatar,
        ip,
        status: 1,
        roomId: 1,
      });
      result = {
        code: 200,
        msg: "system",
        data: {
          info: "登录成功!",
          type: info.type,
        },
      };
    }
  }
  returnMsg(ws, result);
}
  • 用户登出
js 复制代码
// 用户登出
function userLogout(ws, info) {
  let { name } = info.data;
  if (users.length === 0) return;
  let index = users.findIndex((s) => s.name == name);
  if (!users[index]) return;
  users[index].status = 2;
  let result = {
    code: 200,
    msg: "system",
    data: {
      info: "退出成功!",
      type: info.type,
      user: info.data,
    },
  };
  returnMsg(ws, result);
}
  • 加入房间
js 复制代码
// 加入房间
function joinRoom(ws, info) {
  let { name, roomId } = info.data;
  let roomInfo = rooms[roomId - 1];
  if (!roomInfo) return;
  if (!roomInfo.users) return;
  let roomUser = roomInfo.users;
  let result = null;
  if (roomUser.length >= MAX_ROOM_NUM) {
    result = {
      code: 101,
      msg: "system",
      data: {
        info: "房间已满!",
        type: info.type,
      },
    };
  } else {
    if (!roomUser.includes(name)) {
      rooms[roomId - 1].users.push(name);
    }
    let userList = users.filter((s) => {
      if (rooms[roomId - 1].users.includes(s.name)) {
        return s.name;
      }
    });
    result = {
      code: 200,
      msg: "system",
      data: {
        info: "加入成功!",
        type: info.type,
        roomId,
        user: info.data,
        users: userList,
      },
    };
  }
  returnMsg(ws, result);
}
  • 离开房间
js 复制代码
// 离开房间
function leaveRoom(ws, info) {
  let { name, roomId } = info.data;
  let roomUser = rooms[roomId - 1].users;
  if (!roomUser.includes(name)) return;
  let userIndex = roomUser.findIndex((s) => s == name);
  rooms[roomId - 1].users.splice(userIndex, 1);
  let userList = users.filter((s) => {
    if (rooms[roomId - 1].users.includes(s.name)) {
      return s;
    }
  });
  let result = {
    code: 200,
    msg: "system",
    data: {
      info: "离开房间!",
      type: info.type,
      roomId,
      user: info.data,
      users: userList,
    },
  };
  returnMsg(ws, result);
}
  • 发送消息
js 复制代码
// 发送消息
function sendMSg(ws, info) {
  let { roomId, name, avatar, id, message } = info.data,
    createTime = new Date().toLocaleString();
  createTime = createTime.replace(/\//gi, "-");
  let roomIndex = rooms.findIndex((s) => roomId === s.id);
  let roomParam = {
    id,
    name,
    avatar,
    content: message,
    createTime,
  };
  rooms[roomIndex].messages.push(roomParam);
  let msg = rooms[roomId - 1].messages;
  let result = {
    code: 200,
    msg: "user",
    data: {
      info: "接收成功!",
      type: info.type,
      roomId,
      user: info.data,
      msg,
    },
  };
  returnMsg(ws, result);
}
  • 返回消息
js 复制代码
// 返回消息
function returnMsg(ws, result) {
  let type = result.data.type;
  result = JSON.stringify(result);
  if (["join", "leave"].includes(type)) {
    wss.clients.forEach((item) => {
      console.log("room:", item.readyState, webSocket.OPEN);
      if (item.readyState === webSocket.OPEN) {
        item.send(result);
      }
    });
  } else if (type === "chat") {
    wss.clients.forEach((item) => {
      if (item !== ws && item.readyState === webSocket.OPEN) {
        item.send(result);
      }
      if (item === ws && item.readyState === webSocket.OPEN) {
        ws.send(result);
      }
    });
  } else {
    ws.send(result);
  }
}

案例展示

  • h5 端效果
  • 小程序端效果
  • APP 端效果
  • 服务端

最后

以上就是使用 websocket 实现简易聊天室的主要内容,有不足之处,请多多指正。

相关推荐
云小遥4 分钟前
Cornerstone3D 2.x升级调研
前端·数据可视化
李明卫杭州10 分钟前
浅谈JavaScript中Blob对象
前端·javascript
springfe010110 分钟前
Cesium 3D地图 图元 圆柱 图片实现
前端·cesium
meng半颗糖13 分钟前
vue3 双容器自动扩展布局 根据 内容的多少 动态定义宽度
前端·javascript·css·vue.js·elementui·vue3
yt9483214 分钟前
jquery和CSS3圆形倒计时特效
前端·css3·jquery
teeeeeeemo16 分钟前
CSS3 动画基础与技巧
前端·css·笔记·css3
年纪轻轻就扛不住18 分钟前
CSS3 渐变效果
前端·css·css3
Aisanyi22 分钟前
【鸿蒙开发】使用HMRouter路由的使用
前端·harmonyos
杉木笙27 分钟前
Flutter 代码雨实现(矩阵雨)DLC 多图层
前端·flutter
SouthernWind29 分钟前
Vista AI 演示—— 提示词优化功能
前端·vue.js