Backbone使ってみるメモ。まだあんまりよくわかってないんだなー。

よくわからないならコードを読めばいいじゃない、という事で、公式で提供されているTodoアプリのコードを読んでみました。

ソース

公式のサンプル。

いわゆるTodo管理ツールなんだけど、情報をHTML5のLocalStorageに持つようになっている。つまりブラウザを閉じても内容を記憶している。

JSファイル

HTMLから読み込んでいるのは以下。

  • json2.js
  • jquery-1.7.1.js
  • underscore-1.3.1.js
  • backbone.js
  • backbone-localstorage.js
  • todos.js

Backbone.jsはUnderscore.jsとjQueryないしZepto.jsが必須。またLocalStorageを使うためのBackbone拡張を読み込んでいる。

json2.jsは何に使っているのかわからないけど、たぶんLocalStorageのI/OでJSONのパースをしてる、とかかな。まああまり本編とは関係ない。

で、todo.jsがこのアプリの本体。

全体を見渡す

まずは詳細な実装を省略して、外側全体を見てみる。

  • todos.js
// An example Backbone application contributed by
// [Jテゥrテエme Gravel-Niquet](http://jgn.me/). This demo uses a simple
// [LocalStorage adapter](backbone-localstorage.js)
// to persist Backbone models within your browser.

// Load the application once the DOM is ready, using `jQuery.ready`:
$(function(){

  // Todo Model
  // ----------

  // Our basic **Todo** model has `title`, `order`, and `done` attributes.
  var Todo = Backbone.Model.extend({
...
  });

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

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

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

  // Todo Item View
  // --------------

  // The DOM element for a todo item...
  var TodoView = Backbone.View.extend({
...
  });

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

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

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

});

というわけで、こんなものを作っている事がわかる。

  • Todo … Todoのモデル。
    • 属性として以下を持つ: title, order, done
  • TodoList … Todoのコレクション。複数のモデルを格納し、まとめて扱う。
  • TodosTodoListのインスタンス。コンストラクタじゃない。
  • TodoView … Todoのビュー。
  • AppView … アプリケーション自体のビュー。
  • AppAppViewのインスタンス。これもコンストラクタじゃない。

なんでインスタンスを格納する変数Todos, Appが大文字で開始しているのかはよくわからない。あまり一般的な命名規則ではないと思う。

あとここを見ただけでは、どこでユーザーイベントを拾っているのかとかがまだわからない。まあ、慌てずに順に見て行きましょう。

Todoモデル

29行、メソッドが四つ。これくらいなら読めそう。

  • default()
  • initialize()
  • toggle()
  • clear()

ここで実装してるメソッドは四つだけど、さらにそこから別のメソッドを呼んだりしている。モデルのメソッドはBackbone.jsが提供するもので、それぞれドキュメントに掲載されてた。ふむふむ。

default()

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

初期値を返してるっぽい。Todos.nextOrder()は後ろの方(70行目)で実装してる。特に問題なさそう。

initialize()

    // Ensure that each todo created has `title`.
    initialize: function() {
      if (!this.get("title")) {
        this.set({"title": this.defaults.title});
      }
    },

初期処理。Todoのタイトルがなければ初期値を設定、は良いのだけれど、this.defaults.titleが不思議なコード。this.defaults().titleではないのか? 内部的に何かやってくれてるんだろうか。(たぶんtypoだと思ってる。)

get(), set()はモデルに格納されている情報を操作するアクセサみたい。情報はモデルのフィールドattributesから直接操作する事も出来るみたいだけど、もちろんこれらのメソッドを経由して操作するのが「礼儀正しい」んだろう。

toggle()

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

チェックをオン、オフする的な。外部から呼ばれそう。

save()を呼んで状態変更を保存している。保存先の指定はないのかな。LosalStorageを使うライブラリー? (backbone-localstorage.js) を別途読み込んでいるんだけど、そっちの方で処理を上書きしたりしてくれてるんだろうか。

clear()

    // Remove this Todo from *localStorage* and delete its view.
    clear: function() {
      this.destroy();
    }

サヨナラの術。これも外部から呼ばれるのかな。

destroy()save()してある情報を削除するもの。サーバー(今回はLocalStorage)から情報を削除。

Todoコレクション

31行、二つのフィールドと四つのメソッド。

  • model
  • localStorage
  • done()
  • remaining()
  • nextOrder()
  • comparator()

内部で呼んでるfilter(), without(), last() はUnderscore.jsの方のメソッドらしい。(via Underscore Methods (28) )

