Backbone.jsをRailsで使った際の、初期設定とルール

こないだまでRailsとBackbone.jsを使ったWEBサービスを作ってました。折角なので、その際の初期設定とちょっとしたルールをまとめておきます。ちなみに、規模感は以下のような感じです。

・ModelとCollection 各約10個
・ViewとTemplate 各約30個
・Routerは使わない(SinglePageApplicationではないので)

バージョンは

・backbone.js: 1.1.0
rails: 3.2.13

です

ライブラリの配置

依存ライブラリは以下のように配置した

// vendor/assets/javascripts/配下
.
|-- backbone/
|   |-- backbone-min.js
|   `-- backbone-min.map
|-- json2/
|   `-- json2.js
|-- underscore/
|   |-- underscore-min.js
|   `-- underscore-min.map
...

ディレクトリ構成

jsコードのディレクトリ構成は以下のようにした。collectionsとmodelsは直下にファイルをおき、viewsとtemplatesはモデルに対してディレクトリを作ってその下にファイルを置いて行った(その構造に合わないファイルも沢山出てきたので、ある程度自由にディレクトリを切っていった)

// app/assets/javascripts/配下
.
|-- collections/
|   `-- users.js
|-- models/
|   `-- user.js
|-- templates/
|   `-- users/
|       `-- xxx.hbs
|-- views/
|   `-- users/
|       `-- xxx.js

テンプレート

テンプレートにはhandlebarを使った。rails用のgemでhandlebars_assetsというのがあったので、これを利用

https://github.com/leshill/handlebars_assets

gemをインストール

group :assets do
  gem 'handlebars_assets'
end

テンプレートはこんな感じになる

// app/assets/javascripts/templates/users/user-detail.hbs
<span>名前:</span><span>{{name}}</span>
<span>メール:</span><span>{{mail}}</span>

テンプレート関数のデフォルト名は「HandlebarsTemplates」だが、気に入らなければ変えられる。例えば「JST」という名前にしたければ、config/initializers/handlebars.rbというファイルを新規作成し、その中身を以下のようにする

HandlebarsAssets::Config.template_namespace = 'JST'

するとViewからはこのようにテンプレートを使えるようになる

// app/assets/javascripts/views/users/user_detail.js
MYAPP.UserDetailView = Backbone.View.extend({
  template: JST['users/user-detail'],

  render: function() {
    this.$el.html(this.template({
      name: this.model.get('name'),
      mail: this.model.get('mail')
    }));

    return this;
  }
});

application.js

以上を動かすためのapplication.jsは以下のようになる。require_treeでディレクトリを再起的に読み込むようにする

//= require jquery
//= require jquery_ujs
//= require json2/json2
//= require underscore/underscore-min
//= require backbone/backbone-min
//= require handlebars.runtime
//= require_tree ./templates
//= require application
//= require_tree ./models
//= require_tree ./collections
//= require_tree ./views
//= require_tree .

名前空間

名前の衝突を避けるために、アプリケーションに関する名前空間を作る(といってもグローバルなオブジェクトを1つ作るだけ)。application.jsに以下を記載。

window.MYAPP = window.MYAPP || {};

クラス名規則

上記の名前空間の下にクラスとなるオブジェクトを作っていくのだが、その際のクラス名は以下のようにした。全て大文字ではじめる。CollectionはModelの複数形。ViewはModel/Collectionと1対1であれば頭にModel名をつける(そのような関係にならない場合も多いので、その場合は自由に命名する)。

// Model
MYAPP.User = Backbone.Model.extend({ /* ... */ });

// Collection
MYAPP.Users = Backbone.Collection.extend({ /* ... */ });

// View
MYAPP.UserListView = Backbone.View.extend({ /* ... */ });

アプリケーション全体での共通前処理

アプリケーション全体で必ず実行したい共通の前処理が存在したので、Backboneオブジェクト自体のイベントを利用した。application.jsに共通前処理を書き、それが終わったらBackbone.triggerでカスタムイベント(以下の例ではinit)を発火する。各ページのjavascriptではこのイベントを購読しておくという形。

application.js

$(function() {
  // 共通前処理
  // ...
  Backbone.trigger('init');
});

各ページ

Backbone.on('init', function() {
  // 各ページの処理
});

サーバーサイドで生成するjsonのハンドリング

サーバーサイドで生成するjsonのハンドリングは、基本的にはas_jsonメソッドを用いた。使い方はこちらのブログが詳しい

http://d.hatena.ne.jp/gutskun/20130409/1365518684

もっと細かいハンドリングをしたい場合にはgemを使った方がよいと思う。Rails4からはデフォルトで組み込まれているJbuilderかRABLあたりが有名

http://railscasts.com/episodes/320-jbuilder?language=ja&view=asciicast
http://railscasts.com/episodes/322-rabl?language=ja&view=asciicast

初期データ投入

大きく2つの方法があるように思う。1つはデータが空のオブジェクトを作ってからサーバーにfetchするもの。もう1つはhtmlレンダリング時にデータも入れてしまうもの。

1つ目の方法
<div id="user-list-container"></div>

<%= javascript_tag do %>
  Backbone.on('init', function() {
    var users = new MYAPP.Users();
    var userListView = new MYAPP.UserListView({collection: users});
    $('#user-list-container').html(userListView.render().el);
    users.fetch({reset: true});
  });
<% end %>

この場合の流れは
・データが空の状態でViewレンダリング
・ model/collectionがサーバーへデータをfetch
・ model/collectionのresetイベント発火
・ viewがresetイベントキャッチして、再度レンダリング

2つ目の方法
<div id="user-list-container"></div>

<%= javascript_tag do %>
  Backbone.on('init', function() {
    var users = new MYAPP.Users(JSON.parse('<%= j @users.to_json.html_safe %>'));
    var userListView = new MYAPP.UserListView({collection: users});
    $('#user-list-container').html(userListView.render().el);
  });
<% end %>

この場合の流れは
・モデルのjsonデータをサーバー側でjsコードとして出力
・データがある状態でViewがレンダリング

2つ目の方法の方がレンダリングが速いので、特に理由がなければ2つ目の方法を使う。

クライアントサイドでレンダリングするか、サーバーサイドでレンダリングするか

前述コードは、backbone管理下の要素をクライアントサイドで生成するようなコードだった。けれど、そうするとその部分の要素に関してはSEOが死ぬ。この問題は別にbackboneに限った話ではなく、angularのような他のライブラリを使っていても、クライアントサイドでレンダリングする限り起こる問題。対策法はこちらのサイトなどが詳しい

http://info.appdirect.com/blog/solving-the-javascript-seo-conundrum-part-one
http://www.ng-newsletter.com/posts/serious-angular-seo.html

けれど、今回はそこまで大掛かりなことをせずに局所的に対策すればよい程度だったので、SEO対策な必要なページはサーバーサイドでレンダリングし、クライアントサイドではサーバーサイドでレンダリングされたhtmlに対してbackbone.jsのオブジェクトをアタッチするようにした。参考にしたのはこちらのstackoverflowのポスト

http://stackoverflow.com/questions/7549306/single-page-js-websites-and-seo

コードはこんな感じ。

<div id="user-list-container">
  <% @users.each do |user| %>
    <div class="user-detail" data-id="<%= user.id %>">
      <span>名前:</span><span><%= user.name %></span>
      <span>メール:</span><span><%= user.mail %></span>
    </div>
  <% end %>
</div>

<%= javascript_tag do %>
  Backbone.on('init', function() {
    var users = new MYAPP.Users(JSON.parse('<%= j @users.to_json.html_safe %>'));
    var userListView = new MYAPP.UserListView({el: $('#user-list-container'), collection: users});
    userListView.$('div.user-detail').each(function(i, el) {
      var el = $(el);
      var id = el.attr('data-id');
      var user = users.get(id);
      new MYAPP.UserDetailView({el: el, model: user});
    });
  });
<% end %>

このアプローチだと、テンプレートをクライアント側でも使いたい場合にコードの重複が発生するが、今回はそのような箇所がそれほど多くなかったので、よしとした。

参考になったソース

https://github.com/documentcloud/documentcloud
Backbone.jsの本家本元、documentcloud。そのソースコードGithubで公開されてる。サーバーサイドはRails。ちょっとバージョンが古いので、書き方もレガシーなメソッドを使っていたりするけど、Todoアプリみたいなサンプルとは違って実運用されている大きなアプリケーションなので多いに参考になった。

https://github.com/samuelclay/NewsBlur
SinglePageApplicationのRSSリーダーNewsBlur。こちらもdocumentcloudのメンバーが作っているっぽい。サーバーサイドはDjango。 media/js/newsblur/配下にbackboneを使ったjsコードが沢山ある。

終わり

いわゆるSinglePageApplicationといったような、ほとんど画面遷移をしないアプリケーションにする場合、またちょっとポイントが変わってくるとは思います(Routerの役割が大きくなるので)。けれどそこまでいかないアプリケーションであれば、上記のような構成+ルールで破綻せずにいけそうな感じでしたー