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

在上一篇Node.js 與 Socket.io – 即時聊天室文章,我就假設你看過了也做完了,我們有了一個「超級無敵簡單」的聊天室,但那實在是太簡單了、太陽春了。

身為一個聊天室,它沒有前人的紀錄,也沒有每人專屬的名稱,喔對,還不會紀錄名稱!這真是太令人失望的聊天室,實在是太失望,完全就只是個拿來作為教學用的超級無敵簡單版本。作者到底做了什麼鬼東西啊!

喔等等,作者好像就是我。

好吧,為了盡棄前嫌,我決定來改良改良,增加一些新功能。

前情提要

請轉到Node.js 與 Socket.io – 即時聊天室實作收看。

名稱

第一個打算加入的是名稱,為什麼呢?因為比較簡單。

程式

views/insdex.html

// 加入 Cookies
function setCookie(cname, cvalue, exdays) {
    var d = new Date();
    d.setTime(d.getTime() + (exdays * 24 * 60 * 60 * 1000));
    var expires = "expires="+d.toUTCString();
    document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/";
}

function getCookie(cname) {
    var name = cname + "=";
    var ca = document.cookie.split(';');
    for(var i = 0; i < ca.length; i++) {
        var c = ca[i];
        while (c.charAt(0) == ' ') {
            c = c.substring(1);
        }
        if (c.indexOf(name) == 0) {
            return c.substring(name.length, c.length);
        }
    }
    return "";
}

// ...
var content = document.getElementById("content");
// 加入下面這些
var nameInputBox = document.getElementById("name");
var name = getCookie("name");

if (name) {
    nameInputBox.value = name;
}

// ....
if (ok) socket.emit("send", formData); // 這行替換成下面的程式片段

if (ok) {
    socket.emit("send", formData);
    setCookie("name", nameInputBox.value);
}

這部分我們新增了 Cookies 的存取,用來實現我們的名稱記錄的功能。

這功能會在第一次送出成功時,將名稱存入 Cookies 之中,之後用同一個瀏覽器進入聊天室,你的名稱將會維持與先前所輸入的名字一樣的名稱。

聊天記錄

程式

records.js

const {EventEmitter} = require("events");

let instance;
let data = [];
let MAX = 50;

class Records extends EventEmitter {
    constructor () {
        super();
    }

    push (msg) {
        data.push(msg);

        if (data.length > MAX) {
            data.splice(0, 1);
        }

        this.emit("new_message", msg);
    }

    get () {
        return data;
    }

    setMax (max) {
        MAX = max;
    }

    getMax () {
        return MAX;
    }
}

module.exports = (function () {
    if (!instance) {
        instance = new Records();
    }

    return instance;
})();

index.js

const server = require('http').Server(app);
const io = require('socket.io')(server);
const records = require('./records.js'); // 新增這行

// ...

io.on('connection', (socket) => {
    // 有連線發生時增加人數
    onlineCount++;
    // 發送人數給網頁
    io.emit("online", onlineCount);
    socket.emit("maxRecord", records.getMax());   // 新增記錄最大值,用來讓前端網頁知道要放多少筆
    socket.emit("chatRecord", records.get());     // 新增發送紀錄

    socket.on("send", (msg) => {
        // 如果 msg 內容鍵值小於 2 等於是訊息傳送不完全
        // 因此我們直接 return ,終止函式執行。
        if (Object.keys(msg).length < 2) return;
        records.push(msg);
        //io.emit("msg", msg); // 這行刪除改由 Records 事件接手
    });

    socket.on('disconnect', () => {
        // 有人離線了,扣人
        onlineCount = (onlineCount < 0) ? 0 : onlineCount-=1;
        io.emit("online", onlineCount);
    });
});

// 新增 Records 的事件監聽器
records.on("new_message", (msg) => {
    // 廣播訊息到聊天室
    io.emit("msg", msg);
});

views/index.html