model

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

対象となるモデル。さっき作ったやつを指定。

localStorage

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

Store は一緒に読み込んでる backbone-localstorage.js で定義されていた。

あ、そうか保存先はモデルじゃなくてコレクションの方で指定するのか。へえ。

そういうもの?

done()

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

チェックがオンのモデルだけ抽出してくれるっぽい。

remaining()

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

done()の逆版ぽい。なんか実行コスト高そう。

nextOrder()

    // 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;
    },

新しく作るTodoの位置を返す。(位置というか、何というか。)

ちなみにモデルに位置を記憶させるのは、ストレージに保存した際は順序情報がないため、みたいなコメントが付いてる。

comparator()

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

ソートに用いる値を返すっぽい。例えば名前順にするなら、ここでtodo.get('title')てなもんにすりゃ良いんだろう。あとは日付ならUNIX時間に変換してから返すとか。いやDateオブジェクトのままでいいか。

Todoビュー

63行、結構長い。メソッドが七つ、フィールドが三つ。

  • tagName
  • template
  • events
  • initialize()
  • render()
  • toggleDone()
  • edit()
  • close()
  • updateOnEnter()
  • clear()

tagName

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

はいはいタグの名前ですねーってのは良いんだけど、どこで使ってるのかわからない。内部的に使っている?

調べたら、やっぱり内部的に使ってるみたい。後述のrender()参照。

template

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

Underscore.jsが提供するテンプレート。これはrender()で使ってる。

events

    // The DOM events specific to an item.
    events: {
      "click .toggle"   : "toggleDone",
      "dblclick .view"  : "edit",
      "click a.destroy" : "clear",
      "keypress .edit"  : "updateOnEnter",
      "blur .edit"      : "close"
    },

なんか"eventType selector": "methodName"って感じでメソッド呼んでくれそう。明示的に利用している箇所はない。うーん、Backbone.Eventsの仕組みなのかなあ。

と思ったら、あった。

> If an events hash is not passed directly, uses this.events as the source. Events are written in the format {"event selector": "callback"}. The callback may be either the name of a method on the view, or a direct function body.

意訳する。

> 引数でイベント情報が与えられない場合はthis.eventsを利用します。イベント情報の書式は{"event selector": "callback"}です。コールバックはビューのメソッド名か、直接関数を指定する事もできます。

自動的に呼ばれてるのかな。

initialize()

    // The TodoView listens for changes to its model, re-rendering. Since there's
    // a one-to-one correspondence between a **Todo** and a **TodoView** in this
    // app, we set a direct reference on the model for convenience.
    initialize: function() {
      this.model.bind('change', this.render, this);
      this.model.bind('destroy', this.remove, this);
    },

お、コメントが長めだ。意訳してみる。

> TodoViewはこのモデルの変更を監視し、再描画します。このアプリではTodoとTodoViewは一対一のやり取りなので、利便性のためにモデルを直接参照しています。

んー、あんまりよく分かんない。this.modelの事か。それともメッセージ送信の方かなあ。

model.bind()はドキュメントに掲載されていない。Underscore.jsのbind()だろうか。でも引数が違うなあ。まあやってる事は何となく想像がつくけれど。

"change"とか"destroy"とかはイベントの種類だろう。ドキュメントに一覧が掲載されていた。これだろな。

> Here’s a list of all of the built-in events that Backbone.js can fire. You’re also free to trigger your own events on Models and Views as you see fit. > > * "add" (model, collection) — when a model is added to a collection. > * "remove" (model, collection) — when a model is removed from a collection. > * "reset" (collection) — when the collection’s entire contents have been replaced. > * "change" (model, options) — when a model’s attributes have changed. > * "change:[attribute]" (model, value, options) — when a specific attribute has been updated. > * "destroy" (model, collection) — when a model is destroyed. > * "sync" (model, collection) — triggers whenever a model has been successfully synced to the server. > * "error" (model, collection) — when a model’s validation fails, or a save call fails on the server. > * "route:[name]" (router) — when one of a router’s routes has matched. > * "all" — this special event fires for any triggered event, passing the event name as the first argument.

ふむふむ。

つまり結局のところ、モデルに変更があればビューを再描画したりするわけだ。イベントを経由してやり取りする事で疎結合になって幸せ、という話ですな。

render()

    // Re-render the titles of the todo item.
    render: function() {
      this.$el.html(this.template(this.model.toJSON()));
      this.$el.toggleClass('done', this.model.get('done'));
      this.input = this.$('.edit');
      return this;
    },

