沉寂了一周,我開(kāi)發(fā)了一個(gè)聊天室

前言

最近一周沒(méi)有發(fā)文章了,我在這里向大家說(shuō)一聲抱歉。今天,我們來(lái)從零開(kāi)始開(kāi)發(fā)一款聊天室。好,我們現(xiàn)在就開(kāi)始。
了解WebSocket

開(kāi)發(fā)聊天室,我們需要用到WebSocket這個(gè)網(wǎng)絡(luò)通信協(xié)議,那么為什么會(huì)用到它呢?

我們首先來(lái)引用阮一峰大佬的一篇文章一段話(huà):

    初次接觸 WebSocket 的人,都會(huì)問(wèn)同樣的問(wèn)題:我們已經(jīng)有了 HTTP 協(xié)議,為什么還需要另一個(gè)協(xié)議?它能帶來(lái)什么好處?

    答案很簡(jiǎn)單,因?yàn)?HTTP 協(xié)議有一個(gè)缺陷:通信只能由客戶(hù)端發(fā)起。

    舉例來(lái)說(shuō),我們想了解今天的天氣,只能是客戶(hù)端向服務(wù)器發(fā)出請(qǐng)求,服務(wù)器返回查詢(xún)結(jié)果。HTTP 協(xié)議做不到服務(wù)器主動(dòng)向客戶(hù)端推送信息。

    這種單向請(qǐng)求的特點(diǎn),注定了如果服務(wù)器有連續(xù)的狀態(tài)變化,客戶(hù)端要獲知就非常麻煩。我們只能使用"輪詢(xún)":每隔一段時(shí)候,就發(fā)出一個(gè)詢(xún)問(wèn),了解服務(wù)器有沒(méi)有新的信息。最典型的場(chǎng)景就是聊天室。

    輪詢(xún)的效率低,非常浪費(fèi)資源(因?yàn)楸仨毑煌_B接,或者 HTTP 連接始終打開(kāi))。因此,工程師們一直在思考,有沒(méi)有更好的方法。WebSocket 就是這樣發(fā)明的。

我們來(lái)借用MDN網(wǎng)站上的官方介紹總結(jié)一下:

WebSockets 是一種先進(jìn)的技術(shù)。它可以在用戶(hù)的瀏覽器和服務(wù)器之間打開(kāi)交互式通信會(huì)話(huà)。使用此API,您可以向服務(wù)器發(fā)送消息并接收事件驅(qū)動(dòng)的響應(yīng),而無(wú)需通過(guò)輪詢(xún)服務(wù)器的方式以獲得響應(yīng)。

WebSocket 協(xié)議在2008年誕生,2011年成為國(guó)際標(biāo)準(zhǔn)。所有瀏覽器都已經(jīng)支持了。
WebSocket特點(diǎn)

    服務(wù)器可以主動(dòng)向客戶(hù)端推送信息,客戶(hù)端也可以主動(dòng)向服務(wù)器發(fā)送信息,是真正的雙向平等對(duì)話(huà),屬于服務(wù)器推送技術(shù)的一種。
    建立在 TCP 協(xié)議之上,服務(wù)器端的實(shí)現(xiàn)比較容易。
    與 HTTP 協(xié)議有著良好的兼容性。默認(rèn)端口也是80和443,并且握手階段采用 HTTP 協(xié)議,因此握手時(shí)不容易屏蔽,能通過(guò)各種 HTTP 代理服務(wù)器。
    數(shù)據(jù)格式比較輕量,性能開(kāi)銷(xiāo)小,通信高效。
    可以發(fā)送文本,也可以發(fā)送二進(jìn)制數(shù)據(jù)。
    沒(méi)有同源限制,客戶(hù)端可以與任意服務(wù)器通信。
    協(xié)議標(biāo)識(shí)符是ws(如果加密,則為wss),即ws對(duì)應(yīng)http,wss對(duì)應(yīng)https。服務(wù)器網(wǎng)址就是 URL。即ws://www.xx.com或wss://www.xx.com

WebSocket客戶(hù)端常用API

WebSocket 對(duì)象提供了用于創(chuàng)建和管理 WebSocket連接,以及可以通過(guò)該連接發(fā)送和接收數(shù)據(jù)的 API。

