Rails+JSフレームワークでリアルタイム掲示板を作成してみる(Backbone.js編)


前々回のAngularJS、前回のEmber.jsときて、ラストはBackbone.jsを試してみたい。作成するリアルタイム掲示板は今までと同じ仕様。

環境

  • Ruby on Rails 4.1.6
  • Backbone.js 1.1.2
  • jquery 2.1.1

セットアップ

いつも通りGemfileから。またもやRails Assetsのお世話になる。

📄Gemfile
source 'https://rubygems.org'
source "https://rails-assets.org"

gem 'rails', '4.1.6'
gem 'sqlite3'
gem 'sass-rails', '~> 4.0.3'
gem 'uglifier', '>= 1.3.0'
gem 'coffee-rails', '~> 4.0.0'
gem 'jbuilder', '~> 2.0'

gem 'rails-assets-jquery'
gem 'rails-assets-backbone'

group :development do
  gem 'spring'
end

Backbone.jsはjqueryに依存しているので、必ず指定すること。編集が終わったら言うまでもなくbundle installを叩き込む。それからrails newを実行し終わったら、application.jsを編集する。

📄app/assets/javascripts/application.js
//= require jquery
//= require underscore
//= require backbone
//= require_tree ./backbonejs
//= require board_sample

backboneはjquery, underscoreに依存しているので、上記の順で指定する必要がある。require_treeで指定しているbackbonejsディレクトリ、それからapp/assets/javascripts直下にboard_sample.js.coffeeというファイルを作成しておこう(中身は空で構わない)。

とりあえず何か画面を表示しよう。PagesControllerを生成する。

📄app/config/routes.rb
$ rails g controller pages home

続いて、ルーティングを設定をしてあげる。

Rails.application.routes.draw do
  root to: 'pages#home'
end

さらには、レイアウトapplication.html.erbも。turbolinksとか削除しておかねばならないし。

📄app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
<head>
  <title>Board sample</title>
  <%= stylesheet_link_tag 'application', media: 'all' %>
  <%= csrf_meta_tags %>
</head>
<body>

  <%= yield %>

  <%= javascript_include_tag 'application' %>
</body>
</html>

これでhttp://localhost:3000にアクセスすると、殺風景なページが表示されるはずだ。

セットアップ完了

コンソールでBackbone.VERSIONと入力してみよう。バージョン番号が返ってくれば正しくセットアップできている。

step1. 投稿を表示する

最初のステップとして、既存の投稿を画面に表示することから始めよう。兎にも角にもテーブルとモデルがないと始まらない。さっそく生成しよう。

📄db/seeds.rb
$ rails g model Post author body
$ rake db:migrate

新規に投稿する機能を作成するまでは、データはdb/seeds.rbであつらえることにする。

Post.create!(author: '名無しさん', body: 'これは本文です')
Post.create!(author: '豊田サリー', body: 'テクマクマヤコンテクマクマヤコン')
Post.create!(author: 'ガンジー', body: "非暴力\n非服従\n言葉は要らない")

もうちょっとカッコいいデータを投入してみるのも自由だ。気に入ったデータを定義したら、rake db:seedで投入とシャレ込もう。

投稿データの取得はREST APIにてリクエストを送り、JSONで受け取ることになる。というわけで、さっそくAPIを作成するため ルーティングを設定する。

📄config/routes.rb
Rails.application.routes.draw do
  root to: 'pages#home'
  resources :posts, defaults: { format: 'json' }, only: %i(index show create)
end

JSONを返すためにPostsControllerを作成する。

📄app/controllers/posts_controller.rb
class PostsController < ApplicationController
  respond_to :json
  
  def index
    respond_with Post.all
  end
end

ここまででJSONが取得できるようになっているはずなので、http://localhost:3000/postsにアクセスしてみよう。

posts.json

いい感じにJSONが取得できるようになったので、いよいよBackbone.jsで画面に表示できるようにしていこう。

まずはモデルの作成から。app/assets/javascripts/backbonejs/modelsというディレクトリを作成して、その中にpost.js.coffeeというファイルを作成する。

📄app/assets/javascripts/backbonejs/models/post.js.coffee
@Post = Backbone.Model.extend
  urlRoot: '/posts'

@Posts = Backbone.Collection.extend
  model: Post
  url: '/posts'

とりあえず、最低限の記述を施した。Backbone.Modelのextend関数でBackboneモデルを作成することができる。これはレコード1件に相当する。urlRootで指定しているのはRESTリソースのルートパスである点に注意。平たく言えばindexに相当するURLを指定すればよいということ。Backbone.Collectionはこのモデルを複数レコード抱えるコレクションで、modelオプションでモデルを指定する。

