JavascriptでAjaxyなヌルヌル動くUIを作成すると
- HTML要素上のデータ
- ブラウザメモリ上のデータ
- サーバサイドのデータ
というように大体データは3層に分かれることになる。これはブラウザアプリケーションに限った話だけではなくWPFのようなBinding機能のない言語で開発するWindowsのクライアントアプリケーションでも同じような問題に行き当たるので普遍的な話だ。ただ、Javascriptの場合はHTMLの要素から値を取り出すのにjQueryなどのセレクターを多用して目的の要素を見つけ、値を取り出し、データに設定し、Ajaxでデータを保存する、という流れになるのだけれど、こういったUI要素の操作とデータの操作をそこかしこで混ぜ合わせながら行わなければならず、さらにその箇所のコードを切り離すのも何かと難しい・面倒なので規模が大きくなるほどにコードのメンテナンスがディモールト大変になっていってしまう。
というわけで、そんな状況を打破するためのクライアントフレームワークがここ2~3年で大変充実してきていて、今回解説する
Backbone.js、もちっとお手軽らしい
Ember.js、もっともっとシンプルな
Knockout.jsなどなど、どれから手にとって良いやら迷うこと必至なほど盛況だ。で、とりあえず選考の一助になればと簡単なサンプルアプリケーションを作成してみた。
今回のアプリケーションの外観は下図。ソースコードは
https://github.com/yooontheearth/backbone-tutorialから取得可能になっている。
ご覧の通りよくある一般的な尼子十勇士系のアプリケーションだ。左側のリストを選択するとその詳細が右側に表示され、追加、編集、保存が可能となっている。今回のサンプルではBackbon.jsにフォーカスしたかったのでサーバサイドでの永続化処理(DBへの保存など)は行っていない。
アプリケーションの環境
今回のアプリケーションの環境は下記の通り。
- サーバ Ubuntu@12.04
- Webサーバ Node.js@0.6.9
- サーバフレームワーク Express.js@2.5.0
- Viewエンジン Jade@0.26.0
- CSSエンジン Stylus@0.23.0
- クライアントフレームワーク Backbone.js@0.9.2
- CoffeeScript
WebサーバはNode.jsを使用しているけれど、RestfulなRequest、Responseが行えるならば何でもかまわないので、Python Django、ASP.NET MVC、Ruby on Railsなどなど自分の好みにあわせて変えてもらいたい。
Node.jsのセットアップ方法などはNode.jsを
Ubuntu 11.10にセットアップする ついでにExpress.jsもセットアップするなどを参考にしてもらいたい。
ソースコードをどこか適当な場所に$ git cloneなりダウンロードして展開したら下記コマンドで必要なモジュールをインストールする。
$ npm install -d
package.jsonに記述してあるもので必要なモジュールはすべてそろっていると思うけれど、サンプルを作った環境はグローバルにたくさんモジュールをインストールしすぎていて何が必要なのかよく分からなかったので少し適当だ。実行時にエラーが出たらその都度必要なモジュールを追加してほしい。今回はコーディングをCoffeeScriptで行っているので下記コマンドでCoffeeScriptをグローバルにインストールする。
$ npm install coffee-script -g
これで準備が整ったので下記コマンドでアプリケーションを実行してみよう。
$ NODE_PATH=/usr/local/lib/node_modules coffee app
"Express server listening on port 3000 in development mode"
とかなんとか表示されていたら正常に起動できたので下記URLへアクセスしてみよう。
http://localhost:3000/index
先ほどの尼子十勇士的なリストが表示されていたら正常に動作している。
Backbone.jsの解説
Backbone.jsは大きく3つのパートに分けられる。
- Model,Collectionのデータ系
- Viewの表示系
- Routerの管理系
Modelは単一のデータを表しここでは武将単体のデータのまとまりになる。Collectionは武将をリストとして扱うためのデータのまとまりになる。Viewは左側のリストや上部のボタン、右側の詳細表示のUIを表す。Routerはそれらのデータ・UIを操作・管理するための機能を提供し、サーバとクライアントの仲立ちを行う。また、データ系はサーバへのリクエストをラッピングしてくれているので、データの取得、保存時に$.ajaxのような記述の必要がなく、ある一定の規則に基づいて決定されたリクエスト先へ操作にあわせた適切なHTTP動詞でアクセスしてくれる。
今回のアプリケーションで設定されているRouteは下図の通り。
Route | 動詞 | 説明 |
/index | get | 初期表示を行う |
/list | get | リストデータを取得する。Collection.fetch()でリクエストされる。今回は実装していないけれど本来的にはページングなどを行う |
/busho | post | 武将情報の新規追加を行う。Model.save()時にModelが新規(Model.isNew())に作成されたものの場合はpostリクエストされる |
/busho/:id | put | 武将情報の更新を行う。Model.save()時にModelが新規でないものの場合はputリクエストされる |
/busho/:id | delete | 武将情報の削除を行う。Model.destroy()でリクエストされる |
Routeの詳細は./app.coffeeに記述されているので参照してもらいたい。
ここから個別にBackbone.jsを使ったコーディングを見ていこう。ソースコードは./public/javascripts/index.coffeeを参照してもらいたい。
データ系
# 武将モデル
Busho = Backbone.Model.extend
urlRoot: 'busho' # Bushoモデルがサーバへのリクエストを行うときの基本となる部分の指定
defaults: # templateで使用するプロパティがundefinedだとエラーになるので初期値を設定しておく
id:null
name:null
description:null
imageUrl:null
initialize: ->
@on 'error', @failOnValidation
@on 'destroy', @close
validate: (attrs) ->
if not attrs.name? or attrs.name.length == 0
return '名称は必須です' # 検証に失敗した場合はメッセージを返す
failOnValidation: (model, error) -> alert error # 検証に失敗した場合のイベントハンドリング
close: ->
@off()
ここで指定しているurlRootの部分が前述のRouteの部分(/busho, /busho/:id)と対応するようになる。
# 武将モデルコレクション
BushoList = Backbone.Collection.extend
model: Busho # createを呼び出す場合はmodelの指定は必須
url: 'list' # fetchするときのリクエスト先
ここで指定しているurlの部分が前述のRouteの部分(/list)と対応するようになる。
ビュー系
# リストビュー
ListView = Backbone.View.extend
tagName: 'table'
initialize: ->
# modelはBushoListなのでBushoListが変更されたときに備えてイベント登録
@model.bind 'reset', @render, this
@model.bind 'add', (busho)=>
$(@el).append new ListItemView(model:busho).render().el # tableに新しい行を追加する
render: ->
$(@el).empty()
_.each @model.models, (busho) -> # BushoListの内容をtableに行として追加する
$(@el).append new ListItemView(model:busho).render().el
, this
return this
このビューはリスト=CollectionをModelとして受け取り、Collectionのデータ構造をUI的に表すのと、Collectionの変化(リストの更新、追加など)をUIへと反映させる責任を持つ。またtagNameはビューで作成される要素を表しデフォルトではDIVになり、elはその作成された要素を表す。
# リストビューアイテム
ListItemView = Backbone.View.extend
tagName: 'tr'
template: _.template $('#tpl-list-item').html()
initialize: ->
# modelはBushoなのでBusho情報が変更されたときに備えてイベント登録
@model.bind 'change', @render, this
@model.bind 'destroy', @close, this
render: ->
$(@el).html @template @model.toJSON()
return this
events:
'click td':'select' # 行が選択された処理をフックするためにUI要素のイベント登録を行う
select: ->
$('tr.selected').removeClass('selected')
$(@el).addClass('selected')
app.navigate "busho/#{@model.id}", true # 詳細情報を表示するためにルーターに通知する
close: ->
$(@el).off().remove()
templateプロパティへビューに表示するHTML要素を設定する。今回はUnderscore.jsのテンプレートを使用しているけれど、もちろんjQueryTemplateでもなんでもかまわない。ただここにHTML要素をだらだらと記述するのは関心の分離を進めるMVC的には正しくないのでテンプレートを使用するようにしよう。eventsプロパティに要素内でフックしたいイベントの記述をする。構文的には'イベント セレクタ':'イベントハンドラ'となる。
# 詳細情報画面
DetailsView = Backbone.View.extend
template: _.template $('#tpl-details').html()
render: ->
$(@el).html @template @model.toJSON()
$(@el).find('#delete').hide() if @options.hideDelete
return this
events:
'change input,textarea':'changeData' # UI要素に入力された情報をモデルに反映するためのイベントハンドリング
'click #save':'save'
'click #delete':'delete'
changeData:(event)->
# changeイベントでデータを必ずしも反映させる必要はなく、アプリの要件によっては保存ボタン押下時などにまとめて行っても良い
changeDataSet = {}
changeDataSet[event.target.name] = event.target.value
@model.set changeDataSet, silent:true # silent:trueで検証を無効化している
save: ->
if @model.isNew() # 新規登録時はリストに追加する
app.list.create @model,
wait:true
success: (model, response) =>
@model.set 'id', app.list.length # IDの採番は適当。本来ならばサーバからのレスポンスにIDを渡しておいて設定するとか
app.navigate '', true
error:(model, error)-> # サーバサイドでのエラーはresponseTextを参照、それ以外はクライアントでの検証エラー
alert if error.responseText then error.responseText else error
else
@model.save {},
success: (model, response) ->
# とくに処理なし
error: (model, error) ->
alert if error.responseText then error.responseText else error
return false
delete: ->
return false unless confirm '削除しますか?'
@model.destroy
success: ->
app.navigate '', true
return false
close: ->
$(@el).off().empty()
save時に新規の場合はリストに要素を追加したいのでapp.list.createとしているけれど、Backbone.jsの内部的にはModel.save()が呼び出されている。
# ヘッダービュー
HeaderView = Backbone.View.extend
template: _.template $('#tpl-header').html()
render: ->
$(@el).html @template()
return this
events:
'click #refresh':'refresh'
'click #add':'add'
refresh: ->
app.navigate 'refresh', true
return false
add: ->
app.navigate 'busho/add', true
return false
Routerへの通知を行うだけのビュー。
管理系
AppRouter = Backbone.Router.extend
routes:
'busho/add':'add'
'busho/:id':'details'
'refresh':'list'
'':'closeDetails'
initialize: ->
@list = new BushoList()
@list.reset _bushoList # 初期表示するデータはページロード時に用意してあるのでそちらから取得
@listView = new ListView model:@list
$('#list').html @listView.render().el
$('#header').html new HeaderView().render().el # ヘッダービューをDOMツリーに反映
add:->
@detailsView.close() if @detailsView
@detailsView = new DetailsView
model:new Busho()
hideDelete:true # 追加なので削除ボタンは不要
$('#details').html @detailsView.render().el # 詳細ビューをDOMツリーに反映
details:(id)->
@detailsView.close() if @detailsView
busho = @list.get id # リストから詳細を表示するアイテムを取得
@detailsView = new DetailsView model:busho
$('#details').html @detailsView.render().el # 詳細ビューをDOMツリーに反映
list:->
@list.fetch() # リストの更新
# ※fetch({data: {page: 3}}) のような形でQueryStringを渡せるのでページングなどもfetchで行える
closeDetails:->
@detailsView.close() if @detailsView # 詳細ビューが開いていたら閉じる
routesプロパティにクライアント側でページの遷移を制御するためのRouteを登録しておく。Routerはroutesプロパティに基づいてリクエストされた処理を行い、クライアントとサーバの仲立ちを行う。
呼び出し
app = null
$(document).ready ->
app = new AppRouter()
Backbone.history.start()
使用方法はいたって簡単。AppRouterをインスタンス化するだけ。あとはRouterがよきように取り計らってくれる。
まとめ
ここまで駆け足で説明してきたけれどいかがだろうか?今回のサンプルではデータの永続化処理を行っていないので微妙な部分も多々あるけれど、Backbone.jsを使うとクライアントとサーバ、ビューとデータ、UI操作とデータ処理の分離が上手に行えるのが分かったかと思う。ただBackbone.jsを使ったとしてもアプリケーションの規模が大きくなってくれば必然的にコード量が増えるのでどうしても管理は煩雑になっていき適切なサブモジュール化などを行う必要が出てくるけれど、そのサブモジュール化自体をBackbone.jsのおかげで容易に行える実感は持てたのではと思う。UI要素とデータの結びつきとデータの更新に伴うUI要素への反映をさっくりとできるBackbone.jsはどのような場面でも非常に有用なので是非活用してもらいたい。