使用WebSocket()構(gòu)造函數(shù)來(lái)構(gòu)造一個(gè)WebSocket 。
屬性

    WebSocket.onopen

    用于指定連接成功后的回調(diào)函數(shù)。

    WebSocket.onmessage

    用于指定當(dāng)從服務(wù)器接受到信息時(shí)的回調(diào)函數(shù)。

    WebSocket.onclose

    用于指定連接關(guān)閉后的回調(diào)函數(shù)。

    WebSocket.onerror

    用于指定連接失敗后的回調(diào)函數(shù)。

方法

    WebSocket.close()

關(guān)閉當(dāng)前鏈接。

    WebSocket.send(data)

客戶(hù)端發(fā)送數(shù)據(jù)到服務(wù)器,對(duì)要傳輸?shù)臄?shù)據(jù)進(jìn)行排隊(duì)。
客戶(hù)端舉例

// Create WebSocket connection.
const socket = new WebSocket('ws://localhost:8080'); // 這里的地址是服務(wù)器的websocket服務(wù)地址

// Connection opened
socket.onopen = function(evt) {
  console.log("Connection open ...");
  ws.send("Hello WebSockets!");
};

// Listen for messages
socket.onmessage = function(evt) {
  console.log( "Received Message: " + evt.data);
  socket.close();
};

// Connection closed
socket.onclose = function(evt) {
  console.log("Connection closed.");
};     



WebSocket服務(wù)端

這里服務(wù)端我們使用Node.js,這里向大家介紹幾個(gè)常用的庫(kù)。

    ws
    socket.io
    nodejs-websocket

具體用法,大家可以上網(wǎng)瀏覽詳細(xì)文檔,這里就不一一介紹啦。不過(guò)在這篇文章中。我將會(huì)給大家使用ws與nodejs-websocket這兩個(gè)模塊來(lái)分別進(jìn)行項(xiàng)目開(kāi)發(fā)。

客戶(hù)端與服務(wù)端都介紹完啦!我們就趕快行動(dòng)起來(lái)吧!
開(kāi)發(fā)本地端(或局域網(wǎng))聊天室(第一種)

我們將基于Vue.js@3.0開(kāi)發(fā)聊天室,原因是擁抱新技術(shù)。怎么搭建vue腳手架,這里就不介紹了,想必大家也會(huì)。我們直接就上代碼。
客戶(hù)端

<template>
  <div class="home">
    <div class="content">
      <div class="chat-box" ref="chatBox">
        <div
          v-for="(item, index) in chatArr"
          :key="index"
          class="chat-item"
        >
          <div v-if="item.name === name" class="chat-msg mine">
            <p class="msg mineBg">{{ item.txt }}</p>
            <p class="user" :style="{ background: bg }">
              {{ item.name.substring(item.name.length - 5, item.name.length) }}
            </p>
          </div>
          <div v-else class="chat-msg other">
            <p class="user" :style="{ background: item.bg }">
              {{ item.name.substring(item.name.length - 5, item.name.length) }}
            </p>
            <p class="msg otherBg">{{ item.txt }}</p>
          </div>
        </div>
      </div>
    </div>
    <div class="footer">
      <textarea
        placeholder="說(shuō)點(diǎn)什么..."
        v-model="textValue"
        autofocus
        ref="texta"
        @keyup.enter="send"
      ></textarea>
      <div class="send-box">
        <p class="send active" @click="send">發(fā)送</p>
      </div>
    </div>
  </div>
</template>