「上手く記述できていればいいんだけど」と心配性のあなたは、ブラウザのコンソールでnew Posts().fetch()と叩いてみればよい。わらわらとデータが取得できることが分かるだろう。まだWebrickのログでも、/postsへのリクエストが来ていることが確認できるはずだ。

モデルのお次はビューに進もう。backboneとrailsを組み合わせる上で若干の設定が必要なのでまずはそれから。app/assets/javascripts/backbonejs/setting.js.coffeeファイルを作成する。

📄app/assets/javascripts/backbonejs/setting.js.coffee
# erbとの衝突を避けるため
_.templateSettings = {
    interpolate : /\{\{=(.+?)\}\}/g,
    escape : /\{\{-(.+?)\}\}/g,
    evaluate: /\{\{(.+?)\}\}/g,
};

# Ajax送信時にトークンを送信する(トークンがないとRails側で認証エラーになる)
$ ->
  token = $('meta[name="csrf-token"]').attr('content')
  $.ajaxPrefilter (options, originalOptions, xhr) ->
    xhr.setRequestHeader('X-CSRF-Token', token)

erbの識別子<%%>がUnderscore.jsとかぶっているので、これを避けるために{{=  }}, {{-  }}, {{  }}に置き換える。また、AngularJS・Ember.jsのときと同様にトークンを送信するロジックを仕込んでおこう。

では、投稿を表示するビューhome.htmlを作成していく。

📄app/views/pages/home.html
<div id="posts">
</div>

<script type="text/template" id="post-template">
  <span>{{- index }}</span>
  <span class="author">{{- post.author }}</span>
  <span class="time">{{- post.created_at }}</span>
  <div class="body">{{- post.body }}</div>
</script>

実際に投稿を表示するのはdiv#postsで、その中にレンダリングするのが#post-templateの中身だ。これが投稿1件に相当する。scriptタグで囲んでいるので、このまま開いてもも画面には何も表示されない。Backbone.Viewを作成する必要がある。

📄app/assets/javascripts/backbonejs/views/post.js.coffee
# 投稿一覧
@PostsView = Backbone.View.extend
  el: '#posts' #DOM要素を割り当てる

  initialize: ->
    @render() #描画する
    @listenTo(@collection, 'add', @addNew)  #コレクションに追加された場合のリスナー登録

  addNew: (post) ->
    postView = new PostView(model: post)
    @$el.append(postView.render().el)

  render: ->
    @collection.each (post, i) =>
      postView = new PostView(model: post, index: i)
      @$el.append(postView.render().el)
    @

# 投稿
@PostView = Backbone.View.extend
  tagName: 'div'  #ラップするタグ名
  className: 'post'
  template: _.template($('#post-template').html())
  initialize: (@options) ->
  render: ->
    template = @template(post: @model.toJSON(), index: @options.index)
    @$el.html(template)
    @

えぇ〜、こんなに記述しないといけないのか・・・と思う方もいるだろう。Backboneが提供してくれるのは本当に骨格部分だけというイメージで、データとDOMのバインド→自動反映なんて気の利いたことは一切してくれない。自力で実装する必要がある。手間がかかるなと思うかもしれないが、ある意味漢らしいといえるかも。暗黙的な規約がないという点ではEmber.jsとは対照的で、処理の流れが可視化されている方が安心という方にはこの方がよいのかもしれない。

ソースが少し長いので、部分毎にポイントを説明しておく。PostsViewは投稿の一覧を表示するためのビュー。initializeというインスタンスが生成されるときに呼び出される関数内で、自分がどのDOMに相当するのかを指定している(el: '#posts')。listenToはイベントリスナーを登録する関数で、’add’というイベントが発生した場合にaddNew関数を呼び出すように設定している。@collectionってなんぞや?とお思いかもしれないが、これはnew PostsView(…)するときにパラメータで渡す予定のPostモデルの配列だ。addNewで投稿1件に相当するPostViewをnewしている。

PostViewは生成するごとに<div class="post"...</div>というタグでラップしてほしいので、tagNameclassNameでそれぞれ値を指定している。templateには先程のhome.htmlで記述したテンプレートの中身を取得するようにしている。これも自力で書く必要があるのか・・・。

それではいよいよ画面を描画してやろうと思うので、board_sample.js.coffeeにコードを記述しよう。

📄app/assets/javascripts/board_sample.js.coffee
jQuery ->
  posts = new Posts()
  posts.fetch()
  new PostsView(collection: posts)

