socket.io讓每個人都可以開發屬於自己的即時通訊

chenhongdong發表於2018-10-29

上一篇文章《WebSocket是時候展現你優秀的一面了》其實是一個未完待續的讀物

正因如此,答應了大家的東西還是要兌現的,接下來的這篇文章裡,就讓我們一起來利用可愛的socket.io實現個聊天室的功能吧

友情提示: 聊天功能開發如果是第一次寫的話,確實會需要一段時間去咀嚼和消化,不過在你完整的敲過兩三遍後,你就會慢慢的理解和運用了,加油,Fighting!!!

這裡放上該專案的地址,需要對照學習的,盡請拿走!

聊天室的開發過程

其實這個過程從使用者的角度來說,其實無非就是連線上了,傳送訊息唄。

然而實際上,從使用者的觀點看東西,也確實是這個樣子的,那就不繞圈子了,直接進入主題

建立連線

當然,沒錯,這絕對是所有奇妙玄學中的第一步,不建立連線,那還聊個球呢

說到這裡,突然想到應該先把html的結構給大家,不然還怎麼按部就班的一起敲呢

先貼一張目錄的結構,下面的檔案都對應目錄即可

socket.io讓每個人都可以開發屬於自己的即時通訊

頁面結構

佈局樣式方面是直接使用bootstrap來搞的,方便快捷,主要就是讓大家看看樣子,這裡就不太浪費時間了, index.html檔案地址

沒有任何功能,僅僅是頁面佈局,大家copy一下,看看樣子即可了

下面我們來分別試著寫下客戶端和服務端的兩套建立連線的程式碼,一起敲敲敲吧

這才是重要的東西,開擼

客戶端建立連線

// index.js檔案
let socket = io();
// 監聽與服務端的連線
socket.on('connect', () => {
    console.log('連線成功'); 
});
複製程式碼

socket.io用法簡單,方便上手,欲購從速,哈哈,繼續寫服務端的連線吧

服務端建立連線

服務端的搭建我們還是用之前使用的express來處理

// app.js檔案

const express = require('express');
const app = express();
// 設定靜態資料夾,會預設找當前目錄下的index.html檔案當做訪問的頁面
app.use(express.static(__dirname));

// WebSocket是依賴HTTP協議進行握手的
const server = require('http').createServer(app);
const io = require('socket.io')(server);
// 監聽與客戶端的連線事件
io.on('connection', socket => {
    console.log('服務端連線成功');
});
// ☆ 這裡要用server去監聽埠,而非app.listen去監聽(不然找不到socket.io.js檔案)
server.listen(4000);
複製程式碼

以上內容就是客戶端和服務端建立了websocket連線了,如此的so easy,那麼接下來繼續寫傳送訊息吧

傳送訊息

列表Ul輸入框按鈕這些都齊全了,那就開始傳送訊息吧

通過socket.emit('message')方法來傳送訊息給服務端

// index.js檔案

// 列表list,輸入框content,按鈕sendBtn
let list = document.getElementById('list'),
    input = document.getElementById('input'),
    sendBtn = document.getElementById('sendBtn');

// 傳送訊息的方法
function send() {
    let value = input.value;
    if (value) {
        // 傳送訊息給伺服器
        socket.emit('message', value);
        input.value = '';
    } else {
        alert('輸入的內容不能為空!');
    }
}
// 點選按鈕傳送訊息
sendBtn.onclick = send;
複製程式碼

回車傳送訊息

每次都要點傳送按鈕,也是夠反使用者操作行為的了,所以還是加上我們熟悉的回車傳送吧,看程式碼,+號表示新增的程式碼

// index.js檔案
...省略

// 回車傳送訊息的方法
+ function enterSend(event) {
+    let code = event.keyCode;
+    if (code === 13)  send();
+ }
// 在輸入框onkeydown的時候傳送訊息
+ input.onkeydown = function(event) {
+    enterSend(event);  
+ };
複製程式碼

前端已經把訊息發出去了,接下來該服務端出馬了,繼續擼

服務端處理訊息

// app.js檔案
...省略

io.on('connection', socket => {
    // 監聽客戶端發過來的訊息
+    socket.on('message', msg => {
         // 服務端傳送message事件,把msg訊息再傳送給客戶端
+        io.emit('message', {
+            user: '系統',
+            content: msg,
+            createAt: new Date().toLocaleString()
+        });              
+    });
});
複製程式碼

