1. 程式人生 > >Node.js + Web Socket 打造即時聊天程序嗨聊

Node.js + Web Socket 打造即時聊天程序嗨聊

入門教程 world rop 人員 ret blank over win splice

前端一直是一塊充滿驚喜的土地,不僅是那些富有創造性的頁面,還有那些驚贊的效果及不斷推出的新技術。像node.js這樣的後端開拓者直接將前端人員的能力擴大到了後端。瞬間就有了一統天下的感覺,來往穿梭於前後端之間代碼敲得飛起,從此由前端晉升為‘前後端‘。

技術分享

圖片來自G+

本文將使用Node.js加web socket協議打造一個網頁即時聊天程序,取名為HiChat,中文翻過來就是‘嗨聊‘,聽中文名有點像是專為寂寞單身男女打造的~

其中將會使用到express和socket.io兩個包模塊,下面會有介紹。

源碼&演示

在線演示 (heroku服務器網速略慢且免費套餐是小水管,建議下載代碼本地運行)

源碼可訪問項目的GitHub頁面下載

本地運行方法:

  • 命令行運行npm install
  • 模塊下載成功後,運行node server啟動服務器
  • 打開瀏覽器訪問localhost

下圖為效果預覽:

技術分享

準備工作

本文示例環境為Windows,Linux也就Node的安裝與命令行稍有區別,程序實現部分基本與平臺無關。

Node相關

  • 你需要在本機安裝Node.js(廢話)
  • 多少需要一點Node.js的基礎知識,如果還未曾了解過Node.js,這裏有一篇不錯的入門教程

然後我們就可以開始創建一個簡單的HTTP服務器啦。

類似下面非常簡單的代碼,它創建了一個HTTP服務器並監聽系統的80端口。

//node server example

//引入http模塊
var http = require(‘http‘),
    //創建一個服務器
    server = http.createServer(function(req, res) {
        res.writeHead(200, {
            ‘Content-Type‘: ‘text/plain‘
        });
        res.write(‘hello world!‘);
        res.end();
    });
//監聽80端口
server.listen(80);
console.log(‘server started‘);

將其保存為一個js文件比如server.js,然後從命令行運行node server或者node server.js,服務器便可啟動了,此刻我們可以在瀏覽器地址欄輸入localhost進行訪問,也可以輸入本機IP127.0.0.1,都不用加端口,因為我們服務器監聽的是默認的80端口。當然,如果你機子上面80端口被其他程序占用了,可以選擇其他端口比如8080,這樣訪問的時候需要顯示地加上端口號localhost:8080。

技術分享

技術分享

Express

首先通過npm進行安裝

  • 在我們的項目文件夾下打開命令行(tip: 按住Shift同時右擊,可以在右鍵菜單中找到‘從此處打開命令行‘選項)
  • 在命令行中輸入 npm install express 回車進行安裝
  • 然後在server.js中通過require(‘express‘)將其引入到項目中進行使用

express是node.js中管理路由響應請求的模塊,根據請求的URL返回相應的HTML頁面。這裏我們使用一個事先寫好的靜態頁面返回給客戶端,只需使用express指定要返回的頁面的路徑即可。如果不用這個包,我們需要將HTML代碼與後臺JavaScript代碼寫在一起進行請求的響應,不太方便。

//返回一個簡單的HTML內容

server = http.createServer(function(req, res) {
    res.writeHead(200, {
        ‘Content-Type‘: ‘text/html‘ //將返回類型由text/plain改為text/html
    });
    res.write(‘<h1>hello world!</h1>‘); //返回HTML標簽
    res.end();
});

在存放上一步創建的server.js文件的地方,我們新建一個文件夾名字為www用來存放我們的網頁文件,包括圖片以及前端的js文件等。假設已經在www文件夾下寫好了一個index.html文件(將在下一步介紹,這一步你可以放一個空的HTML文件),則可以通過以下方式使用express將該頁面返回到瀏覽器。可以看到較最開始,我們的服務器代碼簡潔了不少。

//使用express模塊返回靜態頁面

var express = require(‘express‘), //引入express模塊
    app = express(),
    server = require(‘http‘).createServer(app);
app.use(‘/‘, express.static(__dirname + ‘/www‘)); //指定靜態HTML文件的位置
server.listen(80);

技術分享

其中有四個按鈕,分別是設置字體顏色,發送表情,發送圖片和清除記錄,將會在下面介紹其實現

socket.io

Node.js中使用socket的一個包。使用它可以很方便地建立服務器到客戶端的sockets連接,發送事件與接收特定事件。

同樣通過npm進行安裝 npm install socket.io 。安裝後在node_modules文件夾下新生成了一個socket.io文件夾,其中我們可以找到一個socket.io.js文件。將它引入到HTML頁面,這樣我們就可以在前端使用socket.io與服務器進行通信了。

<script src="/socket.io/socket.io.js"></script>

同時服務器端的server.js裏跟使用express一樣,也要通過require(‘socket.io‘)將其引入到項目中,這樣就可以在服務器端使用socket.io了。

