Backbone入門 その4

おはようございます、south37です。
今日はBackbone入門最終回です。
最後は、Routerについて勉強したいと思います。例によって、この連載を参考にしています。

Routerとは

Backbone.jsのRouterは、クライアントサイドでルーティング機能を実現する為のオブジェクトです。通常、ブラウザで特定のURLにアクセスするとそのページを構成するhtmlやjs、cssがブラウザに送られ、ブラウザはそれらをレンダリングする事でWebページを表示します。動的なWebページであれば、サーバ側で様々な処理が施された後で、レンダリングされたテンプレートファイルがhtmlの形で送られる事になります。
サーバにおけるルーティングとは、アクセスされたURLとサーバ側の処理を対応づける事です。では、クライアントサイドで動くBackbone.jsのルーティングとはどういう事でしょうか?

実は、HTML5ではhistoryAPIという機能のおかげで、URLとJSのメソッドを関連づける事が出来ます。Backboneではこれを利用して、JSで動的にページを書き換える事で、サーバにアクセスしない画面遷移を実現する事が出来ます。これがクライアントサイドでのルーティングです。

では、実際にコードを見てみましょう。

//router.js
var app = {};
 
var AppRouter = Backbone.Router.extend({
  routes: {
    "": "index",
    "create": "create",
    "edit/:id": "edit"
  },
  initialize: function () {
    this.collection = new MemoList();
    this.headerView = new HeaderView({el:$("#header")}); 
    this.editView = new EditView({el:$("#editForm"), collection:this.collection}); 
    this.listView = new ListView({el:$("#memoList"), collection:this.collection});
  },
  index: function(){
    this.editView.hideView();
  },
  create: function () {
    this.editView.model = new Memo(null, {collection: this.collection});
    this.editView.render();
  },
  edit: function (id) {
    this.editView.model = this.collection.get(id);
    if (this.editView.model) {
      this.editView.render();
    }
  }
});
 
app.router = new AppRouter();

Backbone.history.start({pushState: true});

上記のroute.jsでは、ルーティング用のオブジェクトであるAppRouterを定義しています。routes属性の中で、URLとメソッド名の対応付けが行われています。"edit/:id"のようなパラメータを渡すURL指定も可能です。
各々のメソッドの中では、viewのレンダリングが行われています。これによって、URLに対してサーバーサイドで動的にページを書き換える事が出来る訳です。
initializeの中では、レンダリング用のviewオブジェクトの生成が行われています。indexではeditViewを消し、createでは逆にeditViewをrender()しています。また、editではidを受け取って、一度作られたメモの編集を実現しています。

上記の"", "create", "edit/:id"等はrouteイベントと呼ばれ、Backbone.Routerのnavigateメソッドを使う事で発火する事が出来ます。

var HeaderView = Backbone.View.extend({
  events:{
    "click #create": "onCreate"
  },
  initialize: function () {
    _.bindAll(this, "onCreate");
  },
  onCreate: function () {
    app.router.navigate("create", {trigger:true});
  }
});

上記の様に、app.router.navigateで第一引数に渡したイベントが発生します。ただし、このときはoptionsとして{trigger: true}を渡してやる必要があります。
"edit/:id"のような形のイベントを発行したい時は、単純に

app.router.navigate("edit/" + this.model.cid, {trigger: true});

といった形で文字列連結してやればOKです。

Backbone.Routerを使う事で、サーバとはバックグラウンドでのみ通信し、ページ遷移を極めて高速に行うアプリケーションを作る事が出来ます。

追記

例によって、手元で書いたコードを載せときます。連載のものとほとんど変わらないですが、サーバとの通信が不要になるように少し書き換えてあります。
model.js

var Memo = Backbone.Model.extend({
  defaults: {
    "title": "",
    "content": ""
  },
  validate:function (attributes) {
    if (attributes.content === "") {
      return "content must be not empty.";
    }
  }
});
                                         
var MemoList = Backbone.Collection.extend({
  model:Memo,
  url:"/memos"
});

view.js

var HeaderView = Backbone.View.extend({
  events:{
    "click #create": "onCreate"
  },
  initialize: function () {
    _.bindAll(this, "onCreate");
  },
  onCreate: function () {
    app.router.navigate("create", {trigger:true});
  }
});

