最简单的聊天室

我们将使用socket.io和Angular.js从零开始,搭建一个多人的多房间聊天室。将会带着大家使用socket.io和Angular实现一个单页应用(SPA),通过本章的学习,读者将会了解如何把node与前端的开发框架结合起来,体会前端开发流程,快速实现Web应用。

socket.io简介

HTML5引入了很多新的特性,WebSocket就是其中之一,它为浏览器端和服务器端提供了一个基于TCP链接的双向通道,这样Web开发人员可以使用WebSocket构建真实的实时Web应用。但是并不是所有的浏览器都支持WebSocket特性,在不支持WebSocket的浏览器中,我们可以使用一些其他的方法来实现实时通信,例如:轮询、长轮询、基于流或者Flash Socket的实现。socket.io出现就是为了磨平浏览器的差异,为开发者提供一个统一的接口,在不支持WebSocket的浏览器中,socket.io可以降级为其他通信方式来实现实时通信。下面是socket.io所使用的实时通信方式列表:

在开发过程中,我们甚至可以指定使用某种通信方式。

Angular.js

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服务器

我们可以很轻松地使用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('/')
      socket.on('connected', function () {
        alert('connected to TechNode!')
      })
    </script>
  </body>
</html>

为了与服务端的socket服务通信,我们必须在客户端引入socket.io提供的客户端类库socket.io.js。这个文件由socket.io提供服务,我们无需把这个文件添加到static目录中。

var socket = io.connect('/')
socket.on('connected', function () {
  alert('connected to TechNode!')
})

调用ioconnect方法,传入socket服务的地址,因为在本例中,socket服务器与静态服务器是同一个,可以简写为/(实际上相当于http://localhost:3000),然后我们就获得了一个socket对象,这样就可以和服务端通信了。

别忘了使用npm install express socket.io --save安装expresssocket.io,参数--save可以自动更新package.json文件,将expresssocket.io作为项目依赖添加到package.json`中。

到这里,最基础的服务端已经搭建完成了,运行:

$ node app.js 

TechNode is on port 3000!

访问http://localhost:3000,试试看。

最简单的聊天室

安装Bootstrap和Angular.js

我们使用bower来管理TechNode使用到的前端类库,与npm类似,bower使用名为bower.json的文件来管理项目的依赖。在TechNode目录下运行bower init,生成bower.json文件。

bower默认将依赖的模块安装在bower_compoments下,为了便于管理,新建.bowerrc文件,添加如下内容,为bower指定依赖的安装目录:

{
  "directory" : "static/components"
}

接下来,试用bower安装我们需要的一些前端类库:

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/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:

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,添加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">
                某某: {{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中我们添加的绑定;

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-lineauto-scroll-to-bottom,这是我们自定义的两个Angular指令:

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加入用户的功能!