使用socket.io,其前後端句法是一致的,即通過socket.emit()來激發一個事件,通過socket.on()來偵聽和處理對應事件。這兩個事件通過傳遞的參數進行通信。具體工作模式可以看下面這個示例。

比如我們在index.html裏面有如下JavaScript代碼(假設你已經在頁面放了一個ID為sendBtn的按鈕):

<script type="text/javascript">
	var socket=io.connect(),//與服務器進行連接
		button=document.getElementById(‘sendBtn‘);
	button.onclick=function(){
		socket.emit(‘foo‘, ‘hello‘);//發送一個名為foo的事件,並且傳遞一個字符串數據‘hello’
	}
</script>

上述代碼首先建立與服務器的連接,然後得到一個socket實例。之後如果頁面上面一個ID為sendBtn的按鈕被點擊的話,我們就通過這個socket實例發起一個名為foo的事件,同時傳遞一個hello字符串信息到服務器。

與此同時,我們需要在服務器端寫相應的代碼來處理這個foo事件並接收傳遞來的數據。

為此,我們在server.js中可以這樣寫:

//服務器及頁面響應部分
var express = require(‘express‘),
    app = express(),
    server = require(‘http‘).createServer(app),
    io = require(‘socket.io‘).listen(server); //引入socket.io模塊並綁定到服務器
app.use(‘/‘, express.static(__dirname + ‘/www‘));
server.listen(80);

//socket部分
io.on(‘connection‘, function(socket) {
    //接收並處理客戶端發送的foo事件
    socket.on(‘foo‘, function(data) {
        //將消息輸出到控制臺
        console.log(data);
    })
});

現在Ctrl+C關閉之前啟動的服務器,再次輸入node server啟動服務器運行新代碼查看效果,一切正常的話你會在點擊了頁面的按扭後,在命令行窗口裏看到輸出的‘hello‘字符串。

技術分享

一如之前所說,socket.io在前後端的句法是一致的,所以相反地,從服務器發送事件到客戶端,在客戶端接收並處理消息也是顯而易見的事件了。這裏只是簡單介紹,具體下面會通過發送聊天消息進一步介紹。

基本頁面

有了上面一些基礎的了解,下面可以進入聊天程序功能的開發了。

首先我們構建主頁面。因為是比較大眾化的應用了,界面不用多想,腦海中已經有大致的雛形,它有一個呈現消息的主窗體,還有一個輸入消息的文本框,同時需要一個發送消息的按鈕,這三個是必備的。

另外就是,這裏還準備實現以下四個功能,所以界面上還有設置字體顏色,發送表情,發送圖片和清除記錄四個按鈕。

最後的頁面也就是先前截圖展示的那們,而代碼如下:

www/index.html

<!doctype html>
<html>
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
        <meta name="author" content="Wayou">
        <meta name="description" content="hichat | a simple chat application built with node.js and websocket">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>hichat</title>
        <link rel="stylesheet" href="styles/main.css">
        <link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
        <link rel="icon" href="favicon.ico" type="image/x-icon">
    </head>
    <body>
        <div class="wrapper">
            <div class="banner">
                <h1>HiChat :)</h1>
                <span id="status"></span>
            </div>
            <div id="historyMsg">
            </div>
            <div class="controls" >
                <div class="items">
                    <input id="colorStyle" type="color" placeHolder=‘#000‘ title="font color" />
                    <input id="emoji" type="button" value="emoji" title="emoji" />
                    <label for="sendImage" class="imageLable">
                        <input type="button" value="image"  />
                        <input id="sendImage" type="file" value="image"/>
                    </label>
                    <input id="clearBtn" type="button" value="clear" title="clear screen" />
                </div>
                <textarea id="messageInput" placeHolder="enter to send"></textarea>
                <input id="sendBtn" type="button" value="SEND">
                <div id="emojiWrapper">
                </div>
            </div>
        </div>
        <div id="loginWrapper">
            <p id="info">connecting to server...</p>
            <div id="nickWrapper">
                <input type="text" placeHolder="nickname" id="nicknameInput" />
                <input type="button" value="OK" id="loginBtn" />
            </div>
        </div>
        <script src="/socket.io/socket.io.js"></script>
        <script src="scripts/hichat.js"></script>
    </body>
</html>

樣式文件 www/styles/main.css

html, body {
    margin: 0;
    background-color: #efefef;
    font-family: sans-serif;
}
.wrapper {
    width: 500px;
    height: 640px;
    padding: 5px;
    margin: 0 auto;
    background-color: #ddd;
}
#loginWrapper {
    position: fixed;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    background-color: rgba(5, 5, 5, .6);
    text-align: center;
    color: #fff;
    display: block;
    padding-top: 200px;
}
#nickWrapper {
    display: none;
}
.banner {
    height: 80px;
    width: 100%;
}
.banner p {
    float: left;
    display: inline-block;
}
.controls {
    height: 100px;
    margin: 5px 0px;
    position: relative;
}
#historyMsg {
    height: 400px;
    background-color: #fff;
    overflow: auto;
    padding: 2px;
}
#historyMsg img {
    max-width: 99%;
}
.timespan {
    color: #ddd;
}
.items {
    height: 30px;
}
#colorStyle {
    width: 50px;
    border: none;
    padding: 0;
}
/*custom the file input*/