<script>
import { onMounted, onUnmounted, ref, reactive, nextTick } from "vue";
export default {
  name: "Home",
  setup() {
    let socket = null;
    const path = "ws://localhost:3000/"; // 服務(wù)器地址,服務(wù)器代碼在下方
    const textValue = ref("");
    const chatBox = ref(null);
    const texta = ref(null);
    const name = new Date().getTime().toString();
    const bg = randomRgb();
    const chatArr = reactive([]);
    
    // WebSocket初始化
    function init() {
      if (typeof WebSocket === "undefined") {
        alert("您的瀏覽器不支持socket");
      } else {
        socket = new WebSocket(path);
        socket.onopen = open;
        socket.onerror = error;
        socket.onclose = closed;
        socket.onmessage = getMessage;
        window.onbeforeunload = function(e) {
          e = e || window.event;
          if (e) {
            e.returnValue = "關(guān)閉提示";
            socket.close();
          }
          socket.close();
          return "關(guān)閉提示";
        };
      }
    }
    
    function open() {
      alert("socket連接成功");
    }
    
    function error() {
      alert("連接錯(cuò)誤");
    }
    
    function closed() {
      alert("socket關(guān)閉");
    }
    // 監(jiān)聽(tīng)信息
    async function getMessage(msg) {
      const obj = JSON.parse(msg.data);
      chatArr.push(obj);
      await nextTick(); // 異步更新DOM
      chatBox.value.scrollTop = chatBox.value.scrollHeight; // 保持滾動(dòng)條在底部
    }
    // 隨機(jī)獲取頭像背景
    function randomRgb() {
      let R = Math.floor(Math.random() * 130 + 110);
      let G = Math.floor(Math.random() * 130 + 110);
      let B = Math.floor(Math.random() * 130 + 110);
      return "rgb(" + R + "," + G + "," + B + ")";
    }
    // 發(fā)送消息
    function send() {
      if (textValue.value.trim().length > 0) {
        const obj = {
          name: name,
          txt: textValue.value,
          bg: bg,
        };
        socket.send(JSON.stringify(obj));
        textValue.value = "";
        texta.value.focus();
      }
    }
    
    function close() {
      alert("socket已經(jīng)關(guān)閉");
    }
    
    onMounted(() => {
      init();
    });
    
    onUnmounted(() => {
      socket.onclose = close;
    });
    
    return {
      send,
      textValue,
      chatArr,
      name,
      bg,
      chatBox,
      texta,
      randomRgb
    };
  },
};
</script>



至于樣式文件,這里我也貼出來(lái)。

html,body{
  background-color: #e8e8e8;
  user-select: none;
}
::-webkit-scrollbar {
  width: 8px;
  height: 8px;
  display: none;
}
::-webkit-scrollbar-thumb {
  background-color: #D1D1D1;
  border-radius: 3px;
  -webkit-border-radius: 3px;
  border-left: 2px solid transparent;
  border-top: 2px solid transparent;
}
*{
  margin: 0;
  padding: 0;
}
.mine {
  justify-content: flex-end;
}
.other {
  justify-content: flex-start;
}
.mineBg {
  background: #98e165;
}
.otherBg {
  background: #fff;
}
.home {
  position: fixed;
  top: 0;
  left: 50%;
  transform: translateX(-50%);
  width: 100%;
  height: 100%;
  min-width: 360px;
  min-height: 430px;
  box-shadow: 0 0 24px 0 rgb(19 70 80 / 25%);
}
.count{
  height: 5%;
  display: flex;
  justify-content: center;
  align-items: center;
  background: #EEEAE8;
  font-size: 16px;
}
.content {
  width: 100%;
  height: 80%;
  background-color: #f4f4f4;
  overflow: hidden;
}
.footer {
  position: fixed;
  bottom: 0;
  width: 100%;
  height: 15%;
  background-color: #fff;
}
.footer textarea {
  width: 100%;
  height: 50%;
  background: #fff;
  border: 0;
  box-sizing: border-box;
  resize: none;
  outline: none;
  padding: 10px;
  font-size: 16px;
}
.send-box {
  display: flex;
  height: 40%;
  justify-content: flex-end;
  align-items: center;
}
.send {
  margin-right: 20px;
  cursor: pointer;
  border-radius: 3px;
  background: #f5f5f5;
  z-index: 21;
  font-size: 16px;
  padding: 8px 20px;
}
.send:hover {
  filter: brightness(110%);
}
.active {
  background: #98e165;
  color: #fff;
}
.chat-box {
  height: 100%;
  padding:0 20px;
  overflow-y: auto;
}
.chat-msg {
  display: flex;
  align-items: center;
}
.user {
  font-weight: bold;
  color: #fff;
  position: relative;
  word-wrap: break-word;
  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  width: 60px;
  height: 60px;
  line-height: 60px;
  border-radius:8px ;
  text-align: center;
}
.msg {
  margin: 0 5px;
  max-width: 74%;
  white-space: normal;
  word-break: break-all;
  color: #333;
  border-radius: 8px;
  padding: 10px;
  text-align: justify;
  font-size: 16px;
  box-shadow: 0px 0px 10px #f4f4f4;
}
.chat-item {
  margin: 20px 0;
  animation: up-down 1s both;
}
@keyframes up-down {
  0% {
    opacity: 0;
    transform: translate3d(0, 20px, 0);
  }

  100% {
    opacity: 1;
    transform: none;
  }
}



