【實作筆記】利用Node.js及Socket.io製作簡易聊天室﹙上﹚

【實作筆記】利用Node.js及Socket.io製作簡易聊天室﹙上﹚

在創作屬於自己的聊天室之前,在網上到處搜尋資料進行「參考」也是作為程式員必要的事前部署吧!?可是在搜尋途中我發現十居其九也是資料不齊全的,所以現在就要跟你們分享餘下的一個完整聊天室教學,順便我也幫大家補充一些新手會遇到的小細節吧。

看到這裡有些人可能不太清楚Socket.io是什麼,為什麼不干脆使用Node.js?其實Socket.io是一個Websocket的函式庫,它提供更簡單的方式讓開發者可以方便的使用Node.js配合Websocket的通訊技術來實作許多應用。假設大家對Node.js和Socket.io已經有基礎的認識並且已經在自己的電腦上安裝了Node.js,我們馬上來進行程式基本的設定和安裝需要的模組。

首先,開新資料夾﹙假設我的專案資料夾為SimpleChat﹚並在資料中打開終端機,然後輸入以下指令︰

npm init -y
npm install -S express socket.io

第一行指令是指在資料夾中產生一個package.json檔案,這個檔案會紀錄專案的各種資訊,包含所相關的模組等等。而第二行指令是指安裝我們所需的兩種模組:express.jssocket.io



後端: HTTP 伺服器

一切準備好之後,我們接下來可以在資料夾當中建立index.js以作為控制程式後端之用。然而,與網頁有關係的東西當然就離不開HTTP伺服器,大家可以先把下面這幾行程式碼輸入到index.js中。

const express = require('express');
const app = express();
 
app.get('/', (req, res) => {
    res.send('Hello, World!');
});
 
app.listen(80, () => {
    console.log("System Message: server started on http://localhost:80");
});

然後在你已經進入了專案資料夾的終端機中輸入這個指令來啟動伺服器:

node index.js

你應該會看到終端機顯示這樣的字樣:

System Message: server started on http://localhost:80

然後,你就可以打開瀏覽器輸入http://localhost。你的瀏覽器應該會出現Hello, World!的字樣,這時候你可能發現輸入localhost便已經有回饋的內容是非常的方便。如果像是其他教學一樣設定port3000,有很多人情急之下會在瀏覽器犯傻輸入localhost而非localhost:3000。結果網頁連結不到又要找尋一番以為自己做錯了什麼,所以把port改為預設的80就是一個好方法替大家避免,但因為端口佔用的因素所以不建議大家日後其他程式也是這樣做。回到正題,這樣最簡易的HTTP伺服器就完成了,接下來便要處理Socket.io的部分。



後端: Socket.io

在Socket.io的部分,這邊我們需要稍微修改一下index.js的程式碼:

const express = require('express');
const app = express();
// 加入這兩行
const server = require('http').Server(app);
const io = require('socket.io')(server);
 
app.get('/', (req, res) => {
    res.send('Hello, World!');
});
 
// 當發生連線事件
io.on('connection', (socket) => {
    console.log('System Message: server connected');
 
    // 當發生離線事件
    socket.on('disconnect', () => {
        console.log('System Message: server disconnected');
    });
});
 
// 注意,這邊的 server 原本是 app
server.listen(80, () => {
    console.log("System Message: server started on http://localhost:80");
});

啟動方式跟我們在執行HTTP伺服器時一樣

node index.js

P.S. 小撇步: 如果不想每一次也需要在終端機上輸入node index.js,可以在package.jsonscript的部分加入"start": "node index.js",下一次就只需要在終端機輸入npm start便能夠同樣啟動。

可是你會發現什麼都沒有變。是的,因為我們的網站頁面還沒有放上去,當然什麼都不會變啊。畢竟這時候瀏覽器也不知道要打開Websocket呢,不相信?那你連看看http://localhost:80,看伺服器有沒有出現System Message: server connected啊!沒有對吧。所以接下來我們要透過網頁來與伺服器搭上線。



前端

