Node.js 與 Socket.io – 即時聊天室實作

許久沒有寫文章了(你好像常這樣說欸!),今天要來稍微講講專門用來實作即時通訊的 Node.js 模組 – Socket.io。

Socket.io 其實是一個完整實作 Websocket 的函式庫,他提供更簡單的方式讓開發者可以方便的使用 Websocket 這樣的通訊技術來實作許多應用。

我將以實作一個簡易聊天室作為範例來介紹這一個模組。

開始之前

這篇文章的程式範例將以 Node.js + Socket.io 為主,因此你必須先安裝好 Node.js 的環境,安裝方式請直接至官方網站下載安裝即可。

確認裝好 Node.js 的環境後,我們要來做一些基礎設定以及安裝該有的基本模組啦!請在你想要開發的資料夾中開啟終端機,輸入以下指令:

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

這兩行指令,第一行是在資料夾中產生一個 package.json 的檔案,這個檔案會紀錄專案的各種資訊,包含所相依的模組等等。

第二行指令則是安裝我們所需的兩種模組:Express.jssocket.io

接下來,我們在專案資料夾中開一個新檔案:index.js

一切就緒後,我們就可以開始進行了。

伺服器程式

HTTP

凡與 HTTP 有關係的東西就離不開 HTTP 伺服器啦!在 Node.js 中,建一個 HTTP 的伺服器非常簡單,大概就這不到 10 行的程式碼就能完成了吧。那個,別發呆,還不趕快把下面這幾行程式碼輸入到index.js中!

const express = require('express');
const app = express();

app.get('/', (req, res) => {
    res.send('Hello, World!');
});

app.listen(3000, () => {
    console.log("Server Started. http://localhost:3000");
});

然後輸入這個指令來啟動伺服器:

node index.js

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

Server Started. http://localhost:3000

好了,你可以打開瀏覽器,然後輸入這個網址:http://localhost:3000

你的瀏覽器應該會出現

Hello, World!

HTTP 伺服器完成!

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('Hello!');  // 顯示 Hello!

    // 當發生離線事件
    socket.on('disconnect', () => {
        console.log('Bye~');  // 顯示 bye~
    });
});

// 注意,這邊的 server 原本是 app
server.listen(3000, () => {
    console.log("Server Started. http://localhost:3000");
});

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

node index.js

然後你會發現什麼都沒有變

是的,因為我們的網站頁面還沒有放上去,當然什麼都不會變啊!畢竟這時候瀏覽器也不知道要打開Websocket呢!不信?那你連看看http://localhost:3000,看伺服器有沒有出現Hello啊!沒有對吧~

所以呢,接下來我們要透過網頁來與伺服器搭上線,抓緊,我們要準備起飛了。

網頁

伺服器大致完成後,要來處理人看得到的部分,也就是網頁呈現的部分。總不能只顯示個 Hello, World!,然後什麼事都不能做吧!?這樣還要聊個毛天啊!

所以,接下來請在你的專案資料夾下建立一個資料夾,這個資料夾專門用來存放網頁程式用,我想我們就叫做…views好了。

然後在views中建一個檔案:index.html。

這個檔案將會呈現我們聊天室的主要畫面,請記得在檔案裝輸入下方的程式碼。

<!DOCTYPE html>
<html lang="zh-tw">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My Chatroom</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 連接埠建立連線。要確認有無連線,請重新整理剛剛打開的瀏覽器畫面,然後按下重新整理,你應該會在終端機中看到Hello!:關閉網頁的話會看到Bye~喔。

如果沒的話,重新執行一次伺服器吧,請記得先用CTRL+C關閉伺服器再啟動。

收發測試

現在我們要來做一點實驗,我們先在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>

喔對,還有修改伺服器的程式

io.on('connection', (socket) => {
    console.log('Hello!');

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

//...
});

重新啟動伺服器然後重新整理網頁,你應該會看到網頁上顯示Hi! Client.,這代表一切正常沒有問題。

在這一部份中,我們重新調整了伺服器的程式,以及前端網頁的JavaScript程式。

在前端網頁的部分

  • 更改body的內容,加入一個div來顯示接收到的訊息
  • 加入新的 JavaScript 程式,觸發伺服器回傳訊息與接收訊息並用div顯示