.imageLable {
    position: relative;
}
#sendImage {
    position: absolute;
    width: 52px;
    left: 0;
    opacity: 0;
    overflow: hidden;
}
/*end custom file input*/

#messageInput {
    width: 440px;
    max-width: 440px;
    height: 90px;
    max-height: 90px;
}
#sendBtn {
    width: 50px;
    height: 96px;
    float: right;
}
#emojiWrapper {
    display: none;
    width: 500px;
    bottom: 105px;
    position: absolute;
    background-color: #aaa;
    box-shadow: 0 0 10px #555;
}
#emojiWrapper img {
    margin: 2px;
    padding: 2px;
    width: 25px;
    height: 25px;
}
#emojiWrapper img:hover {
    background-color: blue;
}
.emoji{
    display: inline;
}
footer {
    text-align: center;
}

為了讓項目有一個良好的目錄結構便於管理,這裏在www文件夾下又新建了一個styles文件夾存放樣式文件main.css,然後新建一個scripts文件夾存放前端需要使用的js文件比如hichat.js(我們前端所有的js代碼會放在這個文件中),而我們的服務器js文件server.js位置不變還是放在最外層。

同時再新建一個content文件夾用於存放其他資源比如圖片等,其中content文件夾裏再建一個emoji文件夾用於存入表情gif圖,後面會用到。最後我們項目的目錄結構應該是這樣的了:

├─node_modules
└─www
    ├─content
    │  └─emoji
    ├─scripts
    └─styles

此刻打開頁面你看到的是一個淡黑色的遮罩層,而接下來我們要實現的是用戶昵稱的輸入與服務器登入。這個遮罩層用於顯示連接到服務器的狀態信息,而當連接完成之後,會出現一個輸入框用於昵稱輸入。

技術分享

上面HTML代碼裏已經看到,我們將www/scripts/hichat.js文件已經引入到頁面了,下面開始寫一些基本的前端js開始實現連接功能。

定義一個全局變量用於我們整個程序的開發HiChat,同時使用window.onload在頁面準備好之後實例化HiChat,調用其init方法運行我們的程序。

www/scripts/Hichat.js

window.onload = function() {
    //實例並初始化我們的hichat程序
    var hichat = new HiChat();
    hichat.init();
};

//定義我們的hichat類
var HiChat = function() {
    this.socket = null;
};

//向原型添加業務方法
HiChat.prototype = {
    init: function() {//此方法初始化程序
        var that = this;
        //建立到服務器的socket連接
        this.socket = io.connect();
        //監聽socket的connect事件,此事件表示連接已經建立
        this.socket.on(‘connect‘, function() {
            //連接到服務器後,顯示昵稱輸入框
            document.getElementById(‘info‘).textContent = ‘get yourself a nickname :)‘;
            document.getElementById(‘nickWrapper‘).style.display = ‘block‘;
            document.getElementById(‘nicknameInput‘).focus();
        });
    }
};

上面的代碼定義了整個程序需要使用的類HiChat,之後我們處理消息顯示消息等所有業務邏輯均寫在這個類裏面。

首先定義了一個程序的初始化方法,這裏面初始化socket,監聽連接事件,一旦連接到服務器,便顯示昵稱輸入框。當用戶輸入昵稱後,便可以在服務器後臺接收到然後進行下一步的處理了。

技術分享

設置昵稱

我們要求連接的用戶需要首先設置一個昵稱,且這個昵稱還要唯一,也就是不能與別人同名。一是方便用戶區分,二是為了統計在線人數,同時也方便維護一個保存所有用戶昵稱的數組。

為此在後臺server.js中,我們創建一個名叫users的全局數組變量,當一個用戶設置好昵稱發送到服務器的時候,將昵稱壓入users數組。同時註意,如果用戶斷線離開了,也要相應地從users數組中移除以保證數據的正確性。

在前臺,輸入昵稱點擊OK提交後,我們需要發起一個設置昵稱的事件以便服務器偵聽到。將以下代碼添加到之前的init方法中。

www/scripts/hichat.js

//昵稱設置的確定按鈕
document.getElementById(‘loginBtn‘).addEventListener(‘click‘, function() {
    var nickName = document.getElementById(‘nicknameInput‘).value;
    //檢查昵稱輸入框是否為空
    if (nickName.trim().length != 0) {
        //不為空,則發起一個login事件並將輸入的昵稱發送到服務器
        that.socket.emit(‘login‘, nickName);
    } else {
        //否則輸入框獲得焦點
        document.getElementById(‘nicknameInput‘).focus();
    };
}, false);

server.js

//服務器及頁面部分
var express = require(‘express‘),
    app = express(),
    server = require(‘http‘).createServer(app),
    io = require(‘socket.io‘).listen(server),
    users=[];//保存所有在線用戶的昵稱