document.addEventListener("DOMContentLoaded", () => {
    var max_record; // 新增
    // ...

    // 加入新的事件監聽器  
    socket.on("chatRecord", function (msgs) {
        for (var i=0; i < msgs.length; i++) {
            (function () {
                addMsgToBox(msgs[i]);
            })();
        }
    });

    socket.on("maxRecord", function (amount) {
        max_record = amount;
    });

    // 修改 msg 事件監聽器
    socket.on("msg", addMsgToBox);

    // 新增兩個 function
    // 新增訊息到方框中
    function addMsgToBox (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);

        if (content.children.length > max_record) {
            rmMsgFromBox();
        }
    }

    // 移除多餘的訊息
    function rmMsgFromBox () {
        var childs = content.children;
        childs[0].remove();
    }

在這邊我們新增了一個物件Records,它會將聊天記錄暫存於記憶體中,而當他收到新的訊息時,會通知伺服器主程式,然後伺服器再透過Websocket將訊息傳出去。此外,這個物件是全域型的物件,不過只會存在一個實體,是一種俗稱Singleton的一種程式設計方式。

而前端的調整主要是在第一次進入畫面,收到紀錄資料時的顯示方式,以及聊天筆數太多時要除掉舊的記錄這樣。

好了,啟動伺服器,連到http://localhost:3000,試試吧!

下一階:資料庫

這本篇教學中,我們已經成功地讓聊天訊息存在記憶體中,但重啟伺服器的話,這些存在記憶體中的暫時資料便會消失。為了解決每次重開伺服器聊天資料就被打掉重練的問題,我們可以為服務加入資料庫系統,有興趣的話可以繼續閱讀這篇文章:

完整程式

index.js

const express = require('express');
const app = express();
const server = require('http').Server(app);
const io = require('socket.io')(server);
const records = require('./records.js');
const port = process.env.PORT || 3000;

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

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

io.on('connection', (socket) => {
    // 有連線發生時增加人數
    onlineCount++;
    // 發送人數給網頁
    io.emit("online", onlineCount);
    // 發送紀錄最大值
    socket.emit("maxRecord", records.getMax());
    // 發送紀錄
    socket.emit("chatRecord", records.get());

    socket.on("greet", () => {
        socket.emit("greet", onlineCount);
    });

    socket.on("send", (msg) => {
        // 如果 msg 內容鍵值小於 2 等於是訊息傳送不完全
        // 因此我們直接 return ,終止函式執行。
        if (Object.keys(msg).length < 2) return;
        records.push(msg);
    });

    socket.on('disconnect', () => {
        // 有人離線了,扣人
        onlineCount = (onlineCount < 0) ? 0 : onlineCount-=1;
        io.emit("online", onlineCount);
    });
});

records.on("new_message", (msg) => {
    // 廣播訊息到聊天室
    io.emit("msg", msg);
});

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

records.js

const {EventEmitter} = require("events");

let instance;
let data = [];
let MAX = 50;

class Records extends EventEmitter {
    constructor () {
        super();
    }

    push (msg) {
        data.push(msg);

        if (data.length > MAX) {
            data.splice(0, 1);
        }

        this.emit("new_message", msg);
    }

    get () {
        return data;
    }

    setMax (max) {
        MAX = max;
    }

    getMax () {
        return MAX;
    }
}

module.exports = (function () {
    if (!instance) {
        instance = new Records();
    }

    return instance;
})();

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>
    <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 max_record;

            var status = document.getElementById("status");
            var online = document.getElementById("online");
            var sendForm = document.getElementById("send-form");
            var content = document.getElementById("content");
            var nameInputBox = document.getElementById("name");
            var name = getCookie("name");

            if (name) {
                nameInputBox.value = name;
            }

            socket.on("connect", function () {
                status.innerText = "Connected.";
            });

            socket.on("disconnect", function () {
                status.innerText = "Disconnected.";
            });

            socket.on("online", function (amount) {
                online.innerText = amount;
            });

            socket.on("maxRecord", function (amount) {
                max_record = amount;
            });

            socket.on("chatRecord", function (msgs) {
                for (var i=0; i < msgs.length; i++) {
                    (function () {
                        addMsgToBox(msgs[i]);
                    })();
                }
            });

            socket.on("msg", addMsgToBox);

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


                var ok = true;
                var formData = {
                    time: new Date().toUTCString()
                };
                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);
                    setCookie("name", nameInputBox.value);
                }
            });

            function addMsgToBox (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);

                if (content.children.length > max_record) {
                    rmMsgFromBox();
                }
            }

            function rmMsgFromBox () {
                var childs = content.children;
                childs[0].remove();
            }

            function setCookie(cname, cvalue, exdays) {
                var d = new Date();
                d.setTime(d.getTime() + (exdays * 24 * 60 * 60 * 1000));
                var expires = "expires="+d.toUTCString();
                document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/";
            }

            function getCookie(cname) {
                var name = cname + "=";
                var ca = document.cookie.split(';');
                for(var i = 0; i < ca.length; i++) {
                    var c = ca[i];
                    while (c.charAt(0) == ' ') {
                        c = c.substring(1);
                    }
                    if (c.indexOf(name) == 0) {
                        return c.substring(name.length, c.length);
                    }
                }
                return "";
            }
        });
    </script>
</body>
</html>

Have Fun

獨夜: