ちょっと話題の記事

Backbone.jsでつくるMVPなUIパターン【リスト】

2013.03.04

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

今回はBackbone.jsを使ってフォームから追加し、削除できるリストを作ってみたいと思います。デザインは前回同様Twitter Bootstrapで作成しました。

スクリーンショット 2013-03-01 17.05.45

htmlはこちら!

<div id="ui-list">
  <div class="list-controls">
    <form>
      <div class="input-append">
        <input type="text" placeholder="Please type something" />
        <input type="submit" class="btn" value="Add" />
      </div>
    </form>
  </div>
  <div class="list-items">
    <ul>
      <li class="list-item" data-cid="c1">
        <a href="#" class="close">×</a>
        <div class="text">hoge</div>
      </li>
      <li class="list-item" data-cid="c2">
        <a href="#" class="close">×</a>
        <div class="text">foo</div>
      </li>
    </ul>
  </div>
</div>

仕様

  • ページの表示時に初期アイテムがリストに表示される
  • フォームから文字列を入力しsubmitするとリストにアイテムが追加できる
  • 入力値のバリデーションができる(英数字のみ許可する)
  • 追加されたアイテムを削除できる

今回はモデルを使ってバリデーションも行ってみるので以下のクラスを使ってみようと思います。

  • Backbone.Events
  • Backbone.Model
  • Backbone.Collection
  • Backbone.View
  • Backbone.Router

実装のイメージは以下のようになります。

スクリーンショット 2013-03-01 18.59.00

まず ListRouter がページの表示時に ListApp を初期化します。ListApp内では ItemCollectionItemListView を所有していて、各コンポーネントの単体には直接触らずに集合に対して指示を出すようにします。またItemCollectionとItemListViewはお互いにイベントを通して状態を通知出来るようにします。

Item

まずは核となるデータを管理するモデルから実装してきましょう。
モデルは Backbone.Model を継承して作成します。

var Item = Backbone.Model.extend({});

次にこのモデルのデータをサーバへ送信する際のパスを指定しておきます。今回はサーバサイドの実装は含まないのでこのプロパティも必要ないのですが、定義していないと後に実装するバリデーションの実行の際にエラーになってしまいますので仮のパスを入れておきます。

var Item = Backbone.Model.extend({
  url: '/items'
});

今回は英数字以外の文字列を受け付けないようにしたいので validate 関数も実装しておきます。こうすることで save 関数を実行した際のXHR送信の前段で実行され、文字列がreturnされるとバリデーションの失敗とみなされてXHRが行われないようになります。

  validate: function(attr, options) {
    if (attr.text.match(/^[a-zA-Z0-9]+$/)) {
      return 'Only alphanumeric characters';
    }
  }

以上でItemモデルの実装は完了です。まとめたコードが以下になります。

var Item = Backbone.Model.extend({
  url: '/items',

  validate: function(attr, options) {
    if (attr.text.match(/^[a-zA-Z0-9]+$/)) {
      return 'Only alphanumeric characters';
    }
  }
});

ItemCollection

Itemモデルの集合を担当するクラスは Backbone.Collection を継承して実装します。継承方法はお馴染みの形です。
またあつかうモデルのクラスも指定しておきます。

var ItemCollection = Backbone.Collection.extend({
  model: Item
});

Itemモデルの操作はこのクラスの担当ですので、モデルの追加と削除も出来るようにしておきます。
以下は追加用の関数です。

  createItem: function(data, invalidHandler) {
    var item = new Item(data);
    this.listenTo(item, 'invalid', invalidHandler);
    if (item.save()) {
      this.add(item);
    }
  }

モデルの保存時にバリデーションに失敗した際の処理をする関数も受け取るようにしておきます。この場合の振る舞いはListAppで管理できるようにするためです。
さらに item.save() の戻り値が false でない場合のみItemCollecitonのメンバーに追加するようにしました。
そして以下がモデル削除用のメソッドです。

  removeItem: function(model) {
    this.remove(model);
  }

サーバサイドと連携しないので単純にメンバーからの削除を実行するようにしています。この関数の実行によりItemCollectionの remove イベントが発火されます。

ItemView

ItemViewはリスト単体の描画や削除ボタンのクリックイベントを担当します。まずは Backbone.View クラスを継承してテンプレート関数を用意しておきましょう

var ItemView = Backbone.View.extend({
  itemTemplate: _.template($('#ui-list-item-template')),
});

テンプレートの内容はこちらです。仮組みしたhtmlからは該当のリスト部分を削除しておきます。

  <script type="text/template" id="ui-list-item-template">
    <li class="list-item" data-cid="<%= cid %>">
      <a href="#" class="close">×</a>
      <div class="text">
      	<%= text %>
      </div>
    </li>
  </script>