app.use(‘/‘, express.static(__dirname + ‘/www‘));
server.listen(80);
//socket部分
io.on(‘connection‘, function(socket) {
    //昵稱設置
    socket.on(‘login‘, function(nickname) {
        if (users.indexOf(nickname) > -1) {
            socket.emit(‘nickExisted‘);
        } else {
            socket.userIndex = users.length;
            socket.nickname = nickname;
            users.push(nickname);
            socket.emit(‘loginSuccess‘);
            io.sockets.emit(‘system‘, nickname); //向所有連接到服務器的客戶端發送當前登陸用戶的昵稱 
        };
    });
});

需要解釋一下的是,在connection事件的回調函數中,socket表示的是當前連接到服務器的那個客戶端。所以代碼socket.emit(‘foo‘)則只有自己收得到這個事件,而socket.broadcast.emit(‘foo‘)則表示向除自己外的所有人發送該事件,另外,上面代碼中,io表示服務器整個socket連接,所以代碼io.sockets.emit(‘foo‘)表示所有人都可以收到該事件。

上面代碼先判斷接收到的昵稱是否已經存在在users中,如果存在,則向自己發送一個nickExisted事件,在前端接收到這個事件後我們顯示一條信息通知用戶。

將下面代碼添加到hichat.js的inti方法中。

www/scripts/hichat.js

this.socket.on(‘nickExisted‘, function() {
     document.getElementById(‘info‘).textContent = ‘!nickname is taken, choose another pls‘; //顯示昵稱被占用的提示
 });

如果昵稱沒有被其他用戶占用,則將這個昵稱壓入users數組,同時將其作為一個屬性存到當前socket變量中,並且將這個用戶在數組中的索引(因為是數組最後一個元素,所以索引就是數組的長度users.length)也作為屬性保存到socket中,後面會用到。最後向自己發送一個loginSuccess事件,通知前端登陸成功,前端接收到這個成功消息後將灰色遮罩層移除顯示聊天界面。

將下面代碼添加到hichat.js的inti方法中。

www/scripts/hichat.js

this.socket.on(‘loginSuccess‘, function() {
     document.title = ‘hichat | ‘ + document.getElementById(‘nicknameInput‘).value;
     document.getElementById(‘loginWrapper‘).style.display = ‘none‘;//隱藏遮罩層顯聊天界面
     document.getElementById(‘messageInput‘).focus();//讓消息輸入框獲得焦點
 });

在線統計

這裏實現顯示在線用戶數及在聊天主界面中以系統身份顯示用戶連接離開等信息。

上面server.js中除了loginSuccess事件,後面還有一句代碼,通過io.sockets.emit 向所有用戶發送了一個system事件,傳遞了剛登入用戶的昵稱,所有人接收到這個事件後,會在聊天窗口顯示一條系統消息‘某某加入了聊天室‘。同時考慮到在前端我們無法得知用戶是進入還是離開,所以在這個system事件裏我們多傳遞一個數據來表明用戶是進入還是離開。

將server.js中login事件更改如下:

server.js

socket.on(‘login‘, function(nickname) {
     if (users.indexOf(nickname) > -1) {
         socket.emit(‘nickExisted‘);
     } else {
         socket.userIndex = users.length;
         socket.nickname = nickname;
         users.push(nickname);
         socket.emit(‘loginSuccess‘);
         io.sockets.emit(‘system‘, nickname, users.length, ‘login‘);
     };
 });

較之前,多傳遞了一個login字符串。

同時再添加一個用戶離開的事件,這個可能通過socket.io自帶的disconnect事件完成,當一個用戶斷開連接,disconnect事件就會觸發。在這個事件中,做兩件事情,一是將用戶從users數組中刪除,一是發送一個system事件通知所有人‘某某離開了聊天室‘。

將以下代碼添加到server.js中connection的回調函數中。

server.js

//斷開連接的事件
socket.on(‘disconnect‘, function() {
    //將斷開連接的用戶從users中刪除
    users.splice(socket.userIndex, 1);
    //通知除自己以外的所有人
    socket.broadcast.emit(‘system‘, socket.nickname, users.length, ‘logout‘);
});

上面代碼通過JavaScript數組的splice方法將當前斷開連接的用戶從users數組中刪除,這裏我們看到之前保存的用戶索引被使用了。同時發送和用戶連接時一樣的system事件通知所有人‘某某離開了‘,為了讓前端知道是離開事件,所以發送了一個‘logout‘字符串。

下面開始前端的實現,也就是接收system事件。

在hichat.js中,將以下代碼添加到init方法中。

www/scripts/hichat.js

this.socket.on(‘system‘, function(nickName, userCount, type) {
     //判斷用戶是連接還是離開以顯示不同的信息
     var msg = nickName + (type == ‘login‘ ? ‘ joined‘ : ‘ left‘);
     var p = document.createElement(‘p‘);
     p.textContent = msg;
     document.getElementById(‘historyMsg‘).appendChild(p);
     //將在線人數顯示到頁面頂部
     document.getElementById(‘status‘).textContent = userCount + (userCount > 1 ? ‘ users‘ : ‘ user‘) + ‘ online‘;
 });

