在本文中,咱們將一步步編寫出來聊天室PC端和APP端從新作一次梳理,以及進行一些優化,讓整個項目更容易理解和擴展。其次咱們還會介紹一些前端流行的工具,幫助咱們構建項目,便於發佈。javascript
目前PC端和APP端項目的架構大體類似,以下圖:css
箭頭表明了讀取數據的流向,服務端和客戶端基本上都分爲三層:html
基於上面的問題,咱們作出如下調整:前端
新的結構應該像下面這樣:java
首先簡化app.js:node
// ... var api = require('./services/api') var socketApi = require('./services/socketApi') // ... app.post('/api/login', api.login) app.get('/api/logout', api.logout) app.get('/api/validate', api.validate) // ... io.sockets.on('connection', function(socket) { socketApi.connect(socket) socket.on('disconnect', function() { socketApi.disconnect(socket) }) socket.on('technode', function(request) { socketApi[request.action](request.data, socket, io) }) }) // ...
咱們把http和socket的回調分別放到api.js和socketApi.js中,在socket通訊方面作了簡化,使用technode
做爲統一的事件名,而須要調用的接口名,則由請求數據中的action
來決定。每一個socket請求都會變成下面這樣:jquery
客戶端的請求:git
socket.emit('technode', { action: 'getRoom' })
下面是服務端的返回:github
socket.emit('technode', { "action": "getRoom", "data": [{ "name": "Socket.IO", "_id": "52b0e5dd0a5e66fa26000001", "__v": 0, "createAt": "2013-12-18T00:01:33.528Z", "users": [], "messages": [] }] })
客戶端則根據action,進行不一樣的處理:web
socket.on('technode', function (data) { switch (data.action) { // ... } })
而自己api.js和socketApi.js內的處理,與第三章的基本無異,再也不細說。
爲何須要客戶端緩存?有兩點緣由:
咱們須要一個緩存數據和共享數據的組件,這個組件將服務端請求來的數據緩存下來,避免重複的從服務端請求相同的數據,其次是對全部的controller提供接口,讓controller間能夠共享(讀取、修改)同一份數據。
咱們把這個組件命名爲server,與服務端通訊徹底經過這個組件,數據緩存到這個組件之中,controller直接與它通訊,沒必要關心真正的服務器是什麼樣的。
angular.module('techNodeApp').factory('server', ['$cacheFactory', '$q', '$http', 'socket', function($cacheFactory, $q, $http, socket) { var cache = window.cache = $cacheFactory('technode') socket.on('technode', function(data) { switch (data.action) { case 'getRoom': if (data._roomId) { angular.extend(cache.get(data._roomId), data.data) } else { data.data.forEach(function (room) { cache.get('rooms').push(room) }) } break // case something else // handle for socket events } }) socket.on('err', function (data) { // handle server err }) return { validate: function() { var deferred = $q.defer() $http({ url: '/api/validate', method: 'GET' }).success(function(user) { angular.extend(cache.get('user'), user) deferred.resolve() }).error(function(data) { deferred.reject() }) return deferred.promise } // more API } }])
在server中,咱們使用了兩個Angular提供的組件,$q
和$cacheFactory
。
$q是Angular對JavaScript異步編程模式Promise的實現,參考了https://github.com/kriskowal/q 。在TechNode對它的用法相對比較簡單,僅僅是將Ajax請求隱藏起來。以server.validate爲例:
validate: function() { var deferred = $q.defer() $http({ url: '/api/validate', method: 'GET' }).success(function(user) { angular.extend(cache.get('user'), user) deferred.resolve() }).error(function(data) { deferred.reject() }) return deferred.promise }
$q.defer()
獲取一個differed(推遲)對象,而後return deferred.promise
先返回promise(承諾),在服務器端成功返回後,resolve(兌現)承諾,或者遇到問題,reject(拒絕)兌現。
在technode.js中咱們能夠這樣使用:
server.validate().then(function() { if ($location.path() === '/login') { $location.path('/rooms') } }, function() { $location.path('/login') })
server.validate()
獲取promise(承諾)對象,then(resolvedCallback, rejectCallack)(而後)根據承諾的兌現狀況進行不一樣的處理。
換句話說,technode.js中的techNodeApp
問server,用戶是否是登陸了,server必須調用服務端接口進行驗證,所以server給techNodeApp
許諾,techNodeApp
則只須要針對許諾是否兌現進行處理就行了。
全部與http請求相關的接口,咱們都作了類似的處理。
$cacheFactory是Angular提供的緩存組件,該組件直接將數據存放在內存中。
var cache = window.cache = $cacheFactory('technode') // ... cache.put('rooms', []) // ... cache.get('rooms') && cache.get('rooms').forEach(function(room) { if (room._id === _roomId) { room.users = room.users.filter(function(user) { return user._id !== _userId }) } })
直接調用$cacheFactory,傳入cacheId,Angular就爲我構造出一塊緩存區域,咱們就能夠經過get、put等等方法來存儲或者獲取緩存數據了。
$cacheFactory還提供了一種TechNode中未使用的特性,即這塊緩存能夠是LRU的,什麼是LRU?即這塊緩存是有大小的(避免緩存開銷過大,影響網易性能),而且這塊緩存使用LRU算法來淘汰長時間未使用的數據。
有了server,咱們來看看controller有什麼變化?這是原來的RoomCtrl的代碼:
angular.module('techNodeApp').controller('RoomCtrl', function($scope, $routeParams, $scope, socket) { socket.on('rooms.read' + $routeParams._roomId, function(room) { $scope.room = room }) socket.emit('rooms.read', { _roomId: $routeParams._roomId }) socket.on('messages.add', function(message) { $scope.room.messages.push(message) }) // ... socket.on('users.join', function (join) { $scope.room.users.push(join.user) }) socket.on('users.leave', function(leave) { _userId = leave.user._id $scope.room.users = $scope.room.users.filter(function(user) { return user._id != _userId }) }) })
這是基於server組件修改後的RoomCtrl:
angular.module('techNodeApp').controller('RoomCtrl', ['$scope', '$routeParams', '$scope', 'server', function($scope, $routeParams, $scope, server) { $scope.room = server.getRoom($routeParams._roomId) // ... }])
咱們能夠發現以下的變化:
RoomCtrl只需調用server.getRoom,傳入房間的id便可。那房間信息不是須要到服務端讀取麼?這是怎麼實現的?
這徹底得益於Angular數據綁定特性,即數據變化,視圖也會跟着變化:
getRoom: function(_roomId) { if (!cache.get(_roomId)) { cache.put(_roomId, { users: [], messages: [] }) socket.emit('technode', { action: 'getRoom', data: { _roomId: _roomId } }) } return cache.get(_roomId) }
這裏的處理方式與promise
有殊途同歸之妙。getRoom
方法,若是在緩存中沒有找到房間的數據,就先新建一個房間對象,不過裏面的數據都是空的(此時,RoomCtrl渲染出來的是一個空的房間視圖),而後經過socket向服務端請求房間數據;若是找到就直接返回從緩存中獲取的房間數據,RoomCtrl就能夠渲染出來一個正常的房間視圖。
而在服務端返回房間信息後,
case 'getRoom': if (data._roomId) { angular.extend(cache.get(data._roomId), data.data) } else { data.data.forEach(function (room) { cache.get('rooms').push(room) }) }
咱們使用服務端的數據填充到空房間便可,Angular即根據數據的變化,渲染出新的房間視圖。
咱們必須保證更新的房間對象必須是視圖綁定的對象,所以咱們一開始就返回一個房間對象,後面只是修改這個對象的屬性。
同理,RoomCtrl也無需出來用戶進入或者離開房間,有新消息這類事件,由於server組件會自動更新對應的數據,RoomCtrl只須要按照數據渲染便可。
好了,咱們利用客戶端緩存和Angular數據綁定特性,大大簡化了TechNode控制器層。到此,咱們的開發之旅已經接近尾聲,接下來,咱們將學習如何將前端程序打包,發佈!
開發時,爲了解耦和便於維護,咱們把代碼拆成單獨的文件,JavaScript代碼、CSS代碼和HTML都是單獨的。在生產環境中,爲了提升性能,咱們須要把這些分開的文件合併到一塊兒。若是你的網站使用CDN的化,咱們還須要給每一個版本的文件,添加上惟一的標識,便於維護CDN的緩存。
Grunt是目前JavaScript最流行的項目自動化構建工具。Grunt官方提供了不少插件,也有大量的第三方插件。咱們能夠輕鬆地使用Grunt檢查、壓縮合並代碼,甚至發佈應用程序。咱們將基於grunt-usemin等幾個流行的Grunt插件來構建TechNode項目。
首先咱們須要作一些準備,安裝Grunt命令行和運行時,在TechNode根目錄新建Gruntfile.js。
npm install -g grunt-cli && npm install grunt --save-dev && touch Gruntfile.js
爲了使用grunt-usemin來壓縮咱們的代碼,咱們須要在index.html添加一些特殊的註釋來來幫助grunt-usemin找到須要合併的文件:
<!-- build:css /css/technode.css --> <link rel="stylesheet" href="/components/bootstrap/dist/css/bootstrap.min.css"> <link rel="stylesheet" href="/styles/style.css"> <link rel="stylesheet" href="/styles/login.css"> <link rel="stylesheet" href="/styles/rooms.css"> <link rel="stylesheet" href="/styles/room.css"> <!-- endbuild --> <script type="text/javascript" src="/socket.io/socket.io.js"></script> <!-- build:js /script/technode.js --> <script type="text/javascript" src="/components/jquery/jquery.js"></script> <script type="text/javascript" src="/components/bootstrap/dist/js/bootstrap.min.js"></script> <script type="text/javascript" src="/components/angular/angular.js"></script> <script type="text/javascript" src="/components/angular-route/angular-route.js"></script> <script type="text/javascript" src="/components/moment/moment.js"></script> <script type="text/javascript" src="/components/angular-moment/angular-moment.js"></script> <script type="text/javascript" src="/components/moment/lang/zh-cn.js"></script> <script type="text/javascript" src="/technode.js"></script> <script type="text/javascript" src="/services/socket.js"></script> <script type="text/javascript" src="/services/server.js"></script> <script type="text/javascript" src="/router.js"></script> <script type="text/javascript" src="/directives/auto-scroll-to-bottom.js"></script> <script type="text/javascript" src="/directives/ctrl-enter-break-line.js"></script> <script type="text/javascript" src="/controllers/login.js"></script> <script type="text/javascript" src="/controllers/rooms.js"></script> <script type="text/javascript" src="/controllers/room.js"></script> <script type="text/javascript" src="/controllers/message-creator.js"></script> <!-- endbuild -->
咱們分別在css和javascript的引用周圍加上了註釋,<!-- build:css /css/technode.css -->
標明咱們須要把下面這些css都合併到technode.css這個文件中,javascript全都合併到technode.js中。
注意,socket.io.js這個文件並無包含進來,由於它是socket.io本身輸出的,並無在咱們的本身的源碼中。固然,咱們甚至能夠把這個文件保存到源碼中,本身引用也是能夠的。
首先使用grunt-contrib-copy將不須要打包壓縮的文件拷貝到build目錄中,修改Gruntfile.js
module.exports = function (grunt) { grunt.initConfig({ copy: { main: { files: [ {expand: true, cwd: 'static/components/bootstrap/dist/fonts/', src: ['**'], dest: 'build/fonts'}, {'build/index.html': 'static/index.html'}, {'build/favicon.ico': 'static/favicon.ico'} ] } } }) grunt.loadNpmTasks('grunt-contrib-copy') grunt.registerTask('default', [ 'copy' ]) }
grunt-usemin爲咱們提供了一個useminPrepare的task,這個task就是基於咱們在index.html文件中的配置,自動生成合並和壓縮代碼的配置:
module.exports = function (grunt) { grunt.initConfig({ copy: { main: { files: [ {expand: true, cwd: 'static/components/bootstrap/dist/fonts/', src: ['**'], dest: 'build/fonts'}, {'build/index.html': 'static/index.html'}, {'build/favicon.ico': 'static/favicon.ico'} ] } }, useminPrepare: { html: 'static/index.html', options: { dest: 'build' } } }) grunt.loadNpmTasks('grunt-usemin') grunt.loadNpmTasks('grunt-contrib-copy') grunt.registerTask('default', [ 'copy', 'useminPrepare' ]) }
npm install grunt-usemin --save-dev
,運行grunt
試試看:
Running "useminPrepare:html" (useminPrepare) task Going through static/index.html to update the config Looking for build script HTML comment blocks Configuration is now: concat: { generated: { files: [ { dest: '.tmp/concat/css/technode.css', src: [ 'static/components/bootstrap/dist/css/bootstrap.min.css', 'static/styles/style.css', 'static/styles/login.css', 'static/styles/rooms.css', 'static/styles/room.css' ] }, { dest: '.tmp/concat/script/technode.js', src: [ 'static/components/jquery/jquery.js', 'static/components/bootstrap/dist/js/bootstrap.min.js', 'static/components/angular/angular.js', 'static/components/angular-route/angular-route.js', 'static/components/moment/moment.js', 'static/components/angular-moment/angular-moment.js', 'static/components/moment/lang/zh-cn.js', 'static/technode.js', 'static/services/socket.js', 'static/services/server.js', 'static/router.js', 'static/directives/auto-scroll-to-bottom.js', 'static/directives/ctrl-enter-break-line.js', 'static/controllers/login.js', 'static/controllers/rooms.js', 'static/controllers/room.js', 'static/controllers/message-creator.js' ] } ] } } uglify: { generated: { files: [ { dest: 'build/script/technode.js', src: [ '.tmp/concat/script/technode.js' ] } ] } } cssmin: { generated: { files: [ { dest: 'build/css/technode.css', src: [ '.tmp/concat/css/technode.css' ] } ] } }
它爲咱們生成了原本須要手動編寫的其餘task的配置,接下來,安裝其餘幾個須要的grunt task,繼續修改Gruntfile.js:
module.exports = function (grunt) { grunt.initConfig({ copy: { main: { files: [ {expand: true, cwd: 'static/components/bootstrap/dist/fonts/', src: ['**'], dest: 'build/fonts'}, {'build/index.html': 'static/index.html'}, {'build/favicon.ico': 'static/favicon.ico'} ] } }, useminPrepare: { html: 'static/index.html', options: { dest: 'build' } } }) grunt.loadNpmTasks('grunt-usemin') grunt.loadNpmTasks('grunt-contrib-copy') grunt.loadNpmTasks('grunt-contrib-concat') grunt.loadNpmTasks('grunt-contrib-uglify') grunt.loadNpmTasks('grunt-contrib-cssmin') grunt.registerTask('default', [ 'copy', 'useminPrepare', 'concat', 'uglify', 'cssmin' ]) }
安裝好新的依賴,再運行grunt試試看。首先concat根據useminPrepare生成的配置,將css和js分別合併到.tmp/concat/css/technode.css和.tmp/concat/script/technode.js中;而後uglify和cssmin分別將這兩個文件壓縮成了build/css/technode.css和build/script/technode.js,咱們的css文件和js文件就打包壓縮好了。
除此以外咱們還須要把pages中的html內聯到index.html中。在Angular中,咱們既能夠將模板文件單獨放在不一樣的html文件中,也能夠像下面這樣,內聯在html中:
<script type="text/ng-template" id="/pages/login.html"> <form class="form-inline form-login" ng-submit="login()"> <div class="form-group"> <label class="sr-only">Gmail</label> <input type="email" required class="form-control" ng-model="email" placeholder="Gmail帳號" /> </div> <button type="submit" class="btn btn-primary btn-enter">進入</button> </form> </script>
grunt-inline-angular-templates
就能夠實現這樣的需求:
inline_angular_templates: { dist: { options: { base: 'static/', prefix: '/' }, files: { 'build/index.html': ['static/pages/*.html'] } } }
使用grunt-rev,爲靜態文件加上惟一標識,使用grunt-contrib-clean在每次打包開始時,清除.tmp和build裏的內容:
rev: { options: { encoding: 'utf8', algorithm: 'md5', length: 8 }, assets: { files: [{ src: [ 'build/**/*.{jpg,jpeg,gif,png,js,css,eot,svg,ttf,woff}' ] }] } }, clean: { main:['.tmp', 'build'] }
最後,使用grunt-usemin提供的task usemin,將html中標記的合併區塊已經css中的字體引用使用build目錄中對應的壓縮作了惟一標記的文件名替換掉:
grunt.registerTask('default', [ 'clean', 'copy', 'useminPrepare', 'concat', 'uglify', 'cssmin', 'rev', 'usemin', 'inline_angular_templates' ])
因而咱們整個構建的過程結束了,全部文件都按照咱們想要的方式處理好了。
咱們再來回顧一下打包的過程,開始那麼多的js,首先被concat到了tmp/concat/technode.js中,而後aglify壓縮到build/script/tecnhode.js中,接着rev根據文件內容爲其生成了惟一的標示7add9650.technode.js
,最後,usemin再把build/index.html中的js區塊換成了<script src="/script/7add9650.technode.js"></script>
。這就是咱們採用的整個打包壓縮過程。同理css也是如此。
發佈以前咱們還須要作一些準備工做,咱們須要讓生產環境中訪問的是打包壓縮過的靜態文件,express爲咱們提供了一種區分開發環境和生產環境的方式:
app.configure('development', function () { app.set('staticPath', '/static') }) app.configure('production', function () { app.set('staticPath', '/build') }) app.use(express.static(__dirname + app.get('staticPath')))
若是咱們運行node app.js
express默認採用的是development環境,咱們可使用NODE_ENV=production node app.js
來啓用生產環境的配置,咱們這裏的作法很簡單,將靜態文件的路徑指定到編譯後的/build目錄便可。
mongodb://technode:technode@troup.mongohq.com:10046/technode
web: NODE_ENV=production node app.js
,讓TechNode以生產模式啓動。