backbone-rails から Bower+Backbone.js に移行

はじめに

Rails で Backbone.js を使ってアプリを開発するときは、決まって backbone-rails を使っていた。

…んだけど、Backbone.js の v1.0.0 が出たというのに、backbone-rails は未だ対応してない(2013/07/02 現在)。

JavaScript ライブラリを gem でインストールすることに違和感あったし、 この際なんで backbone-rails にサヨナラすることに決めた。

これから JavaScript ライブラリは Bower で管理していく。 以下は移行メモ。

Bower をインストー

あらかじめ Node を homebrew や nodebrew や nvm を使ってインストールしておき、

npm install -g bower

を実行して Bower をグローバルにインストールする。 -g オプションは必須。

Bower を使って JavaScript ライブラリをインストー

Rails プロジェクトのルート(RAILS_ROOT) に .bowerrc と components.json を作成する。

まず .bowerrc。

{
  "directory": "vendor/assets/components",
  "json": "component.json"
}

RAILS_ROOT/vendor/assets/components 下にインストールするように設定している。

次に components.json

{
  "dependencies": {
    "jquery": "latest",
    "jquery-ujs": "latest",
    "underscore": "latest",
    "backbone": "latest"
  }
}

Backbone.js を Railscsrf-token や params に対応させる、rails-backbone の backbone_rails_sync.js が Backbone.js 1.0.0 に対応してなかった。

フォークして修正するより、Backbone.sync のソースコードをもとに実装し直したほうが早そうだったんで、 同じようなものを自作してみた。

(function($) {

var methodMap = {
  'create': 'POST',
  'update': 'PUT',
  'patch':  'PATCH',
  'delete': 'DELETE',
  'read':   'GET'
};

var getUrl = function(object) {
  if (!(object && object.url)) return null;
  return _.isFunction(object.url) ? object.url() : object.url;
};

// Throw an error when a URL is needed, and none is supplied.
var urlError = function() {
  throw new Error('A "url" property or function must be specified');
};

// Override this function to change the manner in which Backbone persists
// models to the server. You will be passed the type of request, and the
// model in question. By default, makes a RESTful Ajax request
// to the model's `url()`. Some possible customizations could be:
//
// * Use `setTimeout` to batch rapid-fire updates into a single request.
// * Send up the models as XML instead of JSON.
// * Persist models via WebSockets instead of Ajax.
//
// Turn on `Backbone.emulateHTTP` in order to send `PUT` and `DELETE` requests
// as `POST`, with a `_method` parameter containing the true HTTP method,
// as well as all requests with the body as `application/x-www-form-urlencoded`
// instead of `application/json` with the model in a param named `model`.
// Useful when interfacing with server-side languages like **PHP** that make
// it difficult to read the body of `PUT` requests.
Backbone.sync = function(method, model, options) {
  var type = methodMap[method];

  // Default options, unless specified.
  _.defaults(options || (options = {}), {
    emulateHTTP: Backbone.emulateHTTP,
    emulateJSON: Backbone.emulateJSON
  });

  // Default JSON-request options.
  var params = { type: type, dataType: 'json' };

  // Ensure that we have a URL.
  if (!options.url) {
    params.url = getUrl(model) || urlError();
  }

  // Ensure that we have the appropriate request data.
  if (options.data == null && model && (method === 'create' || method === 'update' || method === 'patch')) {
    params.contentType = 'application/json';

    var data = {};
    var attrs = options.attrs || model.toJSON(options);
    if (model.paramRoot) {
      data[model.paramRoot] = attrs;
    } else {
      data = attrs;
    }

    params.data = JSON.stringify(data);
  }

  // For older servers, emulate JSON by encoding the request into an HTML-form.
  if (options.emulateJSON) {
    params.contentType = 'application/x-www-form-urlencoded';
    params.data = params.data ? {model: params.data} : {};
  }

  // For older servers, emulate HTTP by mimicking the HTTP method with `_method`
  // And an `X-HTTP-Method-Override` header.
  if (options.emulateHTTP && (type === 'PUT' || type === 'DELETE' || type === 'PATCH')) {
    params.type = 'POST';
    if (options.emulateJSON) params.data._method = type;
    var beforeSend = options.beforeSend;
    options.beforeSend = function(xhr) {
      xhr.setRequestHeader('X-HTTP-Method-Override', type);
      if (beforeSend) return beforeSend.apply(this, arguments);
    };
  }

  // Don't process data on a non-GET request.
  if (params.type !== 'GET' && !options.emulateJSON) {
    params.processData = false;
  }

  // If we're sending a `PATCH` request, and we're in an old Internet Explorer
  // that still has ActiveX enabled by default, override jQuery to use that
  // for XHR instead. Remove this line when jQuery supports `PATCH` on IE8.
  if (params.type === 'PATCH' && noXhrPatch) {
    params.xhr = function() {
      return new ActiveXObject("Microsoft.XMLHTTP");
    };
  }

  // Set Rails csrf-token.
  options.beforeSend = _.wrap(options.beforeSend, function(func, xhr) {
    if (!options.noCSRF) {
      var token = $('meta[name="csrf-token"]').attr('content');
      if (token) {
        xhr.setRequestHeader('X-CSRF-Token', token);  
      }
    }
    if (func) {
      return func(this, xhr);
    }
  });

  // Make the request, allowing the user to override any Ajax options.
  var xhr = options.xhr = Backbone.ajax(_.extend(params, options));
  model.trigger('request', model, xhr, options);
  return xhr;
};

})(Backbone.$);

あとは

bower install

を実行すると、ライブラリが RAILS_ROOT/vendor/assets/components 下にインストールされる。

アセットパイプラインで読み込めるように設定する

RAILS_ROOT/config/application.rb を編集し、アセットのパスを追加する。

config.assets.paths << Rails.root.join("vendor", "assets", "components")

app/assets/javascripts/application.js を編集し、利用するライブラリを require する。

//= require jquery/jquery
//= require jquery-ujs/src/rails
//= require underscore/underscore
//= require backbone/backbone
//= require backbone_rails_sync/backbone_rails_sync
//= require_directory .
//= require ./backbone/main

RAILS_ROOT/app/assets/javascripts/backbone を読み込むのは、backbone-rails の慣習。 この下に Backbone.js を使ったコードを置いている。

これで Backbone.js 導入完了

あとは RAILS_ROOT/app/assets/javascripts/backbone ディレクトリの中に、Backbone.js のモデルやビューを実装していく。

まとめ

JavaScript ライブラリの管理を gem から Bower に移行した。 これでライブラリがバージョンアップしたとき素早く対応できる。 今後、新規に Rails アプリを作る場合も、JavaScript ライブラリは Bower で管理する予定。 こうなってくると、Grunt や Yeoman も導入したくなってきた。

シングルページアプリケーションでは Rails で Web API を実装し、 クライアント側は Backbone.js と Grunt や Bower や Yoeman 等を使って実装する、 って感じのクライアントとサーバーが疎結合な構成にしていきたい。