上一篇文章《WebSocket是時候展現你優秀的一面了》其實是一個未完待續的讀物
正因如此,答應了大家的東西還是要兌現的,接下來的這篇文章裡,就讓我們一起來利用可愛的socket.io實現個聊天室的功能吧
友情提示: 聊天功能開發如果是第一次寫的話,確實會需要一段時間去咀嚼和消化,不過在你完整的敲過兩三遍後,你就會慢慢的理解和運用了,加油,Fighting!!!
這裡放上該專案的地址,需要對照學習的,盡請拿走!
聊天室的開發過程
其實這個過程從使用者的角度來說,其實無非就是連線上了,傳送訊息唄。
然而實際上,從使用者的觀點看東西,也確實是這個樣子的,那就不繞圈子了,直接進入主題
建立連線
當然,沒錯,這絕對是所有奇妙玄學中的第一步,不建立連線,那還聊個球呢
說到這裡,突然想到應該先把html的結構給大家,不然還怎麼按部就班的一起敲呢
先貼一張目錄的結構,下面的檔案都對應目錄即可
頁面結構
佈局樣式方面是直接使用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;
+ });
複製程式碼
寫到這裡,傳送訊息的部分就已經完事了,執行程式碼應該都可以看到如下圖的樣子了
看到上面的圖後,我們應該高興一下,畢竟有訊息了,離成功又近了一步兩步三四步雖然上面的程式碼還有瑕疵,不過不要方,讓我們繼續完善它
根據圖片所示,所有的使用者都是“系統”,這根本就分不清誰是誰了,讓我們來判斷一下,需要加個使用者名稱
建立使用者名稱
這裡我們可以知道,當使用者是第一次進來的時候,是沒有使用者名稱的,需要在設定之後才會顯示對應的名字
於是乎,我們就把第一次進來後輸入的內容當作使用者名稱了
// 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,這個方法是向除了自己外的所有人廣播
沒錯,畢竟自己進沒進聊天室自己心裡還沒數麼,哈哈
下面再看下執行的效果,請看圖
最基本的發訊息功能已經實現了,下面我們再接再厲,完成一個私聊功能吧新增私聊
在群裡大家都知道@一下就代表這條訊息是專屬被@的那個人的,其他人是不用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;
});
複製程式碼
寫完是寫完了,我們看看效果吧
寫到這裡,看到這裡,是否疲倦了呢,年輕人不要放棄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';
+ });
複製程式碼
上面定義的join和leave方法直接在對應的按鈕上呼叫即可了,如下圖所示
下面我們繼續寫服務端的程式碼邏輯服務端-處理進出房間(群)
// 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()
+ });
+ }
+ });
});
複製程式碼
寫到這裡,我們也實現了加入和離開房間的功能,如下圖所示
既然進入了房間內,那麼很顯然,發言的內容只能是在房間內的人才能看到,這點我們都懂所以下面我們再寫一下房間內發言的邏輯,繼續在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檔案後,再進入房間聊天,會展示如下圖的效果,只有在同一個房間內的使用者,才能相互之間看到訊息
麻雀雖小但五臟俱全,堅持寫到這裡的每一位都是贏家,不過我還想再完善最後一個小功能,就是展示一下歷史訊息畢竟每次一進到聊天室都是空空如也的樣子也太蒼白了,還是希望瞭解到之前的使用者聊了哪些內容的
那麼繼續加油,實現我們最後一個功能吧
展示歷史訊息
其實正確開發的情況,使用者輸入的所有訊息應該是存在資料庫中進行儲存的,不過我們這裡就不涉及其他方面的知識點了,就直接用純前端的技術去模擬一下實現了
獲取歷史訊息
這裡讓客戶端去傳送一個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;
+ });
複製程式碼
這樣就全部大功告成了,完成了最後的歷史訊息功能,如下圖所示效果
最後進行一個功能上的梳理吧,堅持到這裡的人,我已經不知道如何表達對你的敬佩了,好樣的梳理一下
聊天室的功能完成了,看到這裡頭有點暈了,現在簡單回憶一下,實際都有哪些功能
- 建立客戶端與服務端的websocket通訊連線
- 客戶端與服務端相互傳送訊息
- 新增使用者名稱
- 新增私聊
- 進入/離開房間聊天
- 歷史訊息
小Tips
針對以上程式碼中常用的發訊息方法進行一下區分:
- socket.send()傳送訊息是為了給自己看的
- io.emit()傳送訊息是給所有人看的
- socket.broadcast.emit()傳送訊息除了自己都能看到
最後的最後,說下我的感受,這篇文章寫的有些難受
因為文章不能像親口敘述一樣表達的痛快,所以也在探索如何寫好技術類文章,望大家理解以及多提意見吧(新增程式碼部分如何寫的更一目瞭然),感謝大家辛苦的觀看了,再見了!!!