テンプレート化したリスト部分を削除

  ...
  <div class="list-items">
    <ul></ul>
  </div>
  ...

次にインスタンス作成時に実行される初期化関数の中で、リストを格納するコンテナ要素を取得しておきます。

  initialize: function(options) {
    this.$el = $('#ui-list').find('.list-items > ul');
  }

Backbone.Viewを継承したクラスでは this.$el という変数にjQuery化したコンテナ要素を入れておくとクリックイベントなどを補足するためのラッパーとして自動で認識されるようになります。
イベントの補足は以下のように events プロパティに 'イベント名 セレクタ': 'イベントハンドラ名' という形式で定義します。

  events: {
    'click .close': 'clickCloseBtnHandler'
  },

closeボタンのイベントハンドラはクリックされた要素が自分自身と一致しているかを確認し、ItemViewから delete イベントを発火して ItemListView へ通知出来るようにしておきます。

  clickCloseBtnHandler: function(e) {
    e.preventDefault();
    if (this.$item.data('cid') === $(e.target).parents('.list-item').data('cid')) {
      this.trigger('delete', this.model);
    }
  },

さらに実際にDOMを削除する処理も実装しておきます。

  destroy: function() {
    this.$item.remove();
  }

以上でItemViewの実装は完了です。全てをまとめたコードは以下になります。

var ItemView = Backbone.View.extend({
  itemTemplate: _.template($('#ui-list-item-template')),

  events: {
    'click .close': 'clickCloseBtnHandler'
  },

  initialize: function(options) {
    this.$el = $('#ui-list').find('.list-items > ul');
  },

  render: function() {
    this.$item = $(this.itemTemplate(_.extend({ cid: this.model.cid }, this.model.attributes)));
    this.$el.append(this.$item);
  },

  clickCloseBtnHandler: function(e) {
    e.preventDefault();
    if (this.$item.data('cid') === $(e.target).parents('.list-item').data('cid')) {
      this.trigger('delete', this.model);
    }
  },

  destroy: function() {
    this.$item.remove();
  }
});

ItemListView

ItemListViewはItemViewの集合を扱うクラスですBackbone.Viewを継承し、各単体クラスを格納する配列を用意しておきます。

var ItemListView = Backbone.View.extend({
  itemViews: []
});

初期化関数の中でListAppから受け取るcollectionの addremoveイベントに自身の対応する関数を登録しておきます。

  initialize: function(options) {
    this.listenTo(this.collection, 'add', this.addItemView);
    this.listenTo(this.collection, 'remove', this.removeItemView);
  }

itemViewの追加処理はこちらです。

  addItemView: function(model) {
    var itemView = new ItemView({ model: model });
    itemView.render();
    this.listenTo(itemView, 'delete', _.bind(this.collection.removeItem, this.collection));
    this.ItemViews.push(itemView);
  },

ItemCollecitonのaddイベントに登録した関数にはモデルが渡されて実行されるので受け取ったモデルを元に新しくItemViewを作成して描画の実行と、削除イベントが発火された際のItemCollection側のイベントハンドラを登録しておきます。この際にUnderscore.jsの _.bind() を使うことでイベントハンドラ実行時の this がItemCollectionになるようにしておきます。最後に後から単体を操作できるように自身の配列に格納しています。

itemViewの削除処理はItemCollecitonからの通知を受け、モデルを渡されますので対応するItemViewを配列から探し、destroy関数を呼び出すことでDOMからの削除が出来るようにしています。

  removeItemView: function(model) {
    _.each(this.itemViews, function(itemView) {
      if (itemView.model.cid === model.cid) {
        itemView.destroy();
      }
    });
  },

以上でItemListViewの実装は完了です。全てをまとめたコードは以下になります。

var ItemListView = Backbone.View.extend({
  itemViews: [],

  initialize: function(options) {
    this.listenTo(this.collection, 'add', this.addItemView);
    this.listenTo(this.collection, 'remove', this.removeItemView);
  },

  addItemView: function(model) {
    var itemView = new ItemView({ model: model });
    itemView.render();
    this.listenTo(itemView, 'delete', _.bind(this.collection.removeItem, this.collection));
    this.ItemViews.push(itemView);
  },

  removeItemView: function(model) {
    _.each(this.itemViews, function(itemView) {
      if (itemView.model.cid === model.cid) {
        itemView.destroy();
      }
    });
  }
});

ItemFormView

ItemFormViewはフォーム操作の制御を担当します。こちらもBackbone.Viewを継承してフォームの submit イベントを捕捉出来るようにしておきます。

var ItemFormView = Backbone.View.extend({
  events: {
    'submit form': 'submitFormHandler'
  }
});

