Express4 + Socket.io 实现聊天室

实时的数据通信在新的H5的JavascriptAPI出来以前,都是一个让人头疼的问题,实现实时数据展示的几种方式:

  • 传统方式:用ajax设置定时器不断的请求后端,发现数据改变时更新前端显示
  • Comet方式:一次ajax请求,后端设置一个死循环不返回结果以保持连接,不断的监听数据变化,一旦变化则跳出循环返回数据
  • webSocket方式:采用了一些特殊的报头(Header),使得浏览器和服务器只需要做一个握手的动作,就可以在浏览器和服务器之间建立一条连接通道,而毋须消耗大量服务器资源。

功能

  • 自动分配聊天室
  • 提示添加昵称
  • 修改并验证昵称
  • 防止xss攻击
  • 加入、离开时发送系统消息
  • 显示在线人员和个数
  • 发送表情
  • 为每个用户随机分配颜色
  • 当前用户的消息显示在右侧,其它用户消息显示在左则

安装

环境

安装全局的express
npm install express -g

使用express项目生成器快速创建express应用
npm install express-generator -g

创建项目

express -e –git chat
参数说明:-e 使用ejs模板 --git 添加.gitignore文件 项目名称 chat

创建成功后目录结构如下:

.
├── app.js
├── bin
│ └── www
├── package.json
├── public
│ ├── images
│ ├── javascripts
│ └── stylesheets
│ └── style.css
├── routes
│ ├── index.js
│ └── users.js
└── views
├── error.ejs
└── index.ejs

添加依赖

打开 package.json

{
"name": "chat",
"version": "0.0.0",
"private": true,
"scripts": {
"start": "node ./bin/www"
},
"dependencies": {
"body-parser": "~1.13.2",
"cookie-parser": "~1.3.5",
"debug": "~2.2.0",
"ejs": "~2.3.3",
"express": "~4.13.1",
"morgan": "~1.6.1",
"serve-favicon": "~2.3.0",

"socket.io": "^1.0.6", #添加socket.io通信模块
"xss-escape": "0.0.5" #添加处理xss的模块
}
}

安装依赖
cd chat & npm install
配置调试项目 并启动预览
set DEBUG = chat & npm start

预览成功

编写前端页面

代码编辑

打开 views/index.ejs, 编辑内容如下:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Socket聊天室v0.0.1</title>
<!-- Bootstrap-->
<link href="http://libs.baidu.com/bootstrap/3.0.3/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="/css/index.css">
</head>
<body class="container">
<div class="row">
<!-- 聊天区 -->
<div class="col-sm-8"></div>

<!-- 个人信息 -->
<div class="col-sm-4"></div>

<!-- 在线列表 -->
<div class="col-sm-4"></div>
</div>

<!-- 添加或修改昵称弹窗 -->
<div class="modal fade" id="login-modal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog">
</div>
</div>

<script src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>
<script src="http://cdn.staticfile.org/jquery-cookie/1.4.1/jquery.cookie.min.js"></script>
<script src="http://libs.baidu.com/bootstrap/3.0.3/js/bootstrap.min.js"></script>
<script src="/js/socket.io.js"></script>
<script src="/js/emotion.js"></script>
<script src="/js/index.js"></script>
</body>
</html>

引入的文件
bootstrap bootstrop主题相关
socket.io.js 通过websocket方式处理与服务器的的通信
jquery.cookie.js 用于存储用户信息
emotion.js 表情插件

客户端文件socket.io.js来源:node_modules\socket.io\node_modules\socket.io-client\socket.io.js
将其复制到 public\js\文件夹内(个人习惯:将public文件下的三个文件重新命令成css/img/js三个文件夹)

效果预览

页面逻辑编写

public/js/index.js

/**
* index.js 聊天室页面逻辑
* deps:[jquery.js, socket.io.js, emotion.js]
* email:935486956@qq.com
*/
var chat_Utils, //聊天室 工具类
chat_UI, //聊天室 界面逻辑
chat_Socket; //聊天室 数据逻辑

// 与后台服务器建立websocket连接
var chat_server = "http://" + location.hostname + ':3000';
var socket = io.connect(chat_server);
chat_Utils = {
getLocalHMS: function(){}, //获取当前时间
getUserColor:function(){} //获取随机颜色
}

chat_UI = {
init:function(){
this.initEmotion(); //初始化表情插件

this.nameEditModalShowEv(); //注册 点击弹出修改昵称弹窗事件
this.sendMsgEv(); //注册 发送消息事件
this.subNameEv(); //注册 提交昵称事件

this.loginModalShowEv(); //弹窗打开时调整弹窗样式
this.loginModalShownEv(); //弹窗打开后input获取焦点
},
initEmotion:function(){},
chatBodyToBottom: function(){},
addMessage: function(_time, _content, _name, clr){},
removeListUser: function(_user){},
addUserToList: function(_user){},
useUserList: function(_user_list){},
updateListCount: function(){},
sendMessage: function(){},
applyNickname: function(){},
sendMsgEv:function(){},
nameEditModalShowEv:function(){},
subNameEv:function(){},
loginModalShowEv:function(){},
loginModalShownEv:function(){}
};