io.emit()方法是向大廳和所有人房間內的人廣播

客戶端渲染訊息

我們繼續在index.js這裡寫,把服務端傳過來的訊息接收並渲染出來

// index.js檔案
...省略

// 監聽message事件來接收服務端發來的訊息
+ socket.on('message', data => {
      // 建立新的li元素,最終將其新增到list列表
+     let li = document.createElement('li');
+     li.className = 'list-group-item';
+     li.innerHTML = `
        <p style="color: #ccc;">
            <span class="user">${data.user}</span>
            ${data.createAt}
        </p>
        <p class="content">${data.content}</p>`;
      // 將li新增到list列表中
+     list.appendChild(li);
      // 將聊天區域的滾動條設定到最新內容的位置
+     list.scrollTop = list.scrollHeight;
+ });
複製程式碼

寫到這裡,傳送訊息的部分就已經完事了,執行程式碼應該都可以看到如下圖的樣子了

socket.io讓每個人都可以開發屬於自己的即時通訊
看到上面的圖後,我們應該高興一下,畢竟有訊息了,離成功又近了一步兩步三四步

雖然上面的程式碼還有瑕疵,不過不要方,讓我們繼續完善它

根據圖片所示,所有的使用者都是“系統”,這根本就分不清誰是誰了,讓我們來判斷一下,需要加個使用者名稱

建立使用者名稱

這裡我們可以知道,當使用者是第一次進來的時候,是沒有使用者名稱的,需要在設定之後才會顯示對應的名字

於是乎,我們就把第一次進來後輸入的內容當作使用者名稱

// app.js檔案
...省略
// 把系統設定為常量,方便使用
const SYSTEM = '系統';

io.on('connection', socket => {
    // 記錄使用者名稱,用來記錄是不是第一次進入,預設是undefined
+   let username;
    socket.on('message', msg => {
        // 如果使用者名稱存在
+       if (username) {
             // 就向所有人廣播
+            io.emit('message', {
+                user: username,
+                content: msg,
+                createAt: new Date().toLocaleString()
+            });
+       } else {  // 使用者名稱不存在的情況
             // 如果是第一次進入的話,就將輸入的內容當做使用者名稱
+            username = msg;
             // 向除了自己的所有人廣播,畢竟進沒進入自己是知道的,沒必要跟自己再說一遍
+            socket.broadcast.emit('message', {
+                user: SYSTEM,
+                content: `${username}加入了聊天!`,
+                createAt: new Date().toLocaleString()
+            });
+        }
    });
});
複製程式碼

☆️ socket.broadcast.emit,這個方法是向除了自己外的所有人廣播

沒錯,畢竟自己進沒進聊天室自己心裡還沒數麼,哈哈

下面再看下執行的效果,請看圖

socket.io讓每個人都可以開發屬於自己的即時通訊
最基本的發訊息功能已經實現了,下面我們再接再厲,完成一個私聊功能吧

新增私聊

在群裡大家都知道@一下就代表這條訊息是專屬被@的那個人的,其他人是不用care的

如何實現私聊呢?這裡我們採用,在訊息列表list中點選對方的使用者名稱進行私聊,所以廢話不多說,開寫吧

@一下

// index.js檔案
...省略

  // 私聊的方法
+ function privateChat(event) {
+    let target = event.target;
     // 拿到對應的使用者名稱
+    let user = target.innerHTML;
     // 只有class為user的才是目標元素
+    if (target.className === 'user') {
         // 將@使用者名稱顯示在input輸入框中
+        input.value = `@${user} `;
+    }
+ }
  // 點選進行私聊
+ list.onclick = function(event) {
+    privateChat(event);
+ };
複製程式碼

客戶端已將@使用者名稱這樣的格式設定在了輸入框中,只要傳送訊息,服務端就可以進行區分,是私聊還是公聊了,下面繼續寫服務端的處理邏輯吧

服務端處理

首先私聊的前提是已經獲取到了使用者名稱了

然後正則判斷一下,哪些訊息是屬於私聊的

最後還需要找到對方的socket例項,好方便傳送訊息給對方

那麼,看如下程式碼

// app.js檔案
...省略

// 用來儲存對應的socket,就是記錄對方的socket例項
+ let socketObj = {};