Postsコレクションモデルを生成し、fetch()でREST経由でデータを取得している。その後、一覧ビューを表示するためにコレクションを引数にビューをnewしちゃう。

初めての投稿表示

うまく表示出来きた。少し体裁をよくしておこう。

📄app/assets/stylesheets/posts.css.scss
.post {
  margin: 10px 0;
  border-bottom: 1px solid;

  .author {
    background: lightyellow;
    padding: 3px;
  }

  .time {
    color: gray;
    margin-left: 10px;
  }

  .body {
    white-space: pre;
    margin: 5px 0;
  }
}

投稿表示

とりあえず、全投稿を表示することができた。

step2. ヘルパーを作成する

表示されている投稿日時が人類に優しくない書式なので変更しよう。RailsならI18nとロケールで一撃だが、ここではそうはいかない。まずは日付周りの処理を便利にしてくれるmoment.jsを導入する。

📄Gemfile
gem 'rails-assets-moment'
📄app/assets/javascripts/application.js
//= require jquery
//= require underscore
//= require backbone
//= require moment
//= require_tree ./backbonejs
//= require board_sample

日付の書式を変換するのはPostViewがよいだろう。Rubyで変換するほうが楽だといって、サーバ側でやるのはよろしくない。「データをどう見せるのか」はプレゼン側(=ビュー)の仕事なので。

📄app/assets/javascripts/backbonejs/views/post.js.coffee
# 投稿
@PostView = Backbone.View.extend
...
  render: ->
    template = @template
      author: @model.get('author')
      created_at: moment(@model.get('created_at')).format('YYYY-MM-DD HH:mm:ss')
      body: @model.get('body')
      index: @options.index
    @$el.html(template)
    @

テンプレートに渡す引数を変更した。というわけでhtmlも変更する必要がある。

📄app/views/pages/home.html
<div id="posts">
</div>

<script type="text/template" id="post-template">
  <span>{{- index }}</span>
  <span class="author">{{- author }}</span>
  <span class="time">{{- created_at }}</span>
  <div class="body">{{- body }}</div>
</script>

やっとこさ、日付表示が人肌に温まった。

時刻フォーマット

step3. 投稿できるようにする

いよいよ新しい投稿をポストできるようにしよう。まずは投稿フォームを作成する。

📄app/views/pages/home.html
<div id="posts">
</div>

<form id="add-post">
  <div>
    お名前: <input type="text" id="author" required />
  </div>
  <div>
    本文:
    <textarea id="body" required></textarea>
  </div>
  <input type="submit" value="投稿する" />
</form>

<script type="text/template" id="post-template">
...

なんの変哲もないformだ。こいつ対するBackboneビューを実装しよう。

📄app/assets/javascripts/backbonejs/views/post.js.coffee
...
# 新規投稿
@AddPostView = Backbone.View.extend
  el: '#add-post'
  events:
    submit: 'submit'

  initialize: ->
    @initForm(new Post())

  # 投稿フォームの初期化
  initForm: (post) ->
    $('#author').val(post.get('author'))
    $('#body').val(null)

  # 登録
  submit: (e) ->
    e.preventDefault()
    post = new Post(author: $('#author').val(), body: $('#body').val())
    post.save() #DBに保存
    @collection.add(post)
    @initForm(post)

eventsオプションで、submitとかclickのようなDOMイベントに対する関数を割り当てることができる。submit関数内でPostモデルを生成し、save()でDBに保存する。jqueryで自力で値を取ってくる点がBackboneらしさ(?)。@collectionにaddした時点で以前リスナー登録したPostsView#addNewが走るようになっている。

投稿フォームの初期化の際に、Postモデルの初期値を使用するようにしているので、Postモデルを修正しよう。

📄app/assets/javascripts/backbonejs/models/post.js.coffee
@Post = Backbone.Model.extend
  urlRoot: '/posts'
  defaults:
    author: '名無しさん'
...

defaultsオプションでモデルの初期値を設定することができる。モデルの初期化からバリデートまではクライアント側で行い、Rails側では永続化のみを担当させるのがよさげだ。その永続化のためのPostsControllerに保存ロジックを入れてあげよう。

📄app/controllers/posts_controller.rb
class PostsController < ApplicationController
...
  def create
    respond_with Post.create!(post_params)
  end

  private

  def post_params
    params.require(:post).permit(:author, :body)
  end
end

さあこれで完成と思いきや、先程作成したAddPostViewを生成する処理が必要だということを思い出した。