chat_Socket = {
init:function(){
console.log('server:' + chat_server);

this.needNicknameEv(); //监听后端 需要昵称 事件
this.serverMessageEv(); //监听后端 用户新消息 事件
this.changeNicknameErrorEv(); //监听后端 昵称错误 事件
this.changeNicknameDoneEv(); //监听后端 昵称添加成功 事件
this.sayDoneEv(); //监听后端 用户新消息 事件
this.userListEv(); //监听后端 显示用户列表 事件
this.userChangeNicknameEv(); //监听后端 修改昵称提示 事件
this.userJoinEv(); //监听后端 新用户加入 广播
this.userQuitEv(); //监听后端 用户离开 广播
this.userSayEv(); //监听后端 其它用户消息 广播
},
changeNickname:function(_nickname, clr){},
say:function(_content){},
needNicknameEv:function(){},
serverMessageEv:function(){},
changeNicknameErrorEv:function(){},
changeNicknameDoneEv:function(){},
sayDoneEv:function(){},
userListEv:function(){},
userChangeNicknameEv:function(){},
userJoinEv:function(){},
userQuitEv:function(){},
userSayEv:function(){}
}

chat_UI.init();
chat_Socket.init();

几个要注意的地方:

  • 当前用户的昵称和颜色在后端保存于当前的socket对象中
  • 判断当前连接的用户是否与页面中的用户相同,以便判断消息对齐方式

编写服务端

app.js 不用修改,因为是单页面,注释掉users相关代码即可

// var users = require('./routes/users');
// app.use('/users', users);

根目录下新建 chat_server.js

var io = require('socket.io')();
var xssEscape = require('xss-escape');
var nickname_list = [];

// 检查是昵称是否已经存在
function HasNickname(_nickname){
for(var i=0; i<nickname_list.length; i++){
if(nickname_list[i] == _nickname){
return true;
}
}
}

// 删除昵称
function RemoveNickname(_nickname){
for(var i=0; i< nickname_list.length; i++){
if(nickname_list[i] == _nickname){
nickname_list.splice(i, 1);
}
}
}

io.on('connection', function(_socket){
console.log(_socket.id + ':connection');

// 向当前用户发送命令和消息
_socket.emit('user_list', nickname_list);
_socket.emit('need_nickname');
_socket.emit('server_message','欢迎来到聊天室 :)');

// 监听当前用户的请求和数据
// 离开
_socket.on('disconnect', function(){
console.log(_socket.id + ':disconnect');
if(_socket.nickname != null && _socket.nickname != ""){
// 广播 用户退出
_socket.broadcast.emit('user_quit', _socket.nickname);
RemoveNickname(_socket.nickname);
}
});

// 添加 和 修改 昵称
_socket.on('change_nickname', function(_nickname, clr){
console.log(_socket.id + ': change_nickname('+_nickname+')');
_nickname = xssEscape(_nickname.trim());

// 半角替换为tt,模拟为全角字符判断长度
var name_len = _nickname.replace(/[^\u0000-\u00ff]/g, "tt").length;

// 字符长度必须在4到16个字符之间
if(name_len < 4 || name_len > 16){
return _socket.emit('change_nickname_error', '请填写正确的用户昵称,应在4到16个字符之间。')
}

// 昵称重复
if(_socket.nickname == _nickname){
return _socket.emit('change_nickname_error', '你本来就叫这个名字。')
}

// 昵称已经被占用
if(HasNickname(_nickname)){
return _socket.emit('change_nickname_error', '此昵称已经被占用。')
}

var old_name = '';
if(_socket.nickname != '' && _socket.nickname != null){
old_name = _socket.nickname;
RemoveNickname(old_name);
}

nickname_list.push(_nickname);
_socket.nickname = _nickname;
_socket.color = clr;

console.log(nickname_list);
_socket.emit('change_nickname_done', old_name, _nickname, clr);

if(old_name == ''){
// 广播 用户加入
return _socket.broadcast.emit('user_join', _nickname);
}else{
// 广播 用户改名
return _socket.broadcast.emit('user_change_nickname', old_name, _nickname);
}
});

// 说话
_socket.on('say', function(_content){
if('' == _socket.nickname || null == _socket.nickname){
return _socket.emit('need_nickname');
}

_content = _content.trim();
console.log(_socket.nickname + ': say('+_content+')');
// 广播 用户新消息
_socket.broadcast.emit('user_say', _socket.nickname, xssEscape(_content), _socket.color);
return _socket.emit('say_done', _socket.nickname, xssEscape(_content), _socket.color);
});
})

// 这里的listen函数在 bin/www 文件中被调用
exports.listen = function(_server){
return io.listen(_server);
}

去掉 routes/index.js 中返回到页面的 title参数

/* GET home page. */
router.get('/', function(req, res, next) {
res.render('index');
});

Coding托管的实例源码

下一节 将介绍koa.io 聊天室的具体实现