伺服器大致完成後,要來處理人看得到的部分,也就是網頁呈現的部分。總不能只顯示個 Hello, World!,然後什麼事都不能做吧。那請在你的專案資料夾下建立一個資料夾,這個資料夾專門用來存放網頁程式用,我想我們就叫做views好了。然後在views中建一個檔案:index.html。而這個檔案將會呈現我們聊天室的主要畫面,請記得在檔案裝輸入下方的程式碼。

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SimpleChat</title>
    <script src="/socket.io/socket.io.js"></script>
    <script>
        var socket = io();
    </script>
</head>
<body>
    <div>什麼都沒有做,只有連線。</div>
</body>
</html>

然後修改index.js

// 修改這一部分
app.get('/', (req, res) => {
    res.sendFile( __dirname + '/views/index.html');
});

這樣伺服器才會把我們剛剛的網頁內容推到瀏覽器上顯示。那麼,這其實只是一個非常基本的頁面,只有一個目標,就是讓客戶端與伺服器的WebSocket連接埠建立連線。謹記在每次更改後端程式碼後用CTRL+C關閉伺服器再啟動以好讓他更新,要確認有無連線,請重新整理剛剛打開的瀏覽器畫面,然後按下重新整理,你應該會在終端機中看到System Message,連接上了就會顯示server connected,關閉網頁的話會看到server disconnected



收發測試

那麼現在我們要來做一點實驗,我們先在index.htmlbody部分加入如下的代碼:

<body>
    <div id="msg"></div>
 
    <script>
        // 當觸發連線建立事件
        // 發送 greet 事件給伺服器
        socket.on("connect", function () {
            socket.emit("greet");
        });
 
        // 當收到伺服器回傳到 greet 事件
        // 將內容轉到 div 中呈現
        socket.on("greet", function (msg) {
            document.getElementById("msg").innerText = msg;
        });
    </script>
</body>

還有,在index.js修改以下一部分:

io.on('connection', (socket) => {
    console.log('Hello!');
 
    // 加入這一段
    // 接收來自前端的 greet 事件
    // 然後回送 greet 事件,並附帶內容
    socket.on("greet", () => {
        socket.emit("greet", "Hi! Client.");
    });
 
    //...
});

重新啟動伺服器然後重新整理網頁,你應該會看到網頁上會顯示Hi! Client.,這就代表著一切正常沒有問題。在這一部份中,我們重新調整了伺服器的程式,以及前端網頁的JavaScript程式。在前端網頁的部分中,我們更改了body的內容並加入一個div來顯示接收到的訊息,而且加入新的Javascript以觸發伺服器接收前端網頁的事件和回傳一個附帶內容的事件給前端網頁。



用戶界面

對於最基本的聊天室界面,我們需要有下列的基本功能: 伺服器狀態、發言者名稱、顯示聊天內容﹙發言者、發言時間、訊息內容﹚、訊息輸入 。

那立刻開始吧!首先由於原作者會在index.htmlhead的部分加入一串很長的style,長久下去代碼會非常冗長。所以我們先在views的資料夾中建立名為css的資料夾,然後再於css資料夾中加入style.css的檔案,並加入以下代碼:

html,
body {
    padding: 0;
    margin: 0;
}

#container {
    top: 50px;
    width: 500px;
    margin: 0 auto;
    display: block;
    position: relative;
}

#status-box {
    text-align: right;
    font-size: .6em;
}

#content {
    width: 100%;
    height: 350px;
    border: 1px solid darkolivegreen;
    border-radius: 5px;
    overflow: auto;
}

#send-box {
    width: 100%;
    text-align: center;
}

#send-box input {
    display: inline-block;
}

input[name="name"] {
    width: 15%;
}

input[name="msg"] {
    width: 70%;
}

input[type="button"] {
    width: 10%;
}

.msg {
    width: 73%;
    display: inline-block;
    padding: 5px 0 5px 10px;
}

.msg>span {
    width: 25%;
    display: inline-block;
}

.msg>span::before {
    color: darkred;
    content: " { ";
}

.msg>span::after {
    color: darkred;
    content: " } ";
}

