uniapp專案實踐總結(十五)使用websocket實現簡易聊天室

發表於2023-09-19
導語:在一些社交軟體中,經常可以看到各種聊天室的介面,接下來就總結一下聊天室的原理個實現方法,最後做一個簡易的聊天室,包括登入/登出、加入/離開房間、傳送接收聊天訊息等功能。

目錄

  • 準備工作
  • 原理分析
  • 元件實現
  • 實戰演練
  • 服務端搭建
  • 案例展示

準備工作

  • 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 實現簡易聊天室的主要內容,有不足之處,請多多指正。

相關文章