io.on('connection', socket => {
    let username;
    socket.on('message', msg => {
        if (username) {
            // 正則判斷訊息是否為私聊專屬
+           let private = msg.match(/@([^ ]+) (.+)/);

+           if (private) {  // 私聊訊息
                 // 私聊的使用者,正則匹配的第一個分組
+                let toUser = private[1];
                 // 私聊的內容,正則匹配的第二個分組
+                let content = private[2];
                 // 從socketObj中獲取私聊使用者的socket
+                let toSocket = socketObj[toUser];

+                if (toSocket) {
                     // 向私聊的使用者發訊息
+                    toSocket.send({
+                        user: username,
+                        content,
+                        createAt: new Date().toLocaleString()
+                    });
+                }
            } else {    // 公聊訊息
                io.emit('message', {
                    user: username,
                    content: msg,
                    createAt: new Date().toLocaleString()
                });
            }
        } else { // 使用者名稱不存在的情況
            ...省略
            // 把socketObj物件上對應的使用者名稱賦為一個socket
            // 如: socketObj = { '周杰倫': socket, '謝霆鋒': socket }
+           socketObj[username] = socket;
        }
    });
});
複製程式碼

寫到這裡,我們已經完成了公聊和私聊的功能了,可喜可賀,非常了不起了已經,但是不能傲嬌,我們再完善一些小細節

現在所有使用者名稱和傳送訊息的氣泡都是一個顏色,其實這樣也不好區分使用者之間的差異

SO,我們來改下顏色的部分

分配使用者不一樣的顏色

服務端處理顏色

// app.js檔案
...省略
let socketObj = {};
// 設定一些顏色的陣列,讓每次進入聊天的使用者顏色都不一樣
+ let userColor = ['#00a1f4', '#0cc', '#f44336', '#795548', '#e91e63', '#00bcd4', '#009688', '#4caf50', '#8bc34a', '#ffc107', '#607d8b', '#ff9800', '#ff5722'];

// 亂序排列方法,方便把陣列打亂
+ function shuffle(arr) {
+    let len = arr.length, random;
+    while (0 !== len) {
        // 右移位運算子向下取整
+        random = (Math.random() * len--) >>> 0; 
        // 解構賦值實現變數互換
+        [arr[len], arr[random]] = [arr[random], arr[len]]; 
+    }
+    return arr;
+ }

io.on('connection', socket => {
     let username;
+    let color;  // 用於存顏色的變數

    socket.on('message', msg => {
        if (username) {
            ...省略
            if (private) {
                ...省略
                if (toSocket) {
                    toSocket.send({
                        user: username,
+                       color,
                        content: content,
                        createAt: new Date().toLocaleString()
                    });
                }
            } else {
                io.emit('message', {
                    user: username,
+                   color,
                    content: msg,
                    createAt: new Date().toLocaleString()
                });
            }
        } else { // 使用者名稱不存在的情況
            ...省略
            // 亂序後取出顏色陣列中的第一個,分配給進入的使用者
+           color = shuffle(userColor)[0];

            socket.broadcast.emit('message', {
                user: '系統',
+               color,
                content: `${username}加入了聊天!`,
                createAt: new Date().toLocaleString()
            });
        }
    });
});
複製程式碼

服務端那邊給分配好了顏色,前端這邊再渲染一下就好了,接著寫下去,不要停

渲染顏色

在建立的li元素上,給對應的使用者名稱和內容分別在style樣式中加個顏色就可以了,程式碼如下

// index.js
... 省略

socket.on('message', data => {
    let li = document.createElement('li');
    li.className = 'list-group-item';
    // 給對應元素設定行內樣式新增顏色
+   li.innerHTML = `<p style="color: #ccc;"><span class="user" style="color:${data.color}">${data.user} </span>${data.createAt}</p>
                    <p class="content" style="background:${data.color}">${data.content}</p>`;
    list.appendChild(li);
    // 將聊天區域的滾動條設定到最新內容的位置
    list.scrollTop = list.scrollHeight;
});
複製程式碼

寫完是寫完了,我們看看效果吧

socket.io讓每個人都可以開發屬於自己的即時通訊
寫到這裡,看到這裡,是否疲倦了呢,年輕人不要放棄

Now,讓我們來寫理論上的最最最後一個功能吧,進入某個群裡聊天,該訊息只有群裡的人可以看到

加入指定房間(群)

我們一直在上面的截圖中看到了兩個群的按鈕,看到字面意思就能知道是幹嘛的,就是為了這一刻而準備的

下面我們再來,繼續擼,馬上就要完成大作了