然後,在index.html加入:

<link rel="stylesheet" type="text/css" href="/css/style.css" />

index.htmlbody部分則替換成:

<body>
    <div id="container">
        <div id="status-box">Server: <span id="status">-</span> / <span id="online">0</span> online.</div>
        <div id="content">
            <div class="msg">
                <span class="name">Duye</span>
                Hello!
            </div>
            <div class="msg">
                <span class="name">Alice</span>
                Hi!
            </div>
        </div>
        <div id="send-box">
            <form id="send-form">
                <input type="text" name="name" id="name" placeholder="Nickname">
                <input type="text" name="msg" id="msg" placeholder="Enter your message ...">
                <input type="submit" value="Send">
            </form>
        </div>
    </div>
 
    <script>
        document.addEventListener("DOMContentLoaded", () => {
 
            var status = document.getElementById("status");
            var online = document.getElementById("online");
 
            socket.on("connect", function () {
                status.innerText = "Connected.";
            });
 
            socket.on("disconnect", function () {
                status.innerText = "Disconnected.";
            });
 
            socket.on("online", function (amount) {
                online.innerText = amount;
            });
        });
    </script>
</body>

接著,在index.js加入以下代碼:

const path = require('path');
app.use(express.static(path.join(__dirname, 'views')));

重新整理網頁後應會看到一個新的畫面,這時還沒有任何功能,雖然有點簡陋,不過該有的都有啦。接下來我們又要來修改伺服器的部分,順便讓伺服器狀態這個功能動起來。

index.js中修改成以下代碼:

const server = require('http').Server(app);
const io = require('socket.io')(server);
 
// 加入線上人數計數
let onlineCount = 0;
 
// 修改 connection 事件
io.on('connection', (socket) => {
    // 有連線發生時增加人數
    onlineCount++;
    // 發送人數給網頁
    io.emit("online", onlineCount);
 
    socket.on("greet", () => {
        socket.emit("greet", onlineCount);
    });
 
    socket.on('disconnect', () => {
        // 有人離線了,扣人
        onlineCount = (onlineCount < 0) ? 0 : onlineCount-=1;
        io.emit("online", onlineCount);
    });
});

重啟伺服器及網頁之後,大家可以留意到留言框右上角的資訊有所變動,理應會看到Server: Connected. / 1 online.。這樣的資訊才對,如果沒有那應該是失敗了,請再做一次看看。這時你可以再開一個網頁視窗,應該會看到右上角的數字變成2



訊息輸入

完成了簡單的外觀後,接下來是訊息輸入的部分,也就是聊天內容輸入。

index.html中加入以下代碼:

document.addEventListener("DOMContentLoaded", () => {
    var status = document.getElementById("status");
    var online = document.getElementById("online");
    var sendForm = document.getElementById("send-form"); // 加入這行
 
    //...
    // 加入這段
    sendForm.addEventListener("submit", function (e) {
        e.preventDefault();
 
        var formData = {};
        var formChild = sendForm.children;
 
        for (var i=0; i< sendForm.childElementCount; i++) {
            var child = formChild[i];
            if (child.name !== "") {
                formData[child.name] = child.value;
            }
        }
        socket.emit("send", formData);
    });
});

另外,在index.js中加入以下代碼:

io.on('connection', (socket) => {
    //...
    // 加入這段
    socket.on("send", (msg) => {
        console.log(msg)
    });
    //...
});

現在重新啟動伺服器和網頁,然後嘗試輸入一些東西後送出,觀察終端機的變化吧。 在這裡,我們已經在前端網頁的部分中加入表單送出事件監聽器﹙取消原有的送出動作、讀取表單內容、透過socket送出表單內容到伺服器﹚。而且,我們也在伺服器中新增一個事件監聽器﹙接收來自網頁端的訊息、將訊息寫在終端機上﹚。



顯示聊天訊息

完成了送出的部分,並且也確認了伺服器能夠正確收到資料後,我們要讓聊天室可以顯示這些聊天資料咯!

首先於index.html中刪除以下部分:

