使用 Socket.IO 編寫 AngularJS 應用程式

Brian Ford
Brian Ford

簡介

AngularJS 是一項出色的 JavaScript 架構,可提供快速且易於使用的雙向資料繫結,以及強大的指令系統,讓您使用可重複使用的自訂元件,以及更多功能。Socket.IO 是 WebSocket 的跨瀏覽器包裝函式和 polyfill,可輕鬆開發即時應用程式。順帶一提,這兩者搭配起來效果相當不錯!

我之前曾撰文介紹如何使用 Express 編寫 AngularJS 應用程式,但這次我將說明如何整合 Socket.IO,為 AngularJS 應用程式新增即時功能。在本教學課程中,我將逐步說明如何編寫即時通訊應用程式。這項作業是建立在先前教學課程的基礎上 (在伺服器上使用類似的 node.js 堆疊),因此如果您不熟悉 Node.js 或 Express,建議先查看該教學課程。

開啟示範內容

如往常,您可以在 GitHub 上取得成品

必要條件

您需要使用一些樣板,才能設定 Socket.IO 並與 Express 整合,因此我建立了 Angular Socket.IO Seed

如要開始使用,您可以從 GitHub 複製 angular-node-seed 存放區:

git clone git://github.com/btford/angular-socket-io-seed my-project

下載為 ZIP 檔案

取得種子後,您需要使用 npm 取得一些依附元件。開啟終端機並前往含有種子的目錄,然後執行以下指令:

npm install

安裝這些依附元件後,您就可以執行骨架應用程式:

node app.js

並在瀏覽器中查看 http://localhost:3000,確認種子能正常運作。

決定應用程式功能

撰寫即時通訊應用程式的方法有很多種,因此我們先來說明我們的應用程式將具備哪些基本功能。所有使用者都會加入同一個聊天室。使用者可以選擇及變更自己的名稱,但名稱不得重複。伺服器會強制執行此唯一性,並在使用者變更名稱時發布通知。用戶端應提供訊息清單,以及目前在聊天室中的使用者清單。

簡單的前端

有了這個規格,我們就能使用 Jade 製作簡單的前端,提供必要的 UI 元素。開啟 views/index.jade,並在 block body 中新增以下內容:

div(ng-controller='AppCtrl')
.col
  h3 Messages
  .overflowable
    p(ng-repeat='message in messages') : 

.col
  h3 Users
  .overflowable
    p(ng-repeat='user in users') 

.clr
  form(ng-submit='sendMessage()')
    | Message: 
    input(size='60', ng-model='message')
    input(type='submit', value='Send')

.clr
  h3 Change your name
  p Your current user name is 
  form(ng-submit='changeName()')
    input(ng-model='newName')
    input(type='submit', value='Change Name')

開啟 public/css/app.css 並新增 CSS,提供欄和溢位:

/* app css stylesheet */

.overflowable {
  height: 240px;
  overflow-y: auto;
  border: 1px solid #000;
}

.overflowable p {
  margin: 0;
}

/* poor man's grid system */
.col {
  float: left;
  width: 350px;
}

.clr {
  clear: both;
}

與 Socket.IO 互動

雖然 Socket.IO 會在 window 上公開 io 變數,但建議您在 AngularJS 的依附元件插入系統中封裝該變數。因此,我們會先撰寫服務,用於包裝 Socket.IO 傳回的 socket 物件。這真是太棒了,因為這樣日後測試控制器會變得更容易。開啟 public/js/services.js,並將內容替換為:

app.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 回呼包裝在 $scope.$apply 中。這會告訴 AngularJS,在執行傳遞至該函式的回呼後,如果有任何變更,就需要檢查應用程式的狀態並更新範本。在內部,$http 的運作方式相同;在某些 XHR 傳回後,它會呼叫 $scope.$apply,以便 AngularJS 據此更新檢視畫面。

請注意,這項服務不會包裝整個 Socket.IO API (這是讀者要自行練習的部分 ;P)。不過,這項服務涵蓋本教學課程中使用的各項方法,如果您想進一步瞭解,應該會指引您正確的方向。我可能會重新撰寫完整的包裝函式,但這超出本教學課程的範圍。

現在,我們可以在控制器中要求 socket 物件,就像使用 $http 一樣:

function AppCtrl($scope, socket) {
  /* Controller logic */
}

在控制器中,我們來新增傳送及接收訊息的邏輯。開啟 js/public/controllers.js,並將內容替換為以下內容:

function AppCtrl($scope, socket) {

  // Socket listeners
  // ================

  socket.on('init', function (data) {
    $scope.name = data.name;
    $scope.users = data.users;
  });

  socket.on('send:message', function (message) {
    $scope.messages.push(message);
  });

  socket.on('change:name', function (data) {
    changeName(data.oldName, data.newName);
  });

  socket.on('user:join', function (data) {
    $scope.messages.push({
      user: 'chatroom',
      text: 'User ' + data.name + ' has joined.'
    });
    $scope.users.push(data.name);
  });

  // add a message to the conversation when a user disconnects or leaves the room
  socket.on('user:left', function (data) {
    $scope.messages.push({
      user: 'chatroom',
      text: 'User ' + data.name + ' has left.'
    });
    var i, user;
    for (i = 0; i < $scope.users.length; i++) {
      user = $scope.users[i];
      if (user === data.name) {
        $scope.users.splice(i, 1);
        break;
      }
    }
  });

  // Private helpers
  // ===============

  var changeName = function (oldName, newName) {
    // rename user in list of users
    var i;
    for (i = 0; i < $scope.users.length; i++) {
      if ($scope.users[i] === oldName) {
        $scope.users[i] = newName;
      }
    }

    $scope.messages.push({
      user: 'chatroom',
      text: 'User ' + oldName + ' is now known as ' + newName + '.'
    });
  }

  // Methods published to the scope
  // ==============================

  $scope.changeName = function () {
    socket.emit('change:name', {
      name: $scope.newName
    }, function (result) {
      if (!result) {
        alert('There was an error changing your name');
      } else {

        changeName($scope.name, $scope.newName);

        $scope.name = $scope.newName;
        $scope.newName = '';
      }
    });
  };

  $scope.sendMessage = function () {
    socket.emit('send:message', {
      message: $scope.message
    });

    // add the message to our model locally
    $scope.messages.push({
      user: $scope.name,
      text: $scope.message
    });

    // clear message box
    $scope.message = '';
  };
}

這個應用程式只會顯示一個檢視畫面,因此我們可以從 public/js/app.js 中移除轉送作業,並簡化為:

// Declare app level module which depends on filters, and services
var app = angular.module('myApp', ['myApp.filters', 'myApp.directives']);

編寫伺服器

開啟 routes/socket.js。我們需要定義一個物件來維護伺服器的狀態,以便使用者名稱不重複。

// Keep track of which names are used so that there are no duplicates
var userNames = (function () {
  var names = {};

  var claim = function (name) {
    if (!name || userNames[name]) {
      return false;
    } else {
      userNames[name] = true;
      return true;
    }
  };

  // find the lowest unused "guest" name and claim it
  var getGuestName = function () {
    var name,
      nextUserId = 1;

    do {
      name = 'Guest ' + nextUserId;
      nextUserId += 1;
    } while (!claim(name));

    return name;
  };

  // serialize claimed names as an array
  var get = function () {
    var res = [];
    for (user in userNames) {
      res.push(user);
    }

    return res;
  };

  var free = function (name) {
    if (userNames[name]) {
      delete userNames[name];
    }
  };

  return {
    claim: claim,
    free: free,
    get: get,
    getGuestName: getGuestName
  };
}());

這項設定基本上會定義一組名稱,但會使用與即時通訊伺服器網域更相關的 API。讓我們將此連結至伺服器的 Socket,以便回應用戶端發出的呼叫:

// export function for listening to the socket
module.exports = function (socket) {
  var name = userNames.getGuestName();

  // send the new user their name and a list of users
  socket.emit('init', {
    name: name,
    users: userNames.get()
  });

  // notify other clients that a new user has joined
  socket.broadcast.emit('user:join', {
    name: name
  });

  // broadcast a user's message to other users
  socket.on('send:message', function (data) {
    socket.broadcast.emit('send:message', {
      user: name,
      text: data.message
    });
  });

  // validate a user's name change, and broadcast it on success
  socket.on('change:name', function (data, fn) {
    if (userNames.claim(data.name)) {
      var oldName = name;
      userNames.free(oldName);

      name = data.name;

      socket.broadcast.emit('change:name', {
        oldName: oldName,
        newName: name
      });

      fn(true);
    } else {
      fn(false);
    }
  });

  // clean up when a user leaves, and broadcast it to other users
  socket.on('disconnect', function () {
    socket.broadcast.emit('user:left', {
      name: name
    });
    userNames.free(name);
  });
};

應用程式應該已完成。請執行 node app.js 試試看。應用程式應透過 Socket.IO 即時更新。

結論

您可以為這個即時通訊應用程式新增更多內容,例如提交空白訊息。您可以使用 ng-valid 在用戶端防止這種情況,並在伺服器上進行檢查。伺服器或許可以保留最近的訊息記錄,方便新使用者加入應用程式。

只要瞭解如何在服務中包裝其他程式庫,並通知 Angular 模型已變更,就能輕鬆編寫可使用其他程式庫的 AngularJS 應用程式。接下來,我將說明如何搭配使用 AngularJS 和熱門的視覺化程式庫 D3.js

參考資料

Angular Socket.IO Seed 完成即時通訊應用程式 AngularJS Express Socket.IO`