var EditView = Backbone.View.extend({
  events: {
    "click #saveBtn": "onSave"
  },
  initialize: function () {
    _.bindAll(this, "render", "onSave", "hideView");

    this.$title   = $("#editForm [name='title']");
    this.$content = $("#editForm [name='content']");
  },
  render: function () {
    this.$title.val(this.model.get("title"));
    this.$content.val(this.model.get("content"));
    this.$el.show();
  },
  onSave: function () {
    this.model.set({
      title: this.$title.val(),
      content: this.$content.val()
    });
    this.collection.add(this.model, {merge: true});
    this.hideView();
  },
  hideView: function () {
    this.$el.hide();
    app.router.navigate("", {trigger: true});
  }
});

var ItemView = Backbone.View.extend({
  tmpl: _.template($("#tmpl-itemview").html()),
  events: {
    "click .edit": "onEdit",
    "click .delete": "onDelete"
  },
  initialize: function () {
    _.bindAll(this, "onEdit", "onDelete", "render");

    this.listenTo(this.model, "change", this.render);
  },
  onEdit: function () {
    app.router.navigate("edit/" + this.model.cid, {trigger: true});
  },
  onDelete: function () {
    this.model.destroy();
    this.remove();
  },
  render: function () {
    this.$el.html(this.tmpl(this.model.toJSON()));
    return this;
  }
});

var ListView = Backbone.View.extend({
  initialize: function () {
    _.bindAll(this, "render", "addItemView");

    this.listenTo(this.collection, "add", this.addItemView);

    this.render();
  },
  render: function () {
    this.collection.each(function (item) {
      this.addItemView(item);
    }, this);
  },
  addItemView: function (item) {
    this.$el.append((new ItemView({model: item})).render().el);
  }
});

router.js

var app = {};
 
var AppRouter = Backbone.Router.extend({
  routes: {
    "": "index",
    "create": "create",
    "edit/:id": "edit"
  },
  initialize: function () {
    _.bindAll(this, "index", "create", "edit");

    this.collection = new MemoList();
    this.headerView = new HeaderView({el:$("#header")}); 
    this.editView = new EditView({el:$("#editForm"), collection:this.collection}); 
    this.listView = new ListView({el:$("#memoList"), collection:this.collection});
  },
  index: function(){
    this.editView.hideView();
  },
  create: function () {
    this.editView.model = new Memo(null, {collection: this.collection});
    this.editView.render();
  },
  edit: function (id) {
    this.editView.model = this.collection.get(id);
    if (this.editView.model) {
      this.editView.render();
    }
  }
});
 
app.router = new AppRouter();

Backbone.history.start();

index.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Backbone Router Example</title>
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link href="lib/bower_components/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet">
  <style>
  body {
      padding-top: 60px; /* 60px to make the container go all the way to the bottom of the topbar */
  }
  </style>
</head>  
<body>
  <div id="header" class="navbar navbar-inverse navbar-fixed-top" role="navigation">
    <a href="./index.html" class="navbar-brand">Backbone Memo Application</a>
    <a id="create" class="btn btn-primary navbar-btn navbar-right">Create</a>
  </div>
   
  <div id="main" class="container">
    <h1>Memo</h1>
       
    <form id="editForm" role="form" class="form" style="display: none">
      <div class="form-group">
        <input type="title" name="title" class="form-control" placeholder="Title"/>
      </div>
      <div class="form-group">
        <textarea rows="5" name="content" class="form-control" placeholder="Content"></textarea>
      </div>
      <a href="#" id="saveBtn" class="btn btn-primary">Save</a>
      <a href="#" id="cancelBtn" class="btn btn-danger">Cancel</a>
    </form>
    <div id="memoList" class="row-fluid">
    </div>   
  </div>
   
  <script type="text/template" id="tmpl-itemview">
    <h3><%= title %></h3>
    <p><%= content %></p>
    <p><a class="edit">edit</a> <a class="delete">delete</a></p>
  </script>
   
  <script src="lib/bower_components/jquery/jquery.min.js"></script>
  <script src="lib/bower_components/bootstrap/dist/js/bootstrap.min.js"></script>
  <script src="lib/bower_components/json2/json2.js"></script>
  <script src="lib/bower_components/underscore/underscore-min.js"></script>
  <script src="lib/bower_components/backbone/backbone-min.js"></script>
  <script src="lib/js/model.js"></script>
  <script src="lib/js/view.js"></script>
  <script src="lib/js/router.js"></script>
</body>
</html>

ちなみに、router.jsの最後でBackbone.history.start()としており、{pushState: true}を渡していません。その為、historyAPIを使わず、hash fragment(#~というヤツ)を用いたURLで状態を表す形式になっています*1

*1:手元のPCでhistoryAPIを使った形式を試していると、何故か"insecure ~ "のようなエラーが出てしまったんですよね....。原因が分からんくて困っています