初期化メソッド内でフォームのラッパーと、テキスト入力のinput要素を取得しておきます。

  initialize: function(options) {
    this.$el = $('#ul-list').find('.list-controls');
    this.$input = this.$el.find('input[type="text"]');
  }

フォームのsubmitイベントのハンドラではページ遷移が起きないように抑制し、文字列が入力されている場合のみ submit_form イベントを発火するようにしました。続けて入力出来るようにinputのテキストを空にしておきます。

  submitFormHandler: function(e) {
    e.preventDefault();
    var text = this.$input.val();
    if (typeof text === 'string' && text.length) {
      this.trigger('submit_form', { text: text });
      this.$input.val('');
    }
  }

以上でItemFormViewの実装は完了です。全てをまとめたコードは以下になります。

var ItemFormView = Backbone.View.extend({
  events: {
    'submit form': 'submitFormHandler'
  },

  initialize: function(options) {
    this.$el = $('#ul-list').find('.list-controls');
    this.$input = this.$el.find('input[type="text"]');
  },

  submitFormHandler: function(e) {
    e.preventDefault();
    var text = this.$input.val();
    if (typeof text === 'string' && text.length) {
      this.trigger('submit_form', { text: text });
      this.$input.val('');
    }
  }
});

ListApp

ここまで来ればあと一息です。アプリケーションの振る舞いを実装するListAppは Backbone.Events をUnderscore.jsの _.extend 関数で継承して作成します。これはBackbone.Eventsがクラスではなくイベント系の機能を提供するモジュールとして定義されているためです。

var ListApp = _.extend({}, Backbone.Events);

初期化関数の中ではitemFormView、ItemCollection、ItemListViewをそれぞれインスタンス化し、ItemFormViewのsubmit_formイベントを捕捉出来るようにしておきます。

  initialize: function(options) {
    this.form = new ItemFormView;
    this.itemCollection = new ItemCollection;
    this.itemListView = new ItemListView({ collection: this.itemCollection });
    this.listenTo(this.form, 'submit_form', this.createItem);
  }

またページ表示時に初期データが渡された場合にリストを描画出来る関数も実装しておきます。URLのハッシュ値が変更されるたびに描画されてほしくないのでフラグをチェックして1回のみ実行されるようにしました。

  wasRendered: false,

  renderAll: function() {
    var self = this;
    if (!self.wasRendered) {
      self.wasRendered = true;
      _.each(this.options.data, function(_data) {
        self.createItem(_data);
      });
    }
  }

実際にリストを作成する処理は先に実装したItemCollectionの関数にデータとバリデーションを通らなかった場合の処理を渡しておきます。

  createItem: function(data) {
    this.ItemCollection.createItem(data, this.itemInvalidHandler);
  }

バリデーションに失敗した際はアラートを出すようにしておきました。

  itemInvalidHandler: function(item) {
    alert(item.validationError);
  }

以上でListAppの実装は完了です。全てをまとめたコードは以下になります。

var ListApp = _.extend({
  initialize: function(options) {
    this.form = new ItemFormView;
    this.itemCollection = new ItemCollection;
    this.itemListView = new ItemListView({ collection: this.itemCollection });
    this.listenTo(this.form, 'submit_form', this.createItem);
  },

  wasRendered: false,

  renderAll: function() {
    var self = this;
    if (!self.wasRendered) {
      self.wasRendered = true;
      _.each(this.options.data, function(_data) {
        self.createItem(_data);
      });
    }
  },

  createItem: function(data) {
    this.ItemCollection.createItem(data, this.itemInvalidHandler);
  },

  itemInvalidHandler: function(item) {
    alert(item.validationError);
  }
}, Backbone.Events);

ListRouter

今回Routerの役目はページの表示時にListAppの初期化と描画をするだけです。

var ListRouter = Backbone.Router.extend({
  initialize: function(options) {
    this.app = ListApp.iniitalize(options);
  },

  routes: {
    '*action': 'defaultAction'
  },

  defaultAction: function() {
    this.app.renderAll();
  }
});

アプリケーションをスタートさせる

以上ですべてのコンポーネントが用意出来ましたので最後にListRouterへ初期データを投入し初期化して、ページの表示時に描画されるようにしておきましょう。

<script type="text/javascript">
  jQuery(function() {
    var options = { data: [{ text: 'hoge' }, { text: 'foo' }] };

    new ListRouter(options);
    Backbone.history.start();
  });
</script>

全体をjQueryに渡す匿名関数で囲ったのでDOMの構築後に実行されます。そしてListRouterをnewしていますが、特にインスタンスを受け取る必要はありません。最後に Backbone.history.start を実行して hashchange イベントに反応できるようにしておきます。

デモ

そして完成したものがこちらです!