服務(wù)端

這里使用的是Node.js。

nodejs-websocket:websocket服務(wù)器和客戶(hù)端的nodejs模塊。

const ws = require("nodejs-websocket");
const server = ws.createServer((conn) => {
  conn.on("text", (str) => {
    broadcast(str);
  });
  conn.on("error", (err) => {
    console.log(err);
  });
});
server.listen(3000, function () {
  console.log("open");
});
// 群發(fā)消息
function broadcast(data) {
  server.connections.forEach((conn) => {
    conn.sendText(data);
  });
}


項(xiàng)目一覽





















在線(xiàn)人數(shù)為零,這不是bug,是因?yàn)楫?dāng)時(shí)在本地端沒(méi)有做,只是放上了這個(gè)版塊。不過(guò),在云服務(wù)端我已經(jīng)放上了這個(gè)功能。那么,我們來(lái)看一下吧。
開(kāi)發(fā)云端聊天室(第二種)
客戶(hù)端

<template>
  <div class="home">
    <div class="count">
      <p>在線(xiàn)人數(shù):{{ count }}</p>
    </div>
    <div class="content">
      <div class="chat-box" ref="chatBox">
        <div
          v-for="(item, index) in chatArr"
          :key="index"
          class="chat-item"
        >
          <div v-if="item.name === name" class="chat-msg mine">
            <p class="msg mineBg">{{ item.txt }}</p>
            <p class="user" :style="{ background: bg }">
              {{ item.name.substring(item.name.length - 5, item.name.length) }}
            </p>
          </div>
          <div v-else class="chat-msg other">
            <p class="user" :style="{ background: item.bg }">
              {{ item.name.substring(item.name.length - 5, item.name.length) }}
            </p>
            <p class="msg otherBg">{{ item.txt }}</p>
          </div>
        </div>
      </div>
    </div>
    <div class="footer">
      <textarea
        placeholder="說(shuō)點(diǎn)什么..."
        v-model="textValue"
        autofocus
        ref="texta"
        @keyup.enter="send"
      ></textarea>
      <div class="send-box">
        <p class="send active" @click="send">發(fā)送</p>
      </div>
    </div>
  </div>
</template>

<script>
import { onMounted, onUnmounted, ref, reactive, nextTick } from "vue";
export default {
  name: "Home",
  setup() {
    let socket = null;
    const path = "wss:/xxx.com/wsline/"; // 這個(gè)網(wǎng)址只是測(cè)試網(wǎng)址,這里只是說(shuō)明云服務(wù)地址
    const textValue = ref("");
    const chatBox = ref(null);
    const texta = ref(null);
    const count = ref(0);
    const name = new Date().getTime().toString();
    const bg = randomRgb();
    const chatArr = reactive([]);
    function init() {
      if (typeof WebSocket === "undefined") {
        alert("您的瀏覽器不支持socket");
      } else {
        socket = new WebSocket(path);
        socket.onopen = open;
        socket.onerror = error;
        socket.onclose = closed;
        socket.onmessage = getMessage;
        window.onbeforeunload = function(e) {
          e = e || window.event;
          if (e) {
            e.returnValue = "關(guān)閉提示";
            socket.close();
          }
          socket.close();
          return "關(guān)閉提示";
        };
      }
    }
    function open() {
      alert("socket連接成功");
    }
    function error() {
      alert("連接錯(cuò)誤");
    }
    function closed() {
      alert("socket關(guān)閉");
    }
    async function getMessage(msg) {
      if (typeof JSON.parse(msg.data) === "number") {
        console.log(JSON.parse(msg.data));
        count.value = msg.data;
      } else {
        const obj = JSON.parse(msg.data);
        chatArr.push(obj);
      }
      await nextTick();
      chatBox.value.scrollTop = chatBox.value.scrollHeight;
    }
    function randomRgb() {
      let R = Math.floor(Math.random() * 130 + 110);
      let G = Math.floor(Math.random() * 130 + 110);
      let B = Math.floor(Math.random() * 130 + 110);
      return "rgb(" + R + "," + G + "," + B + ")";
    }
    function send() {
      if (textValue.value.trim().length > 0) {
        const obj = {
          name: name,
          txt: textValue.value,
          bg: bg,
        };
        socket.send(JSON.stringify(obj));
        textValue.value = "";
        texta.value.focus();
      }
    }
    function close() {
      alert("socket已經(jīng)關(guān)閉");
    }
    onMounted(() => {
      init();
    });
    onUnmounted(() => {
      socket.onclose = close;
    });
    return {
      send,
      textValue,
      chatArr,
      name,
      bg,
      chatBox,
      texta,
      randomRgb,
      count,
    };
  },
};
</script>