現在運行程序,打開多個瀏覽器標簽,然後登陸離開,你就可以看到相應的系統提示消息了。

技術分享

發送消息

用戶連接以及斷開我們需要顯示系統消息,用戶還要頻繁的發送聊天消息,所以可以考慮將消息顯示到頁面這個功能單獨寫一個函數方便我們調用。為此我們向HiChat類中添加一個_displayNewMsg的方法,它接收要顯示的消息,消息來自誰,以及一個顏色共三個參數。因為我們想系統消息區別於普通用戶的消息,所以增加一個顏色參數。同時這個參數也方便我們之後實現讓用戶自定義文本顏色做準備。

將以下代碼添加到的我的HiChat類當中。

www/scripts/hichat.js

//向原型添加業務方法
HiChat.prototype = {
    init: function() { //此方法初始化程序
        //...
    },
    _displayNewMsg: function(user, msg, color) {
        var container = document.getElementById(‘historyMsg‘),
            msgToDisplay = document.createElement(‘p‘),
            date = new Date().toTimeString().substr(0, 8);
        msgToDisplay.style.color = color || ‘#000‘;
        msgToDisplay.innerHTML = user + ‘<span class="timespan">(‘ + date + ‘): </span>‘ + msg;
        container.appendChild(msgToDisplay);
        container.scrollTop = container.scrollHeight;
    }
};

在_displayNewMsg方法中,我們還向消息添加了一個日期。我們也判斷了該方法在調用時有沒有傳遞顏色參數,沒有傳遞顏色的話默認使用#000即黑色。

同時修改我們在system事件中顯示系統消息的代碼,讓它調用這個_displayNewMsg方法。

www/scripts/hichat.js

this.socket.on(‘system‘, function(nickName, userCount, type) {
    var msg = nickName + (type == ‘login‘ ? ‘ joined‘ : ‘ left‘);
    //指定系統消息顯示為紅色
    that._displayNewMsg(‘system ‘, msg, ‘red‘);
    document.getElementById(‘status‘).textContent = userCount + (userCount > 1 ? ‘ users‘ : ‘ user‘) + ‘ online‘;
});

現在的效果如下:

技術分享

有了這個顯示消息的方法後,下面就開始實現用戶之間的聊天功能了。

做法也很簡單,如果你掌握了上面所描述的emit發送事件,on接收事件,那麽用戶聊天消息的發送接收也就輕車熟路了。

首先為頁面的發送按鈕寫一個click事件處理程序,我們通過addEventListner來監聽這個click事件,當用戶點擊發送的時候,先檢查輸入框是否為空,如果不為空,則向服務器發送postMsg事件,將用戶輸入的聊天文本發送到服務器,由服務器接收並分發到除自己外的所有用戶。

將以下代碼添加到hichat.js的inti方法中。

www/scripts/hichat.js

document.getElementById(‘sendBtn‘).addEventListener(‘click‘, function() {
    var messageInput = document.getElementById(‘messageInput‘),
        msg = messageInput.value;
    messageInput.value = ‘‘;
    messageInput.focus();
    if (msg.trim().length != 0) {
        that.socket.emit(‘postMsg‘, msg); //把消息發送到服務器
        that._displayNewMsg(‘me‘, msg); //把自己的消息顯示到自己的窗口中
    };
}, false);

在server.js中添加代碼以接收postMsg事件。

server.js

io.on(‘connection‘, function(socket) {
    //其他代碼。。。

    //接收新消息
    socket.on(‘postMsg‘, function(msg) {
        //將消息發送到除自己外的所有用戶
        socket.broadcast.emit(‘newMsg‘, socket.nickname, msg);
    });
});

然後在客戶端接收服務器發送的newMsg事件,並將聊天消息顯示到頁面。

將以下代碼顯示添加到hichat.js的init方法中了。

this.socket.on(‘newMsg‘, function(user, msg) {
    that._displayNewMsg(user, msg);
});

運行程序,現在可以發送聊天消息了。

技術分享

發送圖片

上面已經實現了基本的聊天功能了,進一步,如果我們還想讓用戶可以發送圖片,那程序便更加完美了。

圖片不同於文字,但通過將圖片轉化為字符串形式後,便可以像發送普通文本消息一樣發送圖片了,只是在顯示的時候將它還原為圖片。

在這之前,我們已經將圖片按鈕在頁面放好了,其實是一個文件類型的input,下面只需在它身上做功夫便可。

用戶點擊圖片按鈕後,彈出文件選擇窗口供用戶選擇圖片。之後我們可以在JavaScript代碼中使用FileReader來將圖片讀取為base64格式的字符串形式進行發送。而base64格式的圖片直接可以指定為圖片的src,這樣就可以將圖片用img標簽顯示在頁面了。

為此我們監聽圖片按鈕的change事件,一但用戶選擇了圖片,便顯示到自己的屏幕上同時讀取為文本發送到服務器。