this.$elは自動で作られるみたい。どうやって、と思ったら、ここで先のtagNameが使われてるみたい。つまりさっきliを指定していたから、ここでは既に<li />が作成され、this.$elに保存されている。試しにTodoView.prototype.tagName='span'したら、<span />が作成されるようになった。正確に言うとノードのオブジェクト自体ではなくて、それを持ったjQueryオブジェクト。

で、その要素の内容を事前に用意したtemplateを利用して、再構築と。またモデルからチェック状態を得て反映させる。

$()$el.find(selector)と同じ。ショートカットとして編集用の要素(このタイミングで作るのか)をフィールドに保存している。接頭辞付けて$inputにした方が良いのではないかい。

return thisはよくわからないけど、他でも同じ事をやっているのを見た。使い方としては、view.render().$elみたいな感じで$elを更新しつつ利用するみたい。更新がなければ直接view.$elとすればいいし。return this.$elにしないのは、先の二者間の統一感のためだろうか。

toggleDone()

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

おお、モデルで用意したメソッドを呼んでいる。ビューからモデルにメッセージ送信(メソッド実行)、なんかMVCぽい。

edit()

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

編集モードに。

呼び出しはevents"dblclick .view" : "edit"と設定している。$el内でHTMLクラスviewを持つ要素がダブルクリックされると、このメソッドが実行される、と。

なるほど。イベント書くの、簡単でいいねえ。

close()

    // Close the `"editing"` mode, saving changes to the todo.
    close: function() {
      var value = this.input.val();
      if (!value) this.clear();
      this.model.save({title: value});
      this.$el.removeClass("editing");
    },

編集モードを終了して、内容を確定し表示を戻す。

updateOnEnter()

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

keypressイベントで実行されるもの。キーコードを確認して13 (Enter)なら編集終了。

clear()

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

モデルへ通知して終わり。

モデルのclear()は前述の通り、destroy()するのみ。ただしビューのinitialize()でこんなコードがあった。

      this.model.bind('destroy', this.remove, this);

よって、ビューのremove()が呼ばれる。

つまりこういう風に、玉突き?式に処理が進むわけだ。

  1. view.clear()
  2. mode.clear()
  3. mode.destroy()
  4. view.remove()

ビューのremove()$(view.el).remove();に同じ。

結局、DBからも画面からも消える。うーん、疎結合はこういうところが分かり辛い。まあ仕方ないと思うけれど。

アプリビュー

これで最後。86行にメソッドが七つとフィールドが三つ。

いよいよ大詰めです。

el

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

Backbone.jsのビューにはel$elの二種類がある。さっきTodoビューの方では自動生成される$elを使っていたけれど、ここではel

うーん、よくわからない。たぶんel$elの関係は$el == $(el)だと思うんだけど。

というか、Todoで使っていたのはthis.$elであってextend()に与えるものじゃない。関係ないか。じゃあここのelは何なんだろう。セレクター or オブジェクト or 配列(先頭を採用)てな感じかな。

アドバイスに従いBackbone.jsの方を見てみる。ちょろちょろ探したらこんなの出てきた:

まずはビューのコンストラクタ。_ensureElement()というのを実行している。

  • backbone.js #1141 (v0.9.2)
  // Creating a Backbone.View creates its initial element outside of the DOM,
  // if an existing element is not provided...
  var View = Backbone.View = function(options) {
    this.cid = _.uniqueId('view');
    this._configure(options || {});
    this._ensureElement();
    this.initialize.apply(this, arguments);
    this.delegateEvents();
  };

その中ではthis.elを確認して、空ならthis.tagName等から要素を生成して、setElement()している。あるいはthis.elが(それが何であれ)truthyなら、それでsetElement()

  • backbone.js #1262 (v0.9.2)
    // Ensure that the View has a DOM element to render into.
    // If `this.el` is a string, pass it through `$()`, take the first
    // matching element, and re-assign it to `el`. Otherwise, create
    // an element from the `id`, `className` and `tagName` properties.
    _ensureElement: function() {
      if (!this.el) {
        var attrs = getValue(this, 'attributes') || {};
        if (this.id) attrs.id = this.id;
        if (this.className) attrs['class'] = this.className;
        this.setElement(this.make(this.tagName, attrs), false);
      } else {
        this.setElement(this.el, false);
      }
    }

