我们将使用socket.io和Angular.js从零开始,搭建一个多人的多房间聊天室。将会带着大家使用socket.io和Angular实现一个单页应用(SPA),通过本章的学习,读者将会了解如何把node与前端的开发框架结合起来,体会前端开发流程,快速实现Web应用。
HTML5引入了很多新的特性,WebSocket就是其中之一,它为浏览器端和服务器端提供了一个基于TCP链接的双向通道,这样Web开发人员可以使用WebSocket构建真实的实时Web应用。但是并不是所有的浏览器都支持WebSocket特性,在不支持WebSocket的浏览器中,我们可以使用一些其他的方法来实现实时通信,例如:轮询、长轮询、基于流或者Flash Socket的实现。socket.io出现就是为了磨平浏览器的差异,为开发者提供一个统一的接口,在不支持WebSocket的浏览器中,socket.io可以降级为其他通信方式来实现实时通信。下面是socket.io所使用的实时通信方式列表:
- Websocket
- Adobe® Flash® Socket
- AJAX long polling
- AJAX multipart streaming
- Forever Iframe
- JSONP Polling
在开发过程中,我们甚至可以指定使用某种通信方式。
Angular.js是新一代前端MVC框架。与Backbone.js相比,它完全实现了数据层和视图层的双向绑定,开发人员可以专注于功能开发,而无需纠缠在繁琐的DOM操作之中,这也正式选择它的原因。除此之外,Angular.js社区非常活跃,有大量的文档和组件。废话休说,让我开始吧!
新建TechNode
目录,我们所有的代码都会放在这个目录中。使用命令npm init
初始化项目,生成package.json
文件:
$ mkdir TechNode && cd TechNode && npm init
Node.js使用package.json来作为模块的描述文件,与模块相关的信息,比如模块名、作者、依赖的模块信息都会放在这个文件当中。有了package.json文件,我们可以非常方便地使用
npm
来做依赖模块管理。
我们可以很轻松地使用express.js搭建一个Node.js服务器。在TechNode目录下新建app.js文件,添加如下代码:
var express = require('express')
var app = express()
var port = process.env.PORT || 3000
app.use(express.static(__dirname + '/static'))
app.use(function (req, res) {
res.sendfile('./static/index.html')
})
var io = require('socket.io').listen(app.listen(port))
io.sockets.on('connection', function (socket) {
socket.emit('connected')
})
console.log('TechNode is on port ' + port + '!')
虽然这段代码非常简单,但是我需要指出其中的一些约定:
app.use(express.static(__dirname + '/static'))
app.use(function (req, res) {
res.sendfile('./static/index.html')
})
与通常的express.js项目一样,我们将静态文件放在static
目录下;在static
目录下还会放index.html
文件,它将会作为整个应用的启动页面。除了静态文件的请求以外,其他所有的HTTP请求,我们都会输出index.html
文件,服务端不关心路由,所有的路由逻辑都交给在浏览器端的Angular.js去处理。
var io = require('socket.io').listen(app.listen(port))
io.sockets.on('connection', function (socket) {
socket.emit('connected')
})
下一步就是在服务端添加socket
服务。socket.io
提供的接口是基于事件的,服务器端监听connection
事件,如果有客户端链接上来,就会产生一个socket对象,使用这个对象,我们就可以和对应的客户端实时通信了。
在TechNode
下新建static
目录,添加index.html
文件到static
目录中:
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>TechNode</title>
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
</head>
<body>
<script type="text/javascript">
var socket = io.connect('http://localhost:3000/')
socket.on('connected', function () {
alert('connected to TechNode!')
})
</script>
</body>
</html>
为了与服务端的socket服务通信,我们必须在客户端引入socket.io
提供的客户端类库socket.io.js
。这个文件由socket.io
提供服务,我们无需把这个文件添加到static
目录中。
var socket = io.connect('http://localhost:3000/')
socket.on('connected', function () {
alert('connected to TechNode!')
})
调用io
的connect
方法,传入socket服务的地址,然后我们就获得了一个socket对象,这样就可以和服务端通信了。
别忘了使用npm install express socket.io --save
安装express
和socket.io
,参数--save
可以自动更新package.json文件,将
express和
socket.io作为项目依赖添加到
package.json`中。
到这里,最基础的服务端已经搭建完成了,运行:
$ node app.js
TechNode is on port 3000!
访问http://localhost:3000
,试试看。
我们使用bower
来管理TechNode使用到的前端类库,与npm
类似,bower
使用名为bower.json
的文件来管理项目的依赖。在TechNode
目录下运行bower init
,生成bower.json
文件。
bower
默认将依赖的模块安装在bower_compoments
下,为了便于管理,新建.bowerrc
文件,添加如下内容,为bower指定依赖的安装目录:
{
"directory" : "static/components"
}
接下来,试用bower
安装我们需要的一些前端类库:
- Bootstrap:快速构建web项目的前端UI库,依赖jQuery,
bower
在安装Bootstrap的同时,也会安装jQuery; - Angular.js:我们的主角,前端MVC框架。
bower install bootstrap angular --save
--save
参数使得bower
将依赖写入到bower.json
中。
将这些类库添加到index.html
中:
<head>
<meta charset="UTF-8">
<title>TechNode</title>
<link rel="stylesheet" href="/components/bootstrap/dist/css/bootstrap.css">
<script type="text/javascript" src="/socket.io/socket.io.js"></script>
<script type="text/javascript" src="/components/jquery/dist/jquery.js"></script>
<script type="text/javascript" src="/components/bootstrap/dist/js/bootstrap.js"></script>
<script type="text/javascript" src="/components/angular/angular.js"></script>
</head>
首先,使用html将TechNode的外观搭建出来:
<head>
...
<link rel="stylesheet" href="/styles/room.css">
...
</head>
<body>
<div class="navbar navbar-inverse navbar-fixed-top">
<div class="container">
<div class="navbar-header">
<a class="navbar-brand" href="#">TechNode</a>
</div>
</div>
</div>
<div class="container" style="margin-top:100px;">
<div class="col-md-12">
<div class="panel panel-default room">
<div class="panel-heading room-header">TechNode</div>
<div class="panel-body room-content">
<div class="messages">
<div class="list-group">
</div>
</div>
<form class="message-creator">
<div class="form-group">
<textarea required class="form-control message-input" placeholder="Ctrl+Enter to quick send"></textarea>
</div>
</form>
</div>
</div>
</div>
</div>
...
在static
目录下新建styles
目录,新建房间样式文件room.css
,下面就是最简版的TechNode:
接下来我们实现聊天室最基本的功能——消息!
打开app.js文件,修改socket服务部分代码:
// ...
var messages = []
io.sockets.on('connection', function (socket) {
socket.on('getAllMessages', function () {
socket.emit('allMessages', messages)
})
socket.on('createMessage', function (message) {
messages.push(message)
io.sockets.emit('messageAdded', message)
})
})
// ...
我们暂时把消息数据放到messages
这个数组对象中。用户连上来后,向服务端发送getAllMessages
请求,获取所有消息,服务器就把所有的消息通过allMessages
事件推送给客户端;当用户创建消息时,向服务端发送createMessage
事件,服务端把消息存放到messages
数组中,并向所有的客户端广播messageAdded
,有新的消息添加进来。
下一步,使用Angular.js构建聊天室的客户端。
修改index.html(删除了index.html中的JavScript代码),添加Angular绑定:
<!doctype html>
<html ng-app="techNodeApp">
<head>
...
</head>
<body>
...
<div class="container" style="margin-top:100px;">
<div class="col-md-12">
<div class="panel panel-default room" ng-controller="RoomCtrl">
<div class="panel-heading room-header">TechNode</div>
<div class="panel-body room-content">
<div class="list-group messages" auto-scroll-to-bottom>
<div class="list-group-item message" ng-repeat="message in messages track by $index">
某某: {{message}}
</div>
</div>
<form class="message-creator" ng-controller="MessageCreatorCtrl">
<div class="form-group">
<textarea required class="form-control message-input" ng-model="newMessage" ctrl-enter-break-line="createMessage()" placeholder="Ctrl+Enter to quick send"></textarea>
</div>
</form>
</div>
</div>
</div>
</div>
<script type="text/javascript" src="/technode.js"></script>
</body>
</html>
来看看在index.html
中我们添加的绑定;
- ng-app="techNodeApp",绑定一个名为techNodeApp的Angular应用(该应用的所有逻辑都将放到
technode.js
文件中); - ng-controller="RoomCtrl",绑定一个名为
RoomCtrl
的房间控制器; - ng-controller="MessageCreatorCtrl",绑定一个名为
MessageCreatorCtrl
的消息创建控制器; - ng-repeat="message in messages": 一个repeat绑定,将从服务器端读过来的messages显示在页面中,因为Angluar的数据绑定是双向的,所以当messages中有新消息加入时,消息列表就会自动刷新。其中
track by $index
是为了实现可以发重复的消息,否则Angular会报错。
在static
目录下新建名为technode.js
文件,并引入到index.html
中,接下来,逐步在technode.js
中实现整个客户端逻辑:
申明名为techNodeApp
的模块,与index.html
页面中的ng-app
绑定对应;
angular.module('techNodeApp', [])
将socket.io封装成了一个名为socket
的Angular的服务,这样我们就可以在其他组件中使用socket
与服务端通信了:
angular.module('techNodeApp').factory('socket', function($rootScope) {
var socket = io.connect('/')
return {
on: function(eventName, callback) {
socket.on(eventName, function() {
var args = arguments
$rootScope.$apply(function() {
callback.apply(socket, args)
})
})
},
emit: function(eventName, data, callback) {
socket.emit(eventName, data, function() {
var args = arguments
$rootScope.$apply(function() {
if (callback) {
callback.apply(socket, args)
}
})
})
}
}
})
仔细阅读上面的代码,socket服务并不是简单的将socket.io分装成了Angular服务,在每个回调函数里,我们调用了$rootScope.$apply
。在Angular中,如果调用$scope.$apply(callback)
,就是告诉Angular,执行callback
,并在执行后,检查$scope(我们用的是$rootScope就是检查整个应用)数据状态,如果有变化就更新index.html
中的绑定。通俗地说,就是每次与服务端通信后,根据数据变化,更新视图。
接下来是定义RoomCtrl
:
angular.module('techNodeApp').controller('RoomCtrl', function($scope, socket) {
$scope.messages = []
socket.emit('getAllMessages')
socket.on('allMessage', function (messages) {
$scope.messages = messages
})
socket.on('messageAdded', function (message) {
$scope.messages.push(message)
})
})
RoomCtrl
控制器可以分为三部分:
$scope.messages = []
是这个控制器的数据模型;对应视图中的:
<div class="list-group-item message" ng-repeat="message in messages">
某某: {{message}}
</div>
由于Angular的双向绑定机制,我们无需手动操作DOM元素,数据模型messages
的变化能够动态地反映在视图上。
socket.emit('getAllMessages')
socket.on('allMessage', function (messages) {
$scope.messages = messages
})
在techNode启动后,通过socket服务从服务端获取所有消息,更新到数据模型messages
中。
socket.on('messageAdded', function (message) {
$scope.messages.push(message)
})
监听服务端messageAdded
事件,接收新的消息,添加到数据模型中。
MessageCteatorCtrl的定义也非常简单,当用户按下回车时,将消息通过socket发送给服务端;注意着了的newMessage是通过ng-model与textarea直接绑定的;
下面是另一个控制器MessageCteatorCtrl
:
angular.module('techNodeApp').controller('MessageCreatorCtrl', function($scope, socket) {
$scope.newMessage = ''
$scope.createMessage = function () {
if ($scope.newMessage == '') {
return
}
socket.emit('createMessage', $scope.newMessage)
$scope.newMessage = ''
}
})
数据模型$scope.newMessage = ''
与视图中的<textarea ng-model="newMessage" ctrl-enter-break-line="createMessage()"></textarea>
绑定。同时绑定了一个控制器方法createMessage
,当用户回车时,调用这个方法,把新消息发送给服务端。
你一定注意到了视图上有两个奇怪的属性ctrl-enter-break-line
和auto-scroll-to-bottom
,这是我们自定义的两个Angular指令:
- autoScrollToBottom:当消息很多出现滚动条时,该组件使得滚动条能随着消息的增加自动滚动到底部;
- ctrlEnterBreakLine: 在textarea回车,默认会换行,使用这个组件,可以通过ctrl+enter来换行,而enter则触发绑定的行为,在这里就是createMessage这个方法。
angular.module('techNodeApp').directive('autoScrollToBottom', function() {
return {
link: function(scope, element, attrs) {
scope.$watch(
function() {
return element.children().length;
},
function() {
element.animate({
scrollTop: element.prop('scrollHeight')
}, 1000);
}
);
}
};
});
angular.module('techNodeApp').directive('ctrlEnterBreakLine', function() {
return function(scope, element, attrs) {
var ctrlDown = false
element.bind("keydown", function(evt) {
if (evt.which === 17) {
ctrlDown = true
setTimeout(function() {
ctrlDown = false
}, 1000)
}
if (evt.which === 13) {
if (ctrlDown) {
element.val(element.val() + '\n')
} else {
scope.$apply(function() {
scope.$eval(attrs.ctrlEnterBreakLine);
});
evt.preventDefault()
}
}
});
};
});
这是本文中唯一的两个Angular指令,本文并不打算深入探讨
Angular指令
的机制。读者只需明白其作用,学会使用即可。
哦也,一个最简陋的聊天室搭建完成了!说简陋,因为它连用户都没有,消息都是匿名的。不过,通过这个简单的聊天室,想必大家已经了解了TechNode各个基础部分,学会了如何使用Angular和socket.io的快速搭建Web应用。
启动服务器,把地址发给同事或朋友试试看!他们一定会嘲笑你连用户名都没有吧?好了,下一步我们就为TechNode加入用户的功能!