將以下代碼添加到hichat.js的init方法中。

www/scripts/hichat.js

document.getElementById(‘sendImage‘).addEventListener(‘change‘, function() {
    //檢查是否有文件被選中
     if (this.files.length != 0) {
        //獲取文件並用FileReader進行讀取
         var file = this.files[0],
             reader = new FileReader();
         if (!reader) {
             that._displayNewMsg(‘system‘, ‘!your browser doesn\‘t support fileReader‘, ‘red‘);
             this.value = ‘‘;
             return;
         };
         reader.onload = function(e) {
            //讀取成功,顯示到頁面並發送到服務器
             this.value = ‘‘;
             that.socket.emit(‘img‘, e.target.result);
             that._displayImage(‘me‘, e.target.result);
         };
         reader.readAsDataURL(file);
     };
 }, false);

上面圖片讀取成功後,調用_displayNImage方法將圖片顯示在自己的屏幕同時向服務器發送了一個img事件,在server.js中,我們通過這個事件來接收並分發圖片到每個用戶。同時也意味著我們還要在前端寫相應的代碼來接收。

這個_displayNImage還沒有實現,將會在下面介紹。

將以下代碼添加到server.js的socket回調函數中。

server.js

//接收用戶發來的圖片
 socket.on(‘img‘, function(imgData) {
    //通過一個newImg事件分發到除自己外的每個用戶
     socket.broadcast.emit(‘newImg‘, socket.nickname, imgData);
 });

同時向hichat.js的init方法添加以下代碼以接收顯示圖片。

 this.socket.on(‘newImg‘, function(user, img) {
     that._displayImage(user, img);
 });

有個問題就是如果圖片過大,會破壞整個窗口的布局,或者會出現水平滾動條,所以我們對圖片進行樣式上的設置讓它最多只能以聊天窗口的99%寬度來顯示,這樣過大的圖片就會自己縮小了。

#historyMsg img {
    max-width: 99%;
}

但考慮到縮小後的圖片有可能失真,用戶看不清,我們需要提供一個方法讓用戶可以查看原尺寸大小的圖片,所以將圖片用一個鏈接進行包裹,當點擊圖片的時候我們打開一個新的窗口頁面,並將圖片按原始大小呈現到這個新頁面中讓用戶查看。

所以最後我們實現的_displayNImage方法應該是這樣的。

將以下代碼添加到hichat.js的HiChat類中。

www/scripts/hichat.js

_displayImage: function(user, imgData, color) {
    var container = document.getElementById(‘historyMsg‘),
        msgToDisplay = document.createElement(‘p‘),
        date = new Date().toTimeString().substr(0, 8);
    msgToDisplay.style.color = color || ‘#000‘;
    msgToDisplay.innerHTML = user + ‘<span class="timespan">(‘ + date + ‘): </span> <br/>‘ + ‘<a href="‘ + imgData + ‘" target="_blank"><img src="‘ + imgData + ‘"/></a>‘;
    container.appendChild(msgToDisplay);
    container.scrollTop = container.scrollHeight;
}

再次啟動服務器打開程序,我們可以發送圖片了。

技術分享

發送表情

文字總是很難表達出說話時的面部表情的,於是表情就誕生了。

前面已經介紹過如何發送圖片了,嚴格來說,表情也是圖片,但它有特殊之處,因為表情可以穿插在文字中一並發送,所以就不能像處理圖片那樣來處理表情了。

根據以往的經驗,其他聊天程序是把表情轉為符號,比如我想發笑臉,並且規定‘:)‘這個符號代碼笑臉表情,然後數據傳輸過程中其實轉輸的是一個冒號加右括號的組合,當每個客戶端接收到消息後,從文字當中將這些表情符號提取出來,再用gif圖片替換,這樣呈現到頁面我們就 看到了表情加文字的混排了。

你好,王尼瑪[emoji:23]------>你好,王尼瑪技術分享

上面形象地展示了我們程序中表情的使用,可以看出我規定了一種格式來代表表情,[emoji:xx],中括號括起來然後‘emoji‘加個冒號,後面跟一個數字,這個數字表示某個gif圖片的編號。程序中,如果我們點擊表情按扭,然後呈現所有可用的表情圖片,當用戶選擇一個表情後,生成對應的代碼插入到當前待發送的文字消息中。發出去後,每個人接收到的也是代碼形式的消息,只是在將消息顯示到頁面前,我們將表情代碼提取出來,獲取圖片編號,然後用相應的圖片替換。

首先得將所有可用的表情圖片顯示到一個小窗口,這個窗口會在點擊了表情按鈕後顯示如下圖,在HTML代碼中已經添加好了這個窗口了,下面只需實現代碼部分。

技術分享

我們使用兔斯基作為我們聊天程序的表情包。可以看到,有很多張gif圖,如果手動編寫的話,要花一些功夫,不斷地寫<img src=‘xx.gif‘/>,所以考慮將這個工作交給代碼來自動完成,寫一個方法來初始化所有表情。

為此將以下代碼添加到HiChat類中,並在init方法中調用這個方法。