客戶端-進出房間(群)

// index.js檔案
...省略

// 進入房間的方法
+ function join(room) {
+    socket.emit('join', room);
+ }
// 監聽是否已進入房間
// 如果已進入房間,就顯示離開房間按鈕
+ socket.on('joined', room => {
+    document.getElementById(`join-${room}`).style.display = 'none';
+    document.getElementById(`leave-${room}`).style.display = 'inline-block';
+ });

// 離開房間的方法
+ function leave(room) {
    socket.emit('leave', room);
+ }
// 監聽是否已離開房間
// 如果已離開房間,就顯示進入房間按鈕
+ socket.on('leaved', room => {
+    document.getElementById(`leave-${room}`).style.display = 'none';
+    document.getElementById(`join-${room}`).style.display = 'inline-block';
+ });
複製程式碼

上面定義的joinleave方法直接在對應的按鈕上呼叫即可了,如下圖所示

socket.io讓每個人都可以開發屬於自己的即時通訊
下面我們繼續寫服務端的程式碼邏輯

服務端-處理進出房間(群)

// app.js檔案
...省略
io.on('connection', socket => {
    ...省略
    // 記錄進入了哪些房間的陣列
+   let rooms = [];
    io.on('message', msg => {
        ...省略
    });
    // 監聽進入房間的事件
+   socket.on('join', room => {
+       // 判斷一下使用者是否進入了房間,如果沒有就讓其進入房間內
+       if (username && rooms.indexOf(room) === -1) {
            // socket.join表示進入某個房間
+           socket.join(room);
+           rooms.push(room);
            // 這裡傳送個joined事件,讓前端監聽後,控制房間按鈕顯隱
+           socket.emit('joined', room);
            // 通知一下自己
+           socket.send({
+               user: SYSTEM,
+               color,
+               content: `你已加入${room}戰隊`,
+               createAt: new Date().toLocaleString()
+           });
+       }
+   });
    // 監聽離開房間的事件
+   socket.on('leave', room => {
        // index為該房間在陣列rooms中的索引,方便刪除
+       let index = rooms.indexOf(room);
+       if (index !== -1) {
+           socket.leave(room); // 離開該房間
+           rooms.splice(index, 1); // 刪掉該房間
            // 這裡傳送個leaved事件,讓前端監聽後,控制房間按鈕顯隱
+           socket.emit('leaved', room);
            // 通知一下自己
+           socket.send({
+               user: SYSTEM,
+               color,
+               content: `你已離開${room}戰隊`,
+               createAt: new Date().toLocaleString()
+           });
+       }
+   });
});
複製程式碼

寫到這裡,我們也實現了加入和離開房間的功能,如下圖所示

socket.io讓每個人都可以開發屬於自己的即時通訊
既然進入了房間內,那麼很顯然,發言的內容只能是在房間內的人才能看到,這點我們都懂

所以下面我們再寫一下房間內發言的邏輯,繼續在app.js中開擼

處理房間內發言

// app.js檔案
...省略
// 上來記錄一個socket.id用來查詢對應的使用者
+ let mySocket = {};

io.on('connection', socket => {
    ...省略
    // 這是所有連線到服務端的socket.id
+   mySocket[socket.id] = socket;
    
    socket.on('message', msg => {
        if (private) {
            ...省略
        } else {
            // 如果rooms陣列有值,就代表有使用者進入了房間
+           if (rooms.length) {
                // 用來儲存進入房間內的對應的socket.id
+               let socketJson = {};

+               rooms.forEach(room => {
                    // 取得進入房間內所對應的所有sockets的hash值,它便是拿到的socket.id
+                   let roomSockets = io.sockets.adapter.rooms[room].sockets;
+                   Object.keys(roomSockets).forEach(socketId => {
                        console.log('socketId', socketId);
                        // 進行一個去重,在socketJson中只有對應唯一的socketId
+                       if (!socketJson[socketId]) {
+                           socketJson[socketId] = 1;
+                       }
+                   });
+               });

                // 遍歷socketJson,在mySocket裡找到對應的id,然後傳送訊息
+               Object.keys(socketJson).forEach(socketId => {
+                   mySocket[socketId].emit('message', {
+                       user: username,
+                       color,
+                       content: msg,
+                       createAt: new Date().toLocaleString()
+                   });
+               });
            } else {
                // 如果不是私聊的,向所有人廣播
                io.emit('message', {
                    user: username,
                    color,
                    content: msg,
                    createAt: new Date().toLocaleString()
                });
            }
        }
    });
});
複製程式碼