樣式文件同本地端樣式,可以查看上方的代碼。
服務(wù)端

這里我使用了ws模塊,并且我也搭建了https服務(wù)器,并使用了更為安全的wss協(xié)議。接下來(lái),我們來(lái)看下是怎么操作的。

const fs = require("fs");
const httpServ = require("https");
const WebSocketServer = require("ws").Server; // 引用Server類(lèi)

const cfg = {
  port: 3456,
  ssl_key: "../../https/xxx.key", // 配置https所需的文件2
  ssl_cert: "../../https/xxx.crt", // 配置https所需的文件1
};

// 創(chuàng)建request請(qǐng)求監(jiān)聽(tīng)器
const processRequest = (req, res) => {
  res.writeHead(200);
  res.end("Websocket linked successfully");
};

const app = httpServ
  .createServer(
    {
      // 向server傳遞key和cert參數(shù)
      key: fs.readFileSync(cfg.ssl_key),
      cert: fs.readFileSync(cfg.ssl_cert),
    },
    processRequest
  )
  .listen(cfg.port);

// 實(shí)例化WebSocket服務(wù)器
const wss = new WebSocketServer({
  server: app,
});
// 群發(fā)
wss.broadcast = function broadcast(data) {
    wss.clients.forEach(function each(client) {
      client.send(data);
    });
};
// 如果有WebSocket請(qǐng)求接入,wss對(duì)象可以響應(yīng)connection事件來(lái)處理
wss.on("connection", (wsConnect) => {
  console.log("Server monitoring");
  wss.broadcast(wss._server._connections);
  wsConnect.on("message", (message) => {
    wss.broadcast(message);
  });
  wsConnect.on("close", function close() {
    console.log("disconnected");
    wss.broadcast(wss._server._connections);
  });
});



我們?cè)谠品?wù)上啟動(dòng)命令。

啟動(dòng)成功!


















這里還沒(méi)有結(jié)束,因?yàn)槟闶褂玫氖莍p地址端口,必須轉(zhuǎn)發(fā)到域名上。所以我使用的nginx進(jìn)行轉(zhuǎn)發(fā),配置如下參數(shù)。

    location /wsline/ {
         proxy_pass https://xxx:3456/;
         proxy_http_version 1.1;
         proxy_set_header Upgrade $http_upgrade;
         proxy_set_header Connection "Upgrade";
         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
         proxy_set_header Host $http_host;
         proxy_set_header X-Real-IP $remote_addr;
         proxy_set_header X-Forwarded-Proto https;
         proxy_redirect off;
    }


那么,重啟云端服務(wù)器,看下效果。
項(xiàng)目一覽
















那么,到這里一款云端聊天室就這么做成了,可以實(shí)時(shí)顯示在線(xiàn)人數(shù),這樣你就可以知道有多少人在這里跟你聊天。

作者:Vam的金豆之路

主要領(lǐng)域:前端開(kāi)發(fā)

我的微信:maomin9761

微信公眾號(hào):前端歷劫之路