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

目录

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

准备工作

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

原理分析

前端部分

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

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

后端部分

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

组件实现

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

模板部分

  • 登录部分

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

<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>
  • 加入房间部分

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

<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>
  • 聊天室界面

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

<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>

样式部分

  • 登录和加入房间样式
.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;
    }
  }
}
  • 聊天房间页面样式
.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 信息
// 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, // 服务端计时间隔
});
  • 用户信息
// 用户信息
const userInfo = reactive({
  id: "1", // 用户id
  name: "", // 用户名称
  avatar: "", // 用户头像
  users: [], // 用户在线列表
  usersText: "", // 用户在线列表文本
});
  • 房间信息
// 房间信息
const roomInfo = reactive({
  id: 1, // 房间id
  name: "房间1", // 房间名称
  list: ["房间1", "房间2", "房间3"], // 房间列表
  mode: "avatar", // 模式:avatar头像
  scrollTop: 0, // 滚动到顶部距离
  scrollHei: 0, // 滚动高度
  goBottomTimer: null, // 到底部计时
  goBottomTime: 500, // 到顶部计时间隔
});
  • 聊天信息
// 聊天信息
const msg = reactive({
  current: "", // 输入框内容
  list: [], // 聊天消息列表
});

实战演练

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

连接 websocket

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

  • 连接 websocket
// 连接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);
  });
}
  • 处理各种类型的消息
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);
    }
  }
}
  • 心跳检测
// 心跳检测
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);
}
  • 断线重连
// 断线重连
function reConnect() {
  if (wsInfo.lock) return;
  wsInfo.lock = true;
  wsInfo.reconnectTimer = setTimeout(() => {
    connectWs();
    wsInfo.lock = false;
  }, wsInfo.reconnectTime);
}
  • 断开 websocket 连接
// 断开连接
function closeWs() {
  if (!wsInfo.alive) {
    uni.showToast({
      title: "请先连接!",
      icon: "error",
    });
    return;
  }
  leaveRoom();
  wsLogout();
  wsInfo.ws.close();
}
  • 上传头像

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

// 上传操作
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",
    });
  }
}
  • 用户登录
// 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),
  });
}
  • 用户退出
// 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),
  });
}
  • 加入房间
// 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),
  });
}
  • 离开房间
// 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),
  });
}
  • 发送消息
// 发送消息
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

<!-- 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>
  • 引入依赖包
const fs = require("fs");
const http = require("http");
const path = require("path");
const { argv } = require("process");
  • 定义域名和端口号

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

// 域名地址
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 格式类型列表

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

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",
};
  • 定义根目录

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

const directoryName = "./public";
const root = path.normalize(path.resolve(directoryName));
  • 开启 HTTP 服务
// 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);
    }
  });
});
  • 监听端口
// 监听端口
server.listen(port, () => {
  console.log(`Server is listening on port http://${base}:${port} !`);
});

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

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

运行一下npm run dev

> static@1.0.0 dev
> node index.js dev

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

上传文件服务实现

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

  • 安装依赖包
npm i multiparty md5
  • 引入依赖包

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

// ...
const multiparty = require("multiparty");
const md5 = require("md5");
// ...
  • 设置响应头

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

// 设置响应头
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 来命名文件。

// 文件上传
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,安装依赖包。

mkdir socket
cd socket
npm init -y
npm i ws
  • 新建一个文件index.js
  • 引入依赖
const webSocket = require("ws");
const { createServer } = require("http");
const { argv } = require("process");
  • 创建服务
// http服务
const server = createServer((res) => {
  res.end("welcome to WS!");
});

// ws服务
const wss = new webSocket.WebSocketServer({
  server,
});
  • 初始化数据
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: [],
    },
  ];
  • 监听端口
// 监听端口
server.listen(port, () => {
  console.log(`WebSocket is running on http://${domain}:${port} !`);
});
  • 监听 ws 连接
// 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);
  });
});
  • 心跳检测
// 心跳检测
function heartbeat(ws) {
  let result = {
    code: 200,
    msg: "system",
    data: {
      info: "connected",
      type: "ping",
    },
  };
  returnMsg(ws, result);
}
  • 用户登录
// 用户登录
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);
}
  • 用户登出
// 用户登出
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);
}
  • 加入房间
// 加入房间
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);
}
  • 离开房间
// 离开房间
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);
}
  • 发送消息
// 发送消息
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);
}
  • 返回消息
// 返回消息
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 实现简易聊天室的主要内容,有不足之处,请多多指正。


MarkGuan
108 声望8 粉丝

Talk is cheap, show me the code.