www/scripts/hichat.js

_initialEmoji: function() {
    var emojiContainer = document.getElementById(‘emojiWrapper‘),
        docFragment = document.createDocumentFragment();
    for (var i = 69; i > 0; i--) {
        var emojiItem = document.createElement(‘img‘);
        emojiItem.src = ‘../content/emoji/‘ + i + ‘.gif‘;
        emojiItem.title = i;
        docFragment.appendChild(emojiItem);
    };
    emojiContainer.appendChild(docFragment);
}

同時將以下代碼添加到hichat.js的init方法中。

www/scripts/hichat.js

this._initialEmoji();
 document.getElementById(‘emoji‘).addEventListener(‘click‘, function(e) {
     var emojiwrapper = document.getElementById(‘emojiWrapper‘);
     emojiwrapper.style.display = ‘block‘;
     e.stopPropagation();
 }, false);
 document.body.addEventListener(‘click‘, function(e) {
     var emojiwrapper = document.getElementById(‘emojiWrapper‘);
     if (e.target != emojiwrapper) {
         emojiwrapper.style.display = ‘none‘;
     };
 });

上面向頁面添加了兩個單擊事件,一是表情按鈕單擊顯示表情窗口,二是點擊頁面其他地方關閉表情窗口。

現在要做的就是,具體到某個表情被選中後,需要獲取被選中的表情,然後轉換為相應的表情代碼插入到消息框中。

為此我們再寫一個這些圖片的click事件處理程序。將以下代碼添加到hichat.js的inti方法中。

www/scripts/hichat.js

document.getElementById(‘emojiWrapper‘).addEventListener(‘click‘, function(e) {
    //獲取被點擊的表情
    var target = e.target;
    if (target.nodeName.toLowerCase() == ‘img‘) {
        var messageInput = document.getElementById(‘messageInput‘);
        messageInput.focus();
        messageInput.value = messageInput.value + ‘[emoji:‘ + target.title + ‘]‘;
    };
}, false);

現在表情選中後,消息輸入框中可以得到相應的代碼了。

技術分享

之後的發送也普通消息發送沒區別,因為之前已經實現了文本消息的發送了,所以這裏不用再實現什麽,只是需要更改一下之前我們用來顯示消息的代碼,首先判斷消息文本中是否含有表情符號,如果有,則轉換為圖片,最後再顯示到頁面。

為此我們寫一個方法接收文本消息為參數,用正則搜索其中的表情符號,將其替換為img標簽,最後返回處理好的文本消息。

將以下代碼添加到HiChat類中。

www/scripts/hichat.js

_showEmoji: function(msg) {
    var match, result = msg,
        reg = /\[emoji:\d+\]/g,
        emojiIndex,
        totalEmojiNum = document.getElementById(‘emojiWrapper‘).children.length;
    while (match = reg.exec(msg)) {
        emojiIndex = match[0].slice(7, -1);
        if (emojiIndex > totalEmojiNum) {
            result = result.replace(match[0], ‘[X]‘);
        } else {
            result = result.replace(match[0], ‘<img class="emoji" src="../content/emoji/‘ + emojiIndex + ‘.gif" />‘);
        };
    };
    return result;
}

現在去修改之前我們顯示消息的_displayNewMsg方法,讓它在顯示消息之前調用這個_showEmoji方法。

_displayNewMsg: function(user, msg, color) {
     var container = document.getElementById(‘historyMsg‘),
         msgToDisplay = document.createElement(‘p‘),
         date = new Date().toTimeString().substr(0, 8),
         //將消息中的表情轉換為圖片
         msg = this._showEmoji(msg);
     msgToDisplay.style.color = color || ‘#000‘;
     msgToDisplay.innerHTML = user + ‘<span class="timespan">(‘ + date + ‘): </span>‘ + msg;
     container.appendChild(msgToDisplay);
     container.scrollTop = container.scrollHeight;
 }

下面是實現後的效果:

技術分享

主要功能已經完成得差不多了,為了讓程序更加人性與美觀,可以加入一個修改文字顏色的功能,以及鍵盤快捷鍵操作的支持,這也是一般聊天程序都有的功能,回車即可以發送消息。

文字顏色

萬幸,HTML5新增了一個專門用於顏色選取的input標簽,並且Chrome對它的支持非常之贊,直接彈出系統的顏色拾取窗口。

技術分享

IE及FF中均是一個普通的文本框,不過不影響使用,只是用戶只能通過輸入具體的顏色值來進行顏色設置,沒有Chrome裏面那麽方便也直觀。

之前我們的_displayNewMsg方法可以接收一個color參數,現在要做的就是每次發送消息到服務器的時候,多加一個color參數就可以了,同時,在顯示消息時調用_displayNewMsg的時候將這個color傳遞過去。

下面是修改hichat.js中消息發送按鈕代碼的示例:

document.getElementById(‘sendBtn‘).addEventListener(‘click‘, function() {
    var messageInput = document.getElementById(‘messageInput‘),
        msg = messageInput.value,
        //獲取顏色值
        color = document.getElementById(‘colorStyle‘).value;
    messageInput.value = ‘‘;
    messageInput.focus();
    if (msg.trim().length != 0) {
        //顯示和發送時帶上顏色值參數
        that.socket.emit(‘postMsg‘, msg, color);
        that._displayNewMsg(‘me‘, msg, color);
    };
}, false);

同時修改hichat.js中接收消息的代碼,讓它接收顏色值

this.socket.on(‘newMsg‘, function(user, msg, color) {
     that._displayNewMsg(user, msg, color);
 });

這只是展示了發送按鈕的修改,改動非常小,只是每次消息發送時獲取一下顏色值,同時emit事件到服務器的時候也帶上這個顏色值,這樣前端在顯示時就可以根據這個顏色值為每個不兩只用戶顯示他們自己設置的顏色了。剩下的就是按相同的做法把發送圖片時也加上顏色,這裏省略。

最後效果:

技術分享

按鍵操作

將以下代碼添加到hichat.js的inti方法中,這樣在輸入昵稱後,按回車鍵就可以登陸,進入聊天界面後,回車鍵可以發送消息。

document.getElementById(‘nicknameInput‘).addEventListener(‘keyup‘, function(e) {
      if (e.keyCode == 13) {
          var nickName = document.getElementById(‘nicknameInput‘).value;
          if (nickName.trim().length != 0) {
              that.socket.emit(‘login‘, nickName);
          };
      };
  }, false);
  document.getElementById(‘messageInput‘).addEventListener(‘keyup‘, function(e) {
      var messageInput = document.getElementById(‘messageInput‘),
          msg = messageInput.value,
          color = document.getElementById(‘colorStyle‘).value;
      if (e.keyCode == 13 && msg.trim().length != 0) {
          messageInput.value = ‘‘;
          that.socket.emit(‘postMsg‘, msg, color);
          that._displayNewMsg(‘me‘, msg, color);
      };
  }, false);

部署上線

最後一步,當然就是將我們的辛勤結晶部署到實際的站點。這應該是最激動人心也是如釋重負的一刻。但在這之前,讓我們先添加一個node.js程序通用的package.json文件,該文件裏面可以指定我們的程序使用了哪些模塊,這樣別人在獲取到代碼後,只需通過npm install命令就可以自己下載安裝程序中需要的模塊了,而不用我們把模塊隨源碼一起發布。

添加package.json文件

將以下代碼保存為package.json保存到跟server.js相同的位置。

{
    "name": "hichat",
    "description": "a realtime chat web application",
    "version": "0.4.0",
    "main": "server.js",
    "dependencies": {
        "express": "3.4.x",
        "socket.io": "0.9.x"
    },
    "engines": {
        "node": "0.10.x",
        "npm": "1.2.x"
    }
}

雲服務選擇與部署

首先我們得選擇一個支持Node.js同時又支持web socket協議的雲服務器。因為只是用於測試,空間內存限制什麽的都無所謂,只要免費就行。Node.js在GitHub的Wiki頁面上列出了眾多支持Node.js環境的雲服務器,選來選去滿足條件的只有heroku。

如果你之前到heroku部署過相關Node程序的話,一定知道其麻煩之處,並且出錯了非常不容易調試。不過當我在寫這篇博客的時候,我發現了一個利器codeship,將它與你的github綁定之後,你每次提交了新的代碼它會自動部署到heroku上面。什麽都不用做!

代碼更新,環境設置,編譯部署,全部自動搞定,並且提供了詳細的log信息及各步驟的狀態信息。使用方法也是很簡單,註冊後按提示,兩三步搞定,鑒於本文已經夠長了,應該創紀錄了,這裏就不多說了。

技術分享

已知問題

部署測試後,發現一些本地未出現的問題,主要有以下幾點:

  • 首次連接過慢,有時會失敗出現503錯誤,這個查了下heroku文檔,官方表示程序首次接入時受資源限制確實會很慢的,這就是用免費套餐註定被鄙視的結果,不過用於線上測試這點還是能夠忍受的
  • 發送表情時,Chrome會向服務器重新請求已經下載到客戶端的gif圖片,而IE和FF都無此問題,導致在Chrome裏表情會有延遲,進而出現聊天主信息窗口滾動也不及時的現象
  • 用戶未活動一定時間後會與服務器失連,socket自動斷開,不知道是socket.io內部機制還是又是heroku搗鬼

總結展望

經過上面一番折騰,一個基本的聊天程序便打造完畢。可以完善的地方還有許多,比如利用CSS3的動畫,完全可以制作出窗口抖動功能的。聽起來很不錯是吧。同時利用HTML5的Audio API,要實現類似微信的語音消息也不是不可能的,夠震撼吧。甚至還有Geolocaiton API我們就可以聯想到實現同城功能,利用Webcam可以打造出視頻對聊,但這方面WebRTC已經做得很出色了。

PS:做程序員之前有兩個想法,一是寫個播放器,一是寫個聊天程序,現在圓滿了。

Node.js + Web Socket 打造即時聊天程序嗨聊