📄app/assets/javascripts/board_sample.js.coffee
jQuery ->
  posts = new Posts()
  posts.fetch()
  new PostsView(collection: posts)
  new AddPostView(collection: posts)

これで新しい投稿ができるはずだ。

新規投稿

データが登録できるようになると、いまだにテンションが上がる!

step4. 未読投稿を自動的に取得する

画面を開いているだけで自動的に投稿を追記していくように修正しよう。データ取得はポーリング方式で行うので、Postsコレクションに処理を追加することにする。

📄app/assets/javascripts/backbonejs/models/post.js.coffee
@Posts = Backbone.Collection.extend
  model: Post
  url: '/posts'

  startPolling: (@interval) ->
    @fetchInterval()

  fetchInterval: ->
    @fetch()
    setTimeout(_.bind(@fetchInterval, @), @interval)

fetch()を定期的に呼び出すようにしている。_.bind(@fetchInterval, @)としているのは、普通にsetTimeoutを呼び出すとトップレベルコンテキストで関数が呼び出されてしまうため。

投稿した場合も未読分を取得しておかなければならない。自分が投稿文を書いている間に、他の誰かが投稿しているかもしれないので。というわけで、AddNewPostを修正する。

📄app/assets/javascripts/backbonejs/views/post.js.coffee
# 新規投稿
@AddPostView = Backbone.View.extend
...
  # 登録
  submit: (e) ->
    e.preventDefault()
    post = new Post(author: $('#author').val(), body: $('#body').val())
    @collection.fetch().then =>
      post.save().then =>
        @collection.add(post)
        @initForm(post)

@collection.fetch()の結果はPromiseなので、取得が完了してから自分の投稿を保存、追記するようにする。最後に、ポーリングを開始するのはもちろんboard_sample.js.coffeeだ。

📄app/assets/javascripts/board_sample.js.coffee
jQuery ->
  posts = new Posts()
  posts.startPolling(5 * 1000)
  new PostsView(collection: posts)
  new AddPostView(collection: posts)

5秒毎にデータを取得するようにした。ブラウザを2つ開いて互いに投稿してみよう。自動的に投稿が反映されるはずだ。

step5. アニメーションさせる

アニメーションというほど派手なことはしないが、投稿が追加されたことを知らせる最低限のハイライトを実装しよう。Jquery UIを使用するので、Gemfileを修正しよう。

📄Gemfile
gem 'rails-assets-jquery.ui'

もちろんapplication.jsも修正する。必ずjqueryより後ろに宣言すること。

📄app/assets/javascripts/application.js
//= require jquery
//= require jquery.ui/ui/core
//= require jquery.ui/ui/effect
//= require jquery.ui/ui/effect-highlight
//= require underscore
...

アニメーションをさせるのは、投稿ビューを追加するとき。すなわち、PostsView#addNewが相応しい。

📄app/assets/javascripts/backbonejs/views/post.js.coffee
# 投稿一覧
@PostsView = Backbone.View.extend
...
  addNew: (post) ->
    postView = new PostView(model: post)
    postView.render().$el
      .css("opacity", 0)
      .appendTo(@$el)
      .fadeTo(200, 1)
      .effect('highlight', {}, 500)
...

ブラウザをリロードして、最終確認しよう。

完了

見事なピカリ! お疲れさま。

完成版はこちらからダウンロードできます

所感

  • AngularJS, Ember.jsと違い、自分で実装しなければいけない部分が多い
  • 暗黙の規約がキモいと感じる人にはうってつけ
  • 往年のVisual Basic 6っぽさがある(html=デザインビュー、Backbone.View=フォーム.bas + イベントドリブン)
  • 自由度が高いので、カオスなコードになりやすいかも?
  • 始めるにあったてのハードルは低い
  • Jquery, Underscore.jsの知識必須

AngularJSやEmber.jsに慣れている人にとっては「Backbone.js何にもしてくれね〜」って思うかもしれないが、その名の通り『背骨』なのでそれはそれで正しいのかもしれない。軽量な骨格を求めているならば、ベストチョイスなのかも。

AngularJS, Ember.js, Backbone.jsと試してきたが、個人でWebサービスを作るという観点から見ると、バランス、そして情報量の多さからいって、やはりAngularJSが無難なチョイスだと思った。Ember.jsはちょっと大げさな気がするし、暗黙的な決まりが多いのがちょっと。。Backbone.jsはシンプルだし依存関係も少ないので結構気に入ったけど、やはり自分で色々と実装するのが面倒か。

参考リンク

関連する記事


コメントする

メールアドレスが公開されることはありません。

CAPTCHA


このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください