Backbone.jsのTodoアプリを読む。

クライアントサイドMVCフレームワークをもっと勉強したい。

今僕は#Favtileというサービスを作っています。
Twitterのお気に入り機能のためのツールです。
そこではクライアントサイド・サーバーサイド共にCoffeeScriptで書いており、Spine.jsを使っています。
Spine.jsを使いはじめた理由? CoffeeScriptが好きだからです。Spine.jsはCoffeeScriptで書かれていますから。

でも他にもJavaScriptMVCフレームワークはいっぱいあります。
有名だと思うのでBackbone.jsをやります。
有名だと情報が多いので無名のフレームワークよりも勉強しやすいという考えです。

Backbone.jsのサンプルのTodoアプリを読みながらドキュメントを引いて調べていきます。
http://documentcloud.github.com/backbone/examples/todos/index.html

Todo Model

Todoモデルはtext、order、doneの3つの属性を持つようです。
属性のデフォルト値がある場合はdefaultsに指定する。
toggleはTodoが完了した/してないを切り替えるメソッドですね。
textの属性持つとコメントには書いてるけどModelの中には「text」が見当たらない。そんなものか。

  // Our basic **Todo** model has `text`, `order`, and `done` attributes.
  window.Todo = Backbone.Model.extend({

    // Default attributes for a todo item.
    defaults: function() {
      return {
        done:  false,
        order: Todos.nextOrder()
      };
    },

    // Toggle the `done` state of this todo item.
    toggle: function() {
      this.save({done: !this.get("done")});
    }

Backbone.jsのドキュメントを読みながらもっと詳しく読む。

defaults

Modelをインスタンス化するときに属性の値が指定されてなかった時はデフォルト値としてdefaultsの内容が使われる。
ここでdefaultsに関数を指定するとその関数の返り値をデフォルト値として使う、と。
デフォルト値が毎回変わるような場合に関数を指定するといい感じなのかなぁ。

save

Modelのsaveメソッドは、内部でBackbone.syncを使っていて、Backbone.syncではRESTでサーバーにアクセスする。
LocalStorage adapterを使ってるとここが置き換わるかんじか。

saveメソッドに変更したい属性達を{}でまとめて渡すか、save("key", "value")のような感じで呼び出して使う、と。
validateメソッドを実装してvalidateとおらないと保存出来ないようにすることも出来るみたい。
isNewっていうので新規作成されたものか判定してるのかな? 新しく作成されるときはcreateがよばれ、更新の時はupdateが呼ばれるよう。
saveには{success:成功時の処理, error:失敗時の処理}のような感じで成功時・失敗時のコールバック関数を渡せるらしい。

Todo Collection

modelプロパティにCollectionが持ってるモデルのクラスを指定する、Collection.addとかcreateするときにmodelを使うのかなぁ。
Storeっていうのはローカルストレージに読み書きするためのクラスかな?
collectionの中からdoneなやつだけ取るのがdoneメソッドで、remainingはコレクションからdoneなやつを除いたもの、か。読みやすい。
nextOrderでTodoの順番を作ってる。
comparetorプロパティを定義するとcollectionを並べるときにその値が使われるようです。
今のだと追加した順に並ぶようになってますね。

  // Todo Collection
  // ---------------

  // The collection of todos is backed by *localStorage* instead of a remote
  // server.
  window.TodoList = Backbone.Collection.extend({

    // Reference to this collection's model.
    model: Todo,

    // Save all of the todo items under the `"todos"` namespace.
    localStorage: new Store("todos"),

    // Filter down the list of all todo items that are finished.
    done: function() {
      return this.filter(function(todo){ return todo.get('done'); });
    },

    // Filter down the list to only todo items that are still not finished.
    remaining: function() {
      return this.without.apply(this, this.done());
    },

    // We keep the Todos in sequential order, despite being saved by unordered
    // GUID in the database. This generates the next order number for new items.
    nextOrder: function() {
      if (!this.length) return 1;
      return this.last().get('order') + 1;
    },

    // Todos are sorted by their original insertion order.
    comparator: function(todo) {
      return todo.get('order');
    }

  });

  // Create our global collection of **Todos**.
  window.Todos = new TodoList;

TodoView

SpineでいうとControllerにあたる部分。
Backbone.Viewを継承してる。
はtagName、className、id、attributesを指定することで表示用のエレメントが作られ、elプロパティに保持されるようで。
_.template便利だなぁ。クライアントサイドのテンプレートっていっぱいあるのね…
eventsっていうプロパティに{"eventName selector":"methodName"}の形式で書くっぽい。
内部的にはjQuerydelegateが呼ばれてイベントが設定される。(設定するときには名前空間もくっつけてるみたい)
clickしたときはtoggleDoneがよばれて、その中でmodelのtoggleが呼ばれてdoneの値が更新されて、changeイベントが発行されてレンダリングし直されるのかな。
dbclickではeditが呼ばれて、その中で編集中の見た目になるようクラスを追加して、入力欄にフォーカス当ててるみたい。

ではフォーカスはずれた時はどうなるかというと
renderの中でsetTextをよんでいて、その中で、フォーカスが外れたとき(blur)はcloseを呼ぶように設定してる。
closeの中ではモデルの値を保存したあと、編集中の見た目を取り除いてる感じ。
enterが押された時にもcloseは呼ばれるみたいだ。なるほど。

initializeが初期化する時によばれるものかな?

  • モデルが変更されたらレンダリングしなおす
  • モデルが捨てられたら要素を取り除く

みたいな設定をしてるみたい。

  window.TodoView = Backbone.View.extend({

    //... is a list tag.
    tagName:  "li",

    // Cache the template function for a single item.
    template: _.template($('#item-template').html()),

    // The DOM events specific to an item.
    events: {
      "click .check"              : "toggleDone",
      "dblclick div.todo-text"    : "edit",
      "click span.todo-destroy"   : "clear",
      "keypress .todo-input"      : "updateOnEnter"
    },

    // The TodoView listens for changes to its model, re-rendering.
    initialize: function() {
      this.model.bind('change', this.render, this);
      this.model.bind('destroy', this.remove, this);
    },

    // Re-render the contents of the todo item.
    render: function() {
      $(this.el).html(this.template(this.model.toJSON()));
      this.setText();
      return this;
    },

    // To avoid XSS (not that it would be harmful in this particular app),
    // we use `jQuery.text` to set the contents of the todo item.
    setText: function() {
      var text = this.model.get('text');
      this.$('.todo-text').text(text);
      this.input = this.$('.todo-input');
      this.input.bind('blur', _.bind(this.close, this)).val(text);
    },

    // Toggle the `"done"` state of the model.
    toggleDone: function() {
      this.model.toggle();
    },

    // Switch this view into `"editing"` mode, displaying the input field.
    edit: function() {
      $(this.el).addClass("editing");
      this.input.focus();
    },

    // Close the `"editing"` mode, saving changes to the todo.
    close: function() {
      this.model.save({text: this.input.val()});
      $(this.el).removeClass("editing");
    },

    // If you hit `enter`, we're through editing the item.
    updateOnEnter: function(e) {
      if (e.keyCode == 13) this.close();
    },

    // Remove this view from the DOM.
    remove: function() {
      $(this.el).remove();
    },

    // Remove the item, destroy the model.
    clear: function() {
      this.model.destroy();
    }

  });

AppView

なるほど。既にある要素をelにバインドする事も出来る、と。
addとかresetとかそこらへんのイベントはFAQの中にCatalog of Eventsで載ってる。

  // The Application
  // ---------------

  // Our overall **AppView** is the top-level piece of UI.
  window.AppView = Backbone.View.extend({

    // Instead of generating a new element, bind to the existing skeleton of
    // the App already present in the HTML.
    el: $("#todoapp"),

    // Our template for the line of statistics at the bottom of the app.
    statsTemplate: _.template($('#stats-template').html()),

    // Delegated events for creating new items, and clearing completed ones.
    events: {
      "keypress #new-todo":  "createOnEnter",
      "keyup #new-todo":     "showTooltip",
      "click .todo-clear a": "clearCompleted"
    },

    // At initialization we bind to the relevant events on the `Todos`
    // collection, when items are added or changed. Kick things off by
    // loading any preexisting todos that might be saved in *localStorage*.
    initialize: function() {
      this.input    = this.$("#new-todo");

      Todos.bind('add',   this.addOne, this);
      Todos.bind('reset', this.addAll, this);
      Todos.bind('all',   this.render, this);

      Todos.fetch();
    },

    // Re-rendering the App just means refreshing the statistics -- the rest
    // of the app doesn't change.
    render: function() {
      this.$('#todo-stats').html(this.statsTemplate({
        total:      Todos.length,
        done:       Todos.done().length,
        remaining:  Todos.remaining().length
      }));
    },

    // Add a single todo item to the list by creating a view for it, and
    // appending its element to the `<ul>`.
    addOne: function(todo) {
      var view = new TodoView({model: todo});
      $("#todo-list").append(view.render().el);
    },

    // Add all items in the **Todos** collection at once.
    addAll: function() {
      Todos.each(this.addOne);
    },

    // If you hit return in the main input field, and there is text to save,
    // create new **Todo** model persisting it to *localStorage*.
    createOnEnter: function(e) {
      var text = this.input.val();
      if (!text || e.keyCode != 13) return;
      Todos.create({text: text});
      this.input.val('');
    },

    // Clear all done todo items, destroying their models.
    clearCompleted: function() {
      _.each(Todos.done(), function(todo){ todo.destroy(); });
      return false;
    },

    // Lazily show the tooltip that tells you to press `enter` to save
    // a new todo item, after one second.
    showTooltip: function(e) {
      var tooltip = this.$(".ui-tooltip-top");
      var val = this.input.val();
      tooltip.fadeOut();
      if (this.tooltipTimeout) clearTimeout(this.tooltipTimeout);
      if (val == '' || val == this.input.attr('placeholder')) return;
      var show = function(){ tooltip.show().fadeIn(); };
      this.tooltipTimeout = _.delay(show, 1000);
    }

  });

  // Finally, we kick things off by creating the **App**.
  window.App = new AppView;

});

まとめ

だらだらと書いた割に内容のない文章になってしまった。
実際に自分で何か作ってみる方が理解が進むかも。(あるいはBackbone.jsのソースを読むとか)