<!-- 刪除這部分 -->
<div class="msg">
    <span class="name">Duye</span>
    Hello!
</div>
<div class="msg">
    <span class="name">Alice</span>
    Hi!
</div>

然後加入以下代碼:

document.addEventListener("DOMContentLoaded", () => {
    //...
    var sendForm = document.getElementById("send-form");
    var content = document.getElementById("content");    // 加入這行
    //...
    socket.on("online", function (amount) {
        online.innerText = amount;
    });
 
    // 加入這一段
    socket.on("msg", function (d) {
        var msgBox = document.createElement("div")
        msgBox.className = "msg";
        var nameBox = document.createElement("span");
        nameBox.className = "name";
        var name = document.createTextNode(d.name);
        var msg = document.createTextNode(d.msg);
 
        nameBox.appendChild(name);
        msgBox.appendChild(nameBox);
        msgBox.appendChild(msg);
        content.appendChild(msgBox);
    });
    //...
});

再於index.js修改以下代碼:

// 修改 console.log 成 io.emit
socket.on("send", (msg) => {
    // 廣播訊息到聊天室
    io.emit("msg", msg);
});

好了,現在我們可以重新啟動伺服器,打開兩個瀏覽器視窗跟自己聊天吧!



處理空白輸入

我想你們也有發現到,當你沒有輸入任何東西便直接送出時系統還是能夠送出,這其實是一件很詭異的事情,所以我們要來解決這個問題。

首先在style.css中加入以下代碼:

#send-box input.error {
    border: 1px solid red;
}

之後在index.html中替換掉原本的submit監聽器

<br />//...

sendForm.addEventListener("submit", function (e) {
    e.preventDefault();

    var ok = true;
    var formData = {};
    var formChild = sendForm.children;

    for (var i=0; i< sendForm.childElementCount; i++) {
        var child = formChild[i];
        if (child.name !== "") {
            var val = child.value;
            if (val === "" || !val) {    // 如果值為空或不存在
                ok = false;
                child.classList.add("error");
            } else {
                child.classList.remove("error");
                formData[child.name] = val;
            }
        }
    }

    // ok 為真才能送出
    if (ok) socket.emit("send", formData);
});

這樣前端網頁發現值為空時,除了警告提示外,也不會將事件送出去給伺服器。但這樣還不夠,如果有人直接對 WebSocket 送事件的話還是會被發現,所以我們要讓後端也能做一次驗證。

index.js中修改以下代碼:

//...
 
// 修改 send 事件監聽器
socket.on("send", (msg) => {
    // 如果 msg 內容鍵值小於 2 等於是訊息傳送不完全
    // 因此我們直接 return ,終止函式執行。
    if (Object.keys(msg).length < 2) return;
 
    // 廣播訊息到聊天室
    io.emit("msg", msg);
});

伺服器端的驗證很簡單,只判斷鍵值長度是否小於2,而2這個長度則是因為我們的訊息內寫包含有兩個東西﹙暱稱和訊息本體﹚。你可以嘗試把index.html中的if(ok)拿掉,然後試著不輸入任何東西直接送出,我相信伺服器他絕對是毫無反應,就只是個收到了,這樣。



這一篇教學就在這裡完結,如果你也有跟著時實做完文章內容,你應該會看到這樣的完整程式碼:

views/html

<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SimpleChat</title>
    <link rel="stylesheet" type="text/css" href="/css/style.css" />
    <script src="/socket.io/socket.io.js"></script>
    <script>
        var socket = io();
    </script>
</head>