在伺服器的部分

  • 接收前端網頁的事件
  • 回傳一個附帶內容的事件給前端網頁

介面設計

秉持著自己的玩具自己做的精神,本教學不使用過多額外的框架,避免過於複雜的學習難度,所以在這邊你會看到許多原生的語法以及有點糟糕的程式碼

喔對,如果要說框架的話,我有用到一個:Vanilla.js

回到正題。

一個正常的聊天室,我們需要幾個基本功能:

  • 伺服器狀態
  • 發言者名稱
  • 顯示聊天內容
    • 發言者
    • 發言時間
    • 訊息內容
  • 訊息輸入

所以我們的介面大概會長這樣…等等你就會看到了。

好,開始囉。

index.html

</head>

之前加入

<style>
    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: " } ";
    }        
</style>

<body>...</body>替換成

<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="暱稱">
                <input type="text" name="msg" id="msg" placeholder="說點什麼?">
                <input type="submit" value="送出">
            </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>

重新整理網頁後應會看到一個新的畫面,這時還沒有任何功能,雖然有點簡陋,不過該有的都有啦,別嫌 XD。

接下來我們要來修改伺服器的部分,順便讓伺服器狀態這個功能動起來。

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)
    });
    //...
});

現在重新啟動伺服器和網頁,然後嘗試輸入一些東西後送出,觀察終端機的變化吧~

這裡我們在前端網頁的部分加入

  • 表單送出(submit)事件監聽器
    • 取消原有的送出動作
    • 讀取表單內容
    • 透過 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);
    });

好,現在重啟伺服器,開兩個視窗,連上伺服器,你可以開始聊天了!(雖然是跟自己)

空白輸入

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

index.html

  1. CSS 部分加入
#send-box input.error {
    border: 1px solid red;
}
  1. 替換掉原本的submit監聽器。

//... 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這個長度則是因為我們的訊息內寫包含有兩個東西

  1. 暱稱
  2. 訊息本體

好了,你可以嘗試把index.html中的if(ok)拿掉,然後試著不輸入任何東西直接送出,我相信伺服器他絕對是毫無反應,就只是個收到了,這樣。

結束之後

Demo

如果你有點懶得動手做,這邊有實品展示可以玩玩。

下一階

這個聊天室加入全新的功能,有興趣的話可以到這邊來看看:http://single9.net/2018/01/node-js-與-socket-io-即時聊天室實作二/

完整程式

感謝你看完這篇文章,如果你也有跟著時實做完文章內容,你應該會看到這樣的完整程式碼:

index.html

<!DOCTYPE html>
<html lang="zh-tw">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My Chatroom</title>
    <script src="/socket.io/socket.io.js"></script>
    <script>
        var socket = io();
    </script>
    <style>
        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: " } ";
        }        
    </style>
</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="暱稱">
                <input type="text" name="msg" id="msg" placeholder="說點什麼?">
                <input type="submit" value="送出">
            </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>

index.js

const express = require('express');
const app = express();
const server = require('http').Server(app);
const io = require('socket.io')(server);

// 加入線上人數計數
let onlineCount = 0;

app.get('/', (req, res) => {
    res.sendFile( __dirname + '/views/index.html');
});

io.on('connection', (socket) => {
    // 有連線發生時增加人數
    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', () => {
        // 有人離線了,扣人
        onlineCount = (onlineCount < 0) ? 0 : onlineCount-=1;
        io.emit("online", onlineCount);
    });
});

server.listen(3000, () => {
    console.log("Server Started. http://localhost:3000");
});

最後

這篇文章重點:

  • Node.js 專案初始化
  • Node.js HTTP 伺服器
  • Socket.io 應用
  • 全端技能++

感謝收看,我們下一篇再見~

獨夜:

看評論 (5)

  • 剛剛留言,結果卻沒出現? 不知道是什麼問題,總之謝謝你們這篇文章。

    但我又回頭留言主要的原因是 .. 我留言之後,重新連到這篇文章時
    原本程式碼片段的是像 gist 那樣有帶顏色的。
    但被轉址後網址多了 /amp/ ,程式碼片段就變成純文字了 ...
    可是文章中有些連結又不會轉址,覺得怪怪的反應一下~
    還是有顏色比較容易閱讀啦 XD