Socket.ioとBackbone.jsによるSingle Page Application概要編に続いて実装編としてDemoのソースを解説します。
Demoのソースはgithubで公開しています。
backbone_socket_io_reqev
デモの構成
-- app.js
|- public - index.html
|
|- backbone - spa.js
| template - timer.jst.ejs
| |- time.jst.ejs
|
|- app - router - sample_router.js.coffee
|- models - timer.js.coffee
|- views - timer_view.js.coffee
|- time_view.js.coffee
ソース解説
サーバ側
var express = require('express')
var Timer = require('./timer')
var path = require('path')
var http = require('http')
var app = express();
var Mincer = require('mincer');
var environment = new Mincer.Environment();
environment.appendPath(__dirname + '/backbone');
app.use('/assets', Mincer.createServer(environment));
app.configure(function(){
app.set('port', 40000);
});
app.get("/",function(req,res){
res.sendfile('public/index.html');
});
app.get("/spa/*",function(req,res){
res.sendfile('public/index.html');
});
var server = http.createServer(app);
server.listen(app.get('port'), function(){
console.log("Express server listening on port " + app.get('port'));
});
var socketIO = require('socket.io');
var IOReqEv = require('socket.io-reqev');
var ioReqEv = new IOReqEv(socketIO.listen(server));
ioReqEv.register("/timer",new Timer());
Node.jsで構築しています。
SmartFXではWebアプリサーバをrails、Socket.ioサーバをnode.jsと分けた構成なのですが、単純なプロジェクトであれば今回のDemoのようにnode.jsでsocket.ioサーバとWebサーバを兼用するのもいいと思います。
最初のほうにあるMincerというのは、Railsで使われているSprocketsのNode.js移植版です。
CoffeeScript等のAltJSをコンパイルしたり、JSTを使うことによりtemplateファイルを別ファイルとして管理することができ、前もってコンパイルされるので、動作も速くなります。
Development環境では今回のようにMiddlewareとして使うことで、遅くなりますがファイルが更新されると動的に反映させることができます。
mincerについては長くなるので後でAsset Pipelineのすすめを参照ください。
今回は単純なSPAなので、app.get("/")とapp.get("/spa/")で登録しているhandlerは、静的なindex.htmlを返しているだけです。
/spa/ の * は splatと呼ばれるもので、/spa/以下にどんなPathが続いてもこのHandlerを使いますという意味です。
ここはpushStateで使う上で重要です。
Backbone.js側で切り替えるURLの先頭を/spaとつけることで、リロードされても同じページを返すだけの処理をすれば、Backbone側でURLを解析して該当の画面が表示されます。
Express(Webサーバ)とSocket.ioでポートを共有しています。
socket.ioはそのままではなく、socket.io-reqevを挟むようにしていますが、socket.io-reqevを使うとsocket.ioのPathごとにオブジェクトが登録でき、オブジェクト側はsocket.ioを意識しないでプログラムができるようになります。
socket.io-reqevは後でSocket.IO用フレームワーク socket.io-reqevをご参照ください。
var events = require('events');
var Timer = function(){
this.events = ["five","ten","thirty"];
var that = this;
setInterval(function (){
var now = new Date();
if(now.getSeconds() % 5 == 0){
that.emit("five", {id: "five",time: now.toString()});
}
if(now.getSeconds() % 10 == 0){
that.emit("ten", {id: "ten",time: now.toString()});
}
if(now.getSeconds() % 30 == 0){
that.emit("thirty", {id: "thirty",time: now.toString()});
}
},1000);
return this;
}
Timer.prototype = new events.EventEmitter();
Timer.prototype.request = function(req,cb){
if(req=="current"){
cb(null,{id:"current", time: new Date().toString()});
}
}
module.exports = Timer;
Timerの機能は
- 5秒、10秒、30秒で割りきれたら、割り切れたイベントをemitする
- requestでcurrentが来た場合は現在時刻をコールバックで返す
の2つです。
socket.io-reqevがcallbackの場合はunicast、emitの場合はbroadcastでデータをクライアントに送信しています。
例えば "five"をemitすると、fiveイベントをsubscribeしてきたクライアントにbroadcastで時刻を返しています。
クライアント側
//= require_self
//= require_tree ./templates
//= require_tree ./app
var Spa = {socket_io_url: "http://localhost:40000"};
Spa.appStart = function(){
window.router = new Spa.SampleRouter();
Backbone.history.start({pushState: true})
}
Manifestファイルになります。上記のディレクティブにしたがって、Sprocketsもしくはmincerによってファイルがコンパイル、結合されます。
ページが読み込まれた時の最初の処理もここで定義しています。
pushStateはデフォルトではfalseでその場合はhashchange(ページ内アンカーの#)が使われます。
hashchangeがいい場合は、下記です。
1.pushStateの場合はイベントを拾ってnavigatorを呼ぶ必要があるのに対し、#の場合はAタグにhref="#users/1"など書くことで直接遷移できる。
2.pushState非対応のブラウザでも使える
ただ、#はiOS7だとapplication cacheがうまくいかなかったり、ソーシャルで他に共有する際に#が別の意味をもっていたりとおすすめしません。
<span><a href="/" <% if(type == "current"){ %> style="text-decoration:none;"<% } %>>現在</a></span>
<span><a href="/spa/timers/five" <% if(type == "five"){ %> style="text-decoration:none;"<% } %>>5秒</a></span>
<span><a href="/spa/timers/ten" <% if(type == "ten"){ %> style="text-decoration:none;"<% } %>>10秒</a></span>
<span><a href="/spa/timers/thirty" <% if(type == "thirty"){ %> style="text-decoration:none;"<% } %>>30秒</a></span>
<table>
<tbody id="timerList">
</tbody>
</table>
<%= time %>
ejsを使ったJSTです。コンパイル時に関数化されて、viewからtemplateで呼び出せます。
別ファイルで管理でき、デザイナにも優しいです。
<!DOCTYPE html>
<html>
<head>
<script type="text/javascript" src="http://cdnjs.cloudflare.com/ajax/libs/jquery/2.0.3/jquery.min.js"></script>
<script type="text/javascript" src="http://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.6.0/underscore-min.js"></script>
<script type="text/javascript" src="http://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.1.2/backbone-min.js"></script>
<script type="text/javascript" src="http://cdnjs.cloudflare.com/ajax/libs/socket.io/0.9.16/socket.io.min.js"></script>
<script type="text/javascript" src="http://49.212.183.126:8080/js/io-reqev-client.js"></script>
<script type="text/javascript" src="/assets/spa"></script>
<title>sample</title>
</head>
<body>
<div id="container"></div>
<script type="text/javascript">
$(document).ready(function(){
Spa.appStart();
})
</script>
</body>
</html>
一枚ペラのhtmlです。必要なjsのincludeを行い、内容を差し替える用のdivを用意し、初期化処理を呼び出しているのみです。
class Spa.SampleRouter extends Backbone.Router
initialize: (options)->
@timers = new Spa.TimerCollection()
routes:
"spa/timers/:type": "timer"
".*": "timer"
timer:(type)->
@view.remove() if @view
@view = null
type ||= "current"
@view = new Spa.TimerView(collection: @timers,type: type)
$("#container").html(@view.render().el)
Routerです。
TimerCollectionはSocket.ioを使ってデータを管理しているcollectionです。
Socket.ioは一度つなぐとつなぎっぱなしのため、View表示ごとに生成するのではなく、初期時に作成しています。
routesでURLとURLに対するメソッドを提供していて、Demoで提供する画面は1つのみです。
/の場合は現在を指定、それ以外は、/spa/timersの後に5秒、10秒、30秒を示す"five","ten","thirty"のいずれかがはいります。
timerの関数の先頭でviewがあれば、remove()を呼び出しています。
これはBackbone.jsではお決まりのイディオムで、removeを呼び出すことで、view内でlistenToで登録したりしたeventsが開放されます。
これを呼びださずに@viewを上書きしてしまうとリークしてしまいます。
class Spa.Timer extends Backbone.Model
class Spa.TimerCollection extends Backbone.Collection
model: Spa.Timer
socket: null
@types: ["current","five","ten","thirty"]
initialize: ()->
@add(_.map(Spa.TimerCollection.types,(type)-> new Spa.Timer(id: type)))
@socket = new IOReqEvClient(Spa.socket_io_url + "/timer", (obj)=> @update(obj))
update: (obj)->
@add(obj,merge: true)
watch: (id)->
return if !id || !@get(id)
@get(id).unset("time")
param = if id == "current" then {requests: id} else {events: id}
@socket.watch(param)
unwatch: ()->
@socket.unwatch()
ModelおよびCollectionです。
Collectionの初期処理で今回使うTimerの種別分のModelを作成しています。
最初に作ってしまう理由は、modelがない、modelがあって時刻がない、modelがあって時刻があるの3パターンよりmodelがあって時刻がない、modelがあって時刻があるの2パターンになったほうが嬉しいと思ったからです。
IOReqEvClientはsocket.io-reqevのdistに含まれるclient用のライブラリです。
Socket.ioサーバのURL+イベントやリクエスト対象のpathを指定し、データを受信した場合のcallbackを登録しています。
updateでは単純にBackboneのcollectionのaddを呼び出していますが、mergeにtrueを指定しているため、同じIDの場合は追加ではなく更新されます。
watchはSocket.ioサーバにsubscribeしたいイベント、1回だけ受けたい(httpのGETに相当)、およびその両方をsocket.ioを通じて送信します。requestsに渡すメッセージがGETでeventsで渡すメッセージがsubscribeしたいイベントになります。
どちらも単体でも配列でも設定可能です。
データを受信すれば、上記のupdateが呼ばれて、Backbone.jsによりchangeイベントが該当のmodelのlistenerに通知されます。
unwatchはwatchすると、subscribeしたイベントがある度にsocket.ioサーバからpush通知を受けるようになるので、それを必要なくなったことをsocket.ioサーバに伝えます。
class Spa.TimerView extends Backbone.View
template: JST["templates/timer"]
events:
"click a": "changeTimer"
initialize: (options)->
@childViews = []
@type = options.type
@collection.watch(@type)
@model = @collection.get(@type)
@listenTo(@model,'change', @updateTimer)
changeTimer: (e)->
e.preventDefault()
e.stopPropagation()
router.navigate($(e.currentTarget).attr("href"), {trigger: true})
updateTimer: ()->
view = new Spa.TimeView(model: @model)
@$("#timerList").append(view.render().el)
@childViews.push(view)
render: ()->
$(@el).html(@template(type: @type))
@
remove: ()->
for v in @childViews
v.remove()
@collection.unwatch()
super()
class Spa.TimeView extends Backbone.View
template: JST["templates/time"]
el: "<tr/>"
render: ()->
$(@el).html(@template(@model.toJSON()))
@
Viewです。
初期処理で、collectionに対して、現在の種別(現在秒、5秒、10秒、30秒)をwatchに渡しています。
collectionから種別のmodelを取り出し、listenToで変更があったら、updateTimerの処理を呼び出すように登録しています。
初期化してすぐにrouterからrenderが呼び出されますが、この時は枠組みを表示するだけで、時刻データはupdateTimerによってmodelの更新時に行われます。
updateTimerは呼び出されると、子Viewを作って、tbodyに追加し、@childVeiwsに子Viewを登録しています。
eventsにはAタグがクリックされたら、そのイベントを拾って、changeTimerを呼び出すように登録しています。
changeTimerはリンク遷移のデフォルトのイベントをキャンセルして、naviagteによるpushStateを使ってrouterを経由して、また別のTimerの画面に切り替えています。
remove()は、画面の切り替えの際にはrouterで、remove()が呼ばれることになっていて、Backbone.jsのremoveをoverrideして、子Viewの開放と、subscriberをやめる処理を行った後、元々のBackbone.jsのremove()を呼び出しています。
まとめ
半日もあればここに書かれているプログラムは理解できると思います。
簡単にリアルタイムのSinglePageApplicationが作れる気になりませんか?
SPAでサクサク動くWebが世の中に増えたらいいと思います。