<body>
    <div id="container">
        <div id="status-box">Server: <span id="status">-</span> / <span id="online">0</span> online.</div>
        <div id="content">
        </div>
        <div id="send-box">
            <form id="send-form">
                <input type="text" name="name" id="name" placeholder="Nickname">
                <input type="text" name="msg" id="msg" placeholder="Enter your message ...">
                <input type="submit" value="Send">
            </form>
        </div>
    </div>
 
    <script>
        document.addEventListener("DOMContentLoaded", () => {
            var status = document.getElementById("status");
            var online = document.getElementById("online");
            var sendForm = document.getElementById("send-form");
            var content = document.getElementById("content");
 
            socket.on("connect", function () {
                status.innerText = "Connected.";
            });
 
            socket.on("disconnect", function () {
                status.innerText = "Disconnected.";
            });
 
            socket.on("online", function (amount) {
                online.innerText = amount;
            });
 
            socket.on("msg", function (d) {
                var msgBox = document.createElement("div")
                msgBox.className = "msg";
                var nameBox = document.createElement("span");
                nameBox.className = "name";
                var name = document.createTextNode(d.name);
                var msg = document.createTextNode(d.msg);
 
                nameBox.appendChild(name);
                msgBox.appendChild(nameBox);
                msgBox.appendChild(msg);
                content.appendChild(msgBox);
            });
 
            sendForm.addEventListener("submit", function (e) {
                e.preventDefault();
 
                var ok = true;
                var formData = {};
                var formChild = sendForm.children;
 
                for (var i=0; i< sendForm.childElementCount; i++) {
                    var child = formChild[i];
                    if (child.name !== "") {
                        var val = child.value;
                        if (val === "" || !val) {
                            ok = false;
                            child.classList.add("error");
                        } else {
                            child.classList.remove("error");
                            formData[child.name] = val;
                        }
                    }
                }
 
                if (ok) socket.emit("send", formData);
            });
        });
    </script>
</body>

</html>



views/css/style.css

html,
body {
    padding: 0;
    margin: 0;
}

#container {
    top: 50px;
    width: 500px;
    margin: 0 auto;
    display: block;
    position: relative;
}

#status-box {
    text-align: right;
    font-size: .6em;
}

#content {
    width: 100%;
    height: 350px;
    border: 1px solid darkolivegreen;
    border-radius: 5px;
    overflow: auto;
}

#send-box {
    width: 100%;
    text-align: center;
}

#send-box input {
    display: inline-block;
}

#send-box input.error {
    border: 1px solid red;
}

input[name="name"] {
    width: 15%;
}

input[name="msg"] {
    width: 70%;
}

input[type="button"] {
    width: 10%;
}

.msg {
    width: 73%;
    display: inline-block;
    padding: 5px 0 5px 10px;
}

.msg>span {
    width: 25%;
    display: inline-block;
}

.msg>span::before {
    color: darkred;
    content: " { ";
}

.msg>span::after {
    color: darkred;
    content: " } ";
}



index.js

const express = require('express');
const app = express();
const server = require('http').Server(app);
const io = require('socket.io')(server);
const path = require('path');
 
// 加入線上人數計數
let onlineCount = 0;
 
app.use(express.static(path.join(__dirname, 'views')));
 
app.get('/', (req, res) => {
    res.sendFile( __dirname + '/views/index.html');
});
 
io.on('connection', (socket) => {
    console.log('System Message: server connected');
    // 有連線發生時增加人數
    onlineCount++;
    // 發送人數給網頁
    io.emit("online", onlineCount);
 
    socket.on("greet", () => {
        socket.emit("greet", onlineCount);
    });
 
    socket.on("send", (msg) => {
        // 如果 msg 內容鍵值小於 2 等於是訊息傳送不完全
        // 因此我們直接 return ,終止函式執行。
        if (Object.keys(msg).length < 2) return;
 
        // 廣播訊息到聊天室
        io.emit("msg", msg);
    });
 
    socket.on('disconnect', () => {
        console.log('System Message: server disconnected');
        // 有人離線了,扣人
        onlineCount = (onlineCount < 0) ? 0 : onlineCount-=1;
        io.emit("online", onlineCount);
    });
});
 
server.listen(80, () => {
    console.log("System Message: server started on http://localhost:80");
});



結語

在這篇教學中,我們利用Node.js跟Socket.io順利地建立了最簡易的聊天室。在下一篇日誌我們會再來強化一下我們的聊天室,有興趣的話可以繼續閱讀這篇文章:

相關

Modified by Alvin Au © 2023