AngularJS+Node.js+socket.io 开发在线聊天室
所有文章搬运自我的个人主页:sheilasun.me
不得不说,上手AngularJS比我想象得难多了,把官网提供的PhoneCat例子看完,又跑到慕课网把大漠穷秋的 AngularJS实战系列看了一遍,对于基本的使用依然有很多说不清道不明的疑惑,于是决定通过做一个在线聊天室帮助理解。DEMO可以戳→chat room,代码可以戳→ChatRoom-AngularJS。
清晰图可以戳 http://sheilasun.sinaapp.com/public/images/chatroom.gif
功能
着手开发之前,首先明确一下需要实现的功能:
- 新用户登入,广播通知其他用户
- 用户下线,广播通知其他用户
- 可显示在线人数及列表
- 可群聊,可私信
- 用户若发送群消息,广播通知其他所有用户
- 用户若发送私信,单独通知收方
界面
因为自己是个审美渣,所以全靠bootstrap了,另外还模仿了下微信聊天记录里的气泡设计。
界面分左右两个板块,分别用于显示在线列表和聊天内容。
在左侧的在线列表中,点击不同项可以切换右侧板块的聊天对象。
右侧显示与当前聊天对象的对话记录,不过仅显示最近的30条。每一条聊天记录内容包括发送人的昵称及头像、发送时间、消息内容。关于头像,这里做简单处理,用填充了随机色的方块代替。另外,自己发出去的消息与收到的消息样式自然要做不同设计,所有效果可以看下图。
清晰图可以戳 http://sheilasun.sinaapp.com/public/images/chatroomsc.png
服务端
服务端我们用Node.js以及混入express、socket.io来开发,在程序根目录打开终端,执行:
npm init
根据提示,生成一个package.json文件。打开并配置依赖项:
"dependencies": {
"express": "^4.13.3",
"socket.io": "^1.3.6"
}
之后执行 npm install 安装依赖模块。
接下来,我们在根目录下新建app.js,在其中写Server端代码。再新建public文件夹,存放client端代码。
app.js中主要内容如下:
var express = require('express');
var app = require('express')();
var http = require('http').createServer(app);
var io = require('socket.io')(http);
app.use(express.static(__dirname + '/public'));
app.get('/', function (req, res) {
res.sendfile('index.html');
});
io.on('connection',function(socket){
socket.on('addUser',function(data){ //有新用户进入聊天室
});
socket.on('addMessage',function(data){ //有用户发送新消息
});
socket.on('disconnect', function () { //有用户退出聊天室
);
});
http.listen(3002, function () {
console.log('listening on *:3002');
});
在上面的代码中,我们为以下事件添加了监听:
-addUser,有新用户进入聊天室
该事件由客户端输入昵称后触发,服务端收到后对昵称是否已存在进行判断,如果已存在,通知客户端昵称无效:
socket.emit('userAddingResult',{result:false});
反之,通知客户端昵称有效以及当前所有已连接的用户信息,并把新用户信息广播给其他已连接用户:
socket.emit('userAddingResult',{result:true});
allUsers.push(data);//allUsers保存了所有用户
socket.emit('allUser',allUsers);//将所有在线用户发给新用户
socket.broadcast.emit('userAdded',data);//广播欢迎新用户,除新用户外都可看到
其中需要注意'socket.emit'与'socket.broadcast.emit'的区别,可以查看这篇博文socket.io emit的几种用法解释:
// send to current request socket client
socket.emit('message', "this is a test");
// sending to all clients except sender
socket.broadcast.emit('message', "this is a test");
-addMessage,有用户发送新消息
在此事件监听里,需要分成两类情况处理:
1.私信
如果消息是发给特定用户A,那么就需要获取A对应的socket实例,然后调用其emit方法。所以每当一个客户端连接到Server端时,我们得把其socket实例保存起来,以备后续之需。
connectedSockets[nickname]=socket;//以昵称作下标,保存每个socket实例,发私信需要用
需要发私信时,取出socket实例做操作即可:
connectedSockets[nickname].emit('messageAdded',data)
2.群发
群发就比较简单了,用broadcast方法即可:
socket.broadcast.emit('messageAdded',data);//广播消息,除原发送者外都可看到
-disconnect,有用户退出聊天室
需要做三件事情:
1.通知其他用户“某用户下线”
socket.broadcast.emit('userRemoved', data);
2.将用户从保存了所有用户的数组中移除
3.将其socket实例从保存了所有客户端socket实例的数组中移除
delete connectedSockets[nickname]; //删除对应的socket实例
运行一下服务端代码,观察有无错误:
node app.js
若没什么问题,继续编写客户端的代码。
客户端
在public目录下新建'index.html',客户端需要用到bootstrap、angularjs、socket.io、jQuery以及我们自己的js和css文件,先把这些文件用标签引入。
<!DOCTYPE html>
<html>
<head lang="en">
<meta charset="UTF-8">
<title></title>
<link href="http://cdn.bootcss.com/bootstrap/3.3.5/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="./assets/style/app.css"/>
<script src="http://libs.baidu.com/jquery/2.0.0/jquery.min.js"></script>
<script src="/socket.io/socket.io.js"></script>
<script src="//cdn.bootcss.com/angular.js/1.4.3/angular.min.js"></script>
<script src="./assets/js/app.js"></script>
</head>
<body></body>
</html>
我们并不立即深入逻辑细节,把框架搭好先。
首先,在body上加上ng-app属性,标记一下angularjs的“管辖范围”。这个练习中我们只用到了一个控制器,同样将ng-controller属性加到body标签。
<body ng-app="chatRoom" ng-controller="chatCtrl">
接下来在js中,我们来创建module及controller。
var app=angular.module("chatRoom",[]);
app.controller("chatCtrl",['$scope','socket','randomColor',function($scope,socket,randomColor){}]);
注意这里,我们用内联注入添加了socket和randomColor服务依赖。这里我们不用推断式注入,以防部署的时候用uglify或其他工具进行了混淆,变量经过了重命名导致注入失效。
在这个练习中,我们自定义了两个服务,socket和randomColor,前者是对socket.io的包装,让其事件进入angular context,后者是个可以生成随机色的服务,用来给头像指定颜色。
//socket服务
app.factory('socket', function($rootScope) {
var socket = io(); //默认连接部署网站的服务器
return {
on: function(eventName, callback) {...},
emit: function(eventName, data, callback) {...}
};
});
//randomcolor服务
app.factory('randomColor', function($rootScope) {
return {
newColor: function() {
return '#'+('00000'+(Math.random()*0x1000000<<0).toString(16)).slice(-6);//返回一个随机色
}
};
});
注意socket服务中连接的语句“var socket = io();”,我们并没有传入任何url,是因为其默认连接部署这个网站的服务器。
考虑到聊天记录以及在线人员列表都是一个个逻辑及结构重复的条目,且html结构较复杂,为了其复用性,我们把它们封装成两个指令:
app.directive('message', ['$timeout',function($timeout) {}])
.directive('user', ['$timeout',function($timeout) {}]);
注意这里两个指令都注入了'$timeout'依赖,其作用后文会解释。
这样一个外层框架就搭好了,现在我们来完成内部的细节。
登录
页面刚加载时只显示登录界面,只有当输入昵称提交后且收到服务端通知昵称有效方可跳转到聊天室。我们将ng-show指令添加到登录界面和聊天室各自的dom节点上,来帮助我们显示或隐藏元素。用'hasLogined'的值控制是显示或隐藏。
<!-- chat room -->
<div class="chat-room-wrapper" ng-show="hasLogined">
...
</div>
<!-- end of chat room -->
<!-- login form -->
<div class="userform-wrapper" ng-show="!hasLogined">
...
</div>
<!-- end of login form -->
JS部分
$scope.login = function() { //登录
socket.emit("addUser", {...});
}
//收到登录结果
socket.on('userAddingResult', function(data) {
if (data.result) {
$scope.hasLogined = true;
} else { //昵称被占用
$scope.hasLogined = false;
}
});
这里监听了socket连接上的'userAddingResult'事件,接收服务端的通知,确认是否登录成功。
socket连接监听
成功登录以后,我们还监听socket连接上的其他事件:
//接收到欢迎新用户消息,显示系统欢迎辞,刷新在线列表
socket.on('userAdded', function(data) {});
//接收到所有用户信息,初始化在线列表
socket.on('allUser', function(data) {});
//接收到用户退出消息,刷新在线列表
socket.on('userRemoved', function(data) {});
//接收到新消息,添加到聊天记录
socket.on('messageAdded', function(data) {});
接收到事件以后,做相应的刷新动作,这里的socket是socket.io经过包装的服务,内部仅包装了我们需要用到的两个函数on和emit。我们在事件监听里对model做的修改,都会在AngularJS内部得到通知和处理,UI才会得到及时刷新。
监听内做的事情太具体和琐碎了,这里就不列出了,接下来介绍一下message指令。
message 指令
最后分享一下我在写message指令时遇到的问题。首先看一下其代码:
app.directive('message', ['$timeout',function($timeout) {
return {
restrict: 'E',
templateUrl: 'message.html',
scope:{
info:"=",
self:"=",
scrolltothis:"&"
},
link:function(scope, elem, attrs){
$timeout(scope.scrolltothis);
}
};
}])
以及其模板message.html:
<div ng-switch on="info.type">
<!-- 欢迎消息 -->
<div class="system-notification" ng-switch-when="welcome">系统{{info.text}}来啦,大家不要放过他~</div>
<!-- 退出消息 -->
<div class="system-notification" ng-switch-when="bye">系统:byebye,{{info.text}}</div>
<!-- 普通消息 -->
<div class="normal-message" ng-switch-when="normal" ng-class="{others:self!==info.from,self:self===info.from}">
<div class="name-wrapper">{{info.from}} @ {{time | date: 'HH:mm:ss' }}</div>
<div class="content-wrapper">{{info.text}}<span class="avatar"></span></div>
</div>
</div>
模板中我们用ng-switch指令监听info.type变量的值,根据其值的不同显示不同内容。比如,当info.type值为"welcome"时,创建第一个dom节点,删除下方另外两个div。
另外,普通消息下,为了在UI上区分自己发出去的和收到的消息,需要给他们应用不同的样式,这里用ng-class指令实现。
ng-class="{others:self!==info.from,self:self===info.from}"
当'self===info.from'返回true时,应用'self'类,否则,应用'others'类。
在此指令中,我们创建了独立作用域,并绑定了三个属性,绑定完后还必须在父作用域的HTML标签上添加相应属性。
scope:{
info:"=",
self:"=",
scrolltothis:"&"
}
<message self="nickname" scrolltothis="scrollToBottom()" info="message" ng-repeat="message in messages"></message>
关于Isolated Scope的知识,可以查看这两篇博文AngularJS 作用域与数据绑定机制,Understanding AngularJS Isolated Scope。
在link函数中,执行一个动作:每当一个message被加到页面上时,将聊天记录滚动到最下方,一开始我是这样写的:
link:function(scope, elem, attrs){
scope.scrolltothis();
}
结果发生了一个很奇怪的现象,总是滚动到上一条位置,而不是最新这条。调试之后发现是因为'scrolltothis'函数执行的时候,DOM还没渲染,所以在函数内部获取scrollHeight的时候获得的总是添加DOM节点之前的状态。这时候,可以把代码放到$timeout里延迟0秒执行,延迟0秒并不意味着会立即执行,因为js的单线程特性,代码实际会等到dom渲染完再执行。
$timeout(scope.scrolltothis);
完整代码可以戳我的GitHub→ChatRoom-AngularJS,DEMO可以戳→chat room
有任何不妥之处或错误欢迎各位指出,不胜感激~