重新執行app.js檔案後,再進入房間聊天,會展示如下圖的效果,只有在同一個房間內的使用者,才能相互之間看到訊息

socket.io讓每個人都可以開發屬於自己的即時通訊
麻雀雖小但五臟俱全,堅持寫到這裡的每一位都是贏家,不過我還想再完善最後一個小功能,就是展示一下歷史訊息

畢竟每次一進到聊天室都是空空如也的樣子也太蒼白了,還是希望瞭解到之前的使用者聊了哪些內容的

那麼繼續加油,實現我們最後一個功能吧

展示歷史訊息

其實正確開發的情況,使用者輸入的所有訊息應該是存在資料庫中進行儲存的,不過我們這裡就不涉及其他方面的知識點了,就直接用純前端的技術去模擬一下實現了

獲取歷史訊息

這裡讓客戶端去傳送一個getHistory的事件,在socket連線成功的時候,告訴伺服器我們要拿到最新的20條訊息記錄

// index.js
...省略

socket.on('connect', () => {
    console.log('連線成功');
    // 向伺服器發getHistory來拿訊息
+   socket.emit('getHistory');
});
複製程式碼

服務端處理歷史記錄並返回

// app.js
...省略

// 建立一個陣列用來儲存最近的20條訊息記錄,真實專案中會存到資料庫中
let msgHistory = [];

io.on('connection', socket => {
    ...省略
    io.on('message', msg => {
        ...省略
        if (private) {
            ...省略
        } else {
            io.emit('message', {
                user: username,
                color,
                content: msg,
                createAt: new Date().toLocaleString()
            });
            // 把傳送的訊息push到msgHistory中
            // 真實情況是存到資料庫裡的
+           msgHistory.push({
+               user: username,
+               color,
+               content: msg,
+              createAt: new Date().toLocaleString()
+          });
        }
    });
    
    // 監聽獲取歷史訊息的事件
+   socket.on('getHistory', () => {
        // 通過陣列的slice方法擷取最新的20條訊息
+       if (msgHistory.length) {
+           let history = msgHistory.slice(msgHistory.length - 20);
            // 傳送history事件並返回history訊息陣列給客戶端
+           socket.emit('history', history);
+       }
+   });
});
複製程式碼

客戶端渲染歷史訊息

// index.js
...省略

// 接收歷史訊息
+ socket.on('history', history => {
    // history拿到的是一個陣列,所以用map對映成新陣列,然後再join一下連線拼成字串
+   let html = history.map(data => {
+       return `<li class="list-group-item">
            <p style="color: #ccc;"><span class="user" style="color:${data.color}">${data.user} </span>${data.createAt}</p>
            <p class="content" style="background-color: ${data.color}">${data.content}</p>
        </li>`;
+   }).join('');
+   list.innerHTML = html + '<li style="margin: 16px 0;text-align: center">以上是歷史訊息</li>';
    // 將聊天區域的滾動條設定到最新內容的位置
+   list.scrollTop = list.scrollHeight;
+ });
複製程式碼

這樣就全部大功告成了,完成了最後的歷史訊息功能,如下圖所示效果

socket.io讓每個人都可以開發屬於自己的即時通訊
最後進行一個功能上的梳理吧,堅持到這裡的人,我已經不知道如何表達對你的敬佩了,好樣的

梳理一下

聊天室的功能完成了,看到這裡頭有點暈了,現在簡單回憶一下,實際都有哪些功能

  1. 建立客戶端與服務端的websocket通訊連線
  2. 客戶端與服務端相互傳送訊息
  3. 新增使用者名稱
  4. 新增私聊
  5. 進入/離開房間聊天
  6. 歷史訊息

小Tips

針對以上程式碼中常用的發訊息方法進行一下區分:

  • socket.send()傳送訊息是為了給自己看的
  • io.emit()傳送訊息是給所有人看的
  • socket.broadcast.emit()傳送訊息除了自己都能看到

最後的最後,說下我的感受,這篇文章寫的有些難受

因為文章不能像親口敘述一樣表達的痛快,所以也在探索如何寫好技術類文章,望大家理解以及多提意見吧(新增程式碼部分如何寫的更一目瞭然),感謝大家辛苦的觀看了,再見了!!!

相關文章