渡されたelementを確認。 $(jQueryないしZepto)のインスタンスなら、それをthis.$elとして記憶。そうでないなら、$()を通してから同様にする。$()に与えるのはセレクターでもDOMノードのオブジェクトでも良いので、皆が幸せになれるというわけだ。

  • backbone.js #1201 (v0.9.2)
    // Change the view's element (`this.el` property), including event
    // re-delegation.
    setElement: function(element, delegate) {
      if (this.$el) this.undelegateEvents();
      this.$el = (element instanceof $) ? element : $(element);
      this.el = this.$el[0];
      if (delegate !== false) this.delegateEvents();
      return this;
    },

なるほど。というわけで解決した。わーい。

statsTemplate

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

Todoビューと同じく_.template()

あちらはフィールド名がtemplateだったが、こちらはstatsTemplateになっているのは、何か意味があるんだろうか。

events

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

これもTodoビューと同じ。

initialize()

    // 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");
      this.allCheckbox = this.$("#toggle-all")[0];

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

      this.footer = this.$('footer');
      this.main = $('#main');

      Todos.fetch();
    },

これはちょいと長め。といっても大した事はなにもしていない。

まず冒頭のコメントを意訳する。

> 初期処理でTodoコレクションTodosに、項目が追加されたり変更されたりしたときのイベントを登録します。 > LocalStorageに保存されている既存のTodo項目の読み込んで、あれこれを開始します。

これで作成するオブジェクトを「アプリケーションの本体」と呼んで良いのだろう。

this.allCheckboxがjQueryオブジェクトではなく生のDOMノードにしているのは何か意味があるのだろうか。その方が早いってのはあるだろうけれど。

render()

    // Re-rendering the App just means refreshing the statistics -- the rest
    // of the app doesn't change.
    render: function() {
      var done = Todos.done().length;
      var remaining = Todos.remaining().length;

      if (Todos.length) {
        this.main.show();
        this.footer.show();
        this.footer.html(this.statsTemplate({done: done, remaining: remaining}));
      } else {
        this.main.hide();
        this.footer.hide();
      }

      this.allCheckbox.checked = !remaining;
    },

これもまた長め。Todoビューのrender()と同じく、要素の(再)描画を行う処理。

Todoに登録があれば一覧とフッターを表示し、そうでなければ非表示に。また全てチェック済みなら「全チェック」のチェックボックスもオンにします。

addOne()

    // 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});
      this.$("#todo-list").append(view.render().el);
    },

Todoリストのaddに実行されるよう、initialize()のとこで指定されてた処理。

Todoのビューを生成して、その要素をアプリ内の一覧に追加。要素の追加はいいけど、Todoビューの生成はここでやる事なのか? Todoリストの方でそこまでやっておいて欲しいような。いや、あちらはあくまでモデルのコレクションだから、ビューには触れないべきか。すると、やはりここでTodobビューを生成するのがよろしいか。

addAll()

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

Todoリストのresetに実行されるよう、initialize()のとこで指定されてた処理。

各モデルごとにビューを作成したりする。

createOnEnter()

    // If you hit return in the main input field, create new **Todo** model,
    // persisting it to *localStorage*.
    createOnEnter: function(e) {
      if (e.keyCode != 13) return;
      if (!this.input.val()) return;

      Todos.create({title: this.input.val()});
      this.input.val('');
    },

新規作成の入力欄でキー押下時に実行されるよう、eventsで指定されてた処理。

入力があるときにEnterキーが押されると、Todoをひとつ作成する。モデル作成と同時にコレクションに追加するので、new TodoではなくてTodos.create()なんだろう。

イベント経由でTodoビューも作成される。

clearCompleted()

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

チェック済みのTodoのモデルのclear()を呼ぶ。内部ではBackbone.Modeldestroy()が呼ばれ、そこからイベント経由でビューのremoveが実行される。

toggleAllComplete()

    toggleAllComplete: function () {
      var done = this.allCheckbox.checked;
      Todos.each(function (todo) { todo.save({'done': done}); });
    }

「全チェック」のオン・オフ時に実行されるよう、eventsで指定されてた処理。

おしまい

全体で250行足らずのコード。綺麗に分割されている事がよく分かった。DOMやイベントまわりの抽象化は結構好みだ。

使い方の方もなんとなく見えてきたけれど、LocalStorage以外を使うのにどうするのかとか、デフォルトだとたぶんサーバーと通信するんだけど、そこんとこどうなのってのがまだわからない。まあ、おいおい見て行きましょう。