勉強会JS編＜8＞ yeoman + backbone.js 実践 - その１


## 関連記事

* [勉強会JS編＜1＞ オブジェクト指向言語としてのJavaScriptを理解](http://qiita.com/park-jh/items/1887d87f33474cf7d733)
* [勉強会JS編＜2＞ クライアントサイドMVCフレームワーク](http://qiita.com/park-jh/items/8b0a21e09223e3a8d0bc)
* [勉強会JS編＜3＞ フロントエンド開発環境構築](http://qiita.com/park-jh/items/319cacc28850c9fd4898)
* [勉強会JS編＜4＞ yeoman + backbone.model + grunt](http://qiita.com/park-jh/items/78bc01b93fe6d4167624)
* [勉強会JS編＜5＞ yeoman + backbone.collection + backbone.localStorage](http://qiita.com/park-jh/items/49fefe30cdf57de7b6f0)
* [勉強会JS編＜6＞ yeoman + backbone.view](http://qiita.com/park-jh/items/44fb4faac997c71201a0)
* [勉強会JS編＜7＞ yeoman + backbone.router](http://qiita.com/park-jh/items/8f7d4275279201d5796b)
* 勉強会JS編＜8＞ yeoman + backbone.js 実践 - その１
* [勉強会JS編＜9＞ yeoman + backbone.js 実践 - その２](http://qiita.com/park-jh/items/55d62bc58b94b12835c3)

## 雛形生成

* モデル
  - Practice.Models.Note
  - titleとbody属性を持っている。
  - データの永続化のため、backbone.localStorageを使う。
* コレクション
  - Practice.Collections.Note
  - モデルを格納する。
* ビュー
  - Practice.Views.Note：個々のメモ項目を表示する子ビュー（レコード）
  - Practice.Views.NoteList：メモ一覧のビュー（テーブル）
  - Practice.Views.Container：古いビューがカベージコレクションの対象になるように破壊する。

```sh
  [devnote@hooni:~/documents/study/js/practice] % yo backbone:model note
     create app/scripts/models/note.js
     create test/models/note.spec.js
  [devnote@hooni:~/documents/study/js/practice] % yo backbone:collection note
     create app/scripts/collections/note.js
     create test/collections/note.spec.js
  [devnote@hooni:~/documents/study/js/practice] % yo backbone:view note
     create app/scripts/templates/note.ejs
     create app/scripts/views/note.js
     create test/views/note.spec.js
  [devnote@hooni:~/documents/study/js/practice] % yo backbone:view note_list
     create app/scripts/templates/note_list.ejs
     create app/scripts/views/note_list.js
     create test/views/note_list.spec.js
  [devnote@hooni:~/documents/study/js/practice] % yo backbone:view container
     create app/scripts/templates/container.ejs
     create app/scripts/views/container.js
     create test/views/container.spec.js
```

## モデル

* app/scripts/models/note.js

```javascript
  /*global Practice, Backbone*/

  Practice.Models = Practice.Models || {};

  (function () {
    'use strict';

    Practice.Models.Note = Backbone.Model.extend({

      url: '',

      initialize: function() {
      },

      defaults: {
        title: '',
        body: ''
      },

      validate: function(attrs, options) {
      },

      parse: function(response, options)  {
        return response;
      }
    });

  })();
```

## コレクション

* app/scripts/collections/note.js

```javascript
  /*global Practice, Backbone*/

  Practice.Collections = Practice.Collections || {};

  (function () {
    'use strict';

    Practice.Collections.Note = Backbone.Collection.extend({

      localStorage: new Backbone.LocalStorage('Notes'),
      model: Practice.Models.Note

    });

  })();
```

## ビュー

* app/scripts/views/note.js

```javascript
  /*global Practice, Backbone, JST*/

  Practice.Views = Practice.Views || {};

  (function () {
    'use strict';

    Practice.Views.Note = Backbone.View.extend({

      template: JST['app/scripts/templates/note.ejs'],

      tagName: 'tr',

      id: '',

      className: '',

      events: {},

      initialize: function () {
        this.listenTo(this.model, 'change', this.render);
      },

      render: function () {
        this.$el.html(this.template(this.model.toJSON()));
        return this;
      }

    });

  })();
```

* app/scripts/views/note_list.js

```javascript
  /*global Practice, Backbone, JST*/

  Practice.Views = Practice.Views || {};

  (function () {
    'use strict';

    Practice.Views.NoteList = Backbone.View.extend({

      template: JST['app/scripts/templates/note_list.ejs'],

      tagName: 'table',

      id: '',

      className: 'table',

      events: {},

      initialize: function (options) {
        // Backbone.Collectionインスタンスを受け取る
        this.collection = options.collection;
        // this.listenTo(this.model, 'change', this.render);
      },

      render: function () {
        this.$el.html(this.template);
        this.collection.each(function(note) {
          var noteView = new Practice.Views.Note({
            model: note
          });
          this.$('#noteList').append(noteView.render().$el);
        }, this);
        return this;
      }

    });

  })();
```
    
* app/scripts/views/container.js

```javascript
  /*global Practice, Backbone, JST*/

  Practice.Views = Practice.Views || {};

  (function () {
    'use strict';

    Practice.Views.Container = Backbone.View.extend({

      show: function(view) {
        this.destroyView(this.currentView);
        this.$el.append(view.render().$el);
        this.currentView = view;
      },

      destroyView: function(view) {
        if(!view) {
          return;
        }
        view.off();
        view.remove();
      },

      empty: function() {
        this.destroyView(this.currentView);
        this.currentView = null;
      }

    });

  })();
```

## テンプレート

* app/scripts/templates/note.ejs

```html
  <td>
    <a href="#">
      <%= title %>
    </a>
  </td>
  <td>
    <div class="text-right">
      <a href="#" class="btn btn-primary btn-sm">
        <span class="glyphicon glyphicon-edit"></span>
        Edit
      </a>
      <a href="#" class="btn btn-danger btn-sm">
        <span class="glyphicon glyphicon-remove"></span>
        Delete
      </a>
    </div>
  </td>
```

* app/scripts/templates/note_list.ejs

```html
  <thead>
    <th class="col-md-2" colspan="2">Title</th>
  </thead>
  <tbody id="noteList"></tbody>
```
    
* app/index.html

```html
  <div class="container">
    <div class="header">
      <ul class="nav nav-pills pull-right">
        <li class="active"><a href="#">Home</a></li>
        <li><a href="#">About</a></li>
        <li><a href="#">Contact</a></li>
      </ul>
      <h3 class="text-muted">Practice</h3>
    </div>

    <div id="main">
      <div id="header-container"></div>
      <div id="main-container"></div>
    </div>

    <div class="footer">
      <p>♥ from the Yeoman team</p>
    </div>
  </div>
```

## メモ一覧を表示

* app/scripts/main.js

```javascript
  /*global Practice, $*/

  window.Practice = {
    Models: {},
    Collections: {},
    Views: {},
    Routers: {},
    init: function () {
      'use strict';
      console.log('Hello from Backbone!');
    }
  };

  $(document).ready(function () {
    'use strict';
    Practice.init();
    var noteCollection = new Practice.Collections.Note([{
      title: 'テスト1',
      body: 'テスト1です'
    }, {
      title: 'テスト2',
      body: 'テスト2です'
    }]);
    var noteListView = new Practice.Views.NoteList({
      collection: noteCollection
    });

    // containerビューを利用しない場合
    // $('#main-container').append(noteListView.render().$el);

    // containerビューを利用する場合
    var mainContainer = new Practice.Views.Container({
      el: '#main-container'
    })
    mainContainer.show(noteListView);
  });
```

## メモの削除

### 仕様

* Deleteボタンを押したら対応するモデルを破棄する。
* メモ一覧を更新される。

### テンプレートの修正

* app/scripts/templates/note.ejs

```html
  <td>
    <a href="#">
      <%= title %>
    </a>
  </td>
  <td>
    <div class="text-right">
      <a href="#" class="btn btn-primary btn-sm js-edit">
        <span class="glyphicon glyphicon-edit"></span>
        Edit
      </a>
      <a href="#" class="btn btn-danger btn-sm js-delete">
        <span class="glyphicon glyphicon-remove"></span>
        Delete
      </a>
    </div>
  </td>
```

### ビューの修正

* app/scripts/views/note.js

```javascript
  ... snip ...
  events: {
    // Deleteボタンを監視してonClickDelete()メソッドを呼び出す。
    'click .js-delete': 'onClickDelete'
  },

  initialize: function () {
    // モデルのdestroyイベントを監視してBackbone.Viewのremove()メソッドを呼び出す。
    this.listenTo(this.model, 'destroy', this.remove);
  },

  render: function () {
    this.$el.html(this.template(this.model.toJSON()));
    return this;
  },

  onClickDelete: function() {
    // モデルを削除する。
    this.model.destroy();
  }
  ... snip ...
```

## データの永続化

* app/scripts/main.js

```javascript
  /*global Practice, $*/

  window.Practice = {
    Models: {},
    Collections: {},
    Views: {},
    Routers: {},
    init: function () {
      'use strict';
      console.log('Hello from Backbone!');
      // ダミーのNoteモデルを生成する。
      var noteCollection = new Practice.Collections.Note([{
        title: 'テスト1',
        body: 'テスト1です'
      }, {
        title: 'テスト2',
        body: 'テスト2です'
      }, {
        title: 'テスト3',
        body: 'テスト3です'
      }]);

      // 作成したモデルはローカルストレージに保存する。
      noteCollection.each(function(note) {
        note.save();
      });

      // この処理で作ったコレクションは一時的な用途であり
      // 必要なのは中身のモデルなのでモデルの配列を返す。
      return noteCollection.models;
    }
  };

  $(document).ready(function () {
    'use strict';

    // noteCollectionを初期化する。
    // 後で別のjsファイルからも参照するので、Practice名前空間下に参照を持たせておく。
    Practice.noteCollection = new Practice.Collections.Note();

    // メモ一覧のビューを表示する領域としてmainContainerを初期化する。
    // こちらも同様の理由でPractice配下に参照を持たせる。
    Practice.mainContainer = new Practice.Views.Container({
      el: '#main-container'
    });

    // noteCollectionコレクションのデータを受信する。
    // backbone.localStorageを使用しているのでブラウザのローカルストレージから読み込む。
    Practice.noteCollection.fetch().then(function(notes) {
      // もし読み込んだデータが空であればダミーデータでコレクションの中身を上書きする。
      if (notes.length === 0) {
        var models = Practice.init();
        Practice.noteCollection.reset(models);
      }

      // コレクションを渡してメモ一覧の親ビューを初期化する。
      var noteListView = new Practice.Views.NoteList({
        collection: Practice.noteCollection
      });

      // 表示領域にメモ一覧を表示する。
      Practice.mainContainer.show(noteListView);
    })
  });
```

## メモの詳細画面の表示

### 仕様

* メモ一覧のタイトルをクリックしたら、その詳細画面が表示される。

### 詳細画面用のビューとルータを生成

* 雛形生成

```sh
  [devnote@hooni:~/documents/study/js/practice] % yo backbone:view note_detail
     create app/scripts/templates/note_detail.ejs
     create app/scripts/views/note_detail.js
     create test/views/note_detail.spec.js
  [devnote@hooni:~/documents/study/js/practice] % yo backbone:router note
     create app/scripts/routes/note.js
     create test/routers/note.spec.js
```

### ビュー

* app/scripts/views/note_detail.js

```javascript
  /*global Practice, Backbone, JST*/

  Practice.Views = Practice.Views || {};

  (function () {
    'use strict';

    Practice.Views.NoteDetail = Backbone.View.extend({

      template: JST['app/scripts/templates/note_detail.ejs'],

      render: function () {
        this.$el.html(this.template(this.model.toJSON()));
        return this;
      }

    });

  })();
```

### テンプレート

* app/scripts/templates/note_detail.ejs

```html
  <h2><%= title %></h2>
  <p><%= body %></p>
```

### ルータ

* メモタイトルのリンク先の変更
* app/scripts/templates/note.ejs

```html
  ... snip ...
  <td>
    <a href="#notes/<%= id %>">
      <%= title %>
    </a>
  </td>
  ... snip ...
```

* ルーティングの追加
* app/scripts/routes/note.js

```javascript
  /*global Practice, Backbone*/

  Practice.Routers = Practice.Routers || {};

  (function () {
    'use strict';

    Practice.Routers.Note = Backbone.Router.extend({
      routes: {
        'notes/:id': 'showNoteDetail'
      },

      // ルーティングが受け取った:idパラメータはそのまま引数名idで受け取れる。
      showNoteDetail: function(id) {
        var note = Practice.noteCollection.get(id);
        var noteDetailView = new Practice.Views.NoteDetail({
          model: note
        });
        Practice.mainContainer.show(noteDetailView);
      }
    });

  })();
```

* ルータの初期化処理の追加
* app/scripts/main.js

```javascript
  ... snip ...
  // 表示領域にメモ一覧を表示する。
  Practice.mainContainer.show(noteListView);

  // ルータの初期化と履歴管理の開始
  Practice.noteRouter = new Practice.Routers.Note();
  Backbone.history.start();
  ... snip ...
```

## メモ一覧を表示するルーティング

### デフォルトのルーティング

* アスタリスク（＊）で始まる構文は任意の文字列にマッチする。
* *引数名という定義で他のルーティングに引っかからないアクセスすべてに反応する。
* navigate()メソッドは、hashchangeイベントを発生せずにURLの更新だけを行う。
* デフォルトの#付きURLを#notesに変更する。
* app/scripts/routes/note.js

```javascript
  /*global Practice, Backbone*/

  Practice.Routers = Practice.Routers || {};

  (function () {
    'use strict';

    Practice.Routers.Note = Backbone.Router.extend({
      routes: {
        'notes/:id': 'showNoteDetail',
        '*actions': 'defaultRoute'
      },

      defaultRoute: function() {
        this.showNoteList();
        this.navigate('notes');
      },

      // ルーティングが受け取った:idパラメータはそのまま引数名idで受け取れる。
      showNoteDetail: function(id) {
        var note = Practice.noteCollection.get(id);
        var noteDetailView = new Practice.Views.NoteDetail({
          model: note
        });
        Practice.mainContainer.show(noteDetailView);
      },

      // app/scripts/main.jsからここに移す。
      showNoteList: function() {
        // コレクションを渡してメモ一覧の親ビューを初期化する。
        var noteListView = new Practice.Views.NoteList({
          collection: Practice.noteCollection
        });

        // 表示領域にメモ一覧を表示する。
        Practice.mainContainer.show(noteListView);
      }
    });

  })();
```
  
* app/scripts/main.js

```javascript
  ... snip ...
  Practice.noteCollection.fetch().then(function(notes) {
    // もし読み込んだデータが空であればダミーデータでコレクションの中身を上書きする。
    if (notes.length === 0) {
      var models = Practice.init();
      Practice.noteCollection.reset(models);
    }

    // ルータの初期化と履歴管理の開始
    Practice.noteRouter = new Practice.Routers.Note();
    Backbone.history.start();
  })
  ... snip ...
```
  
* HOMEのURLを修正する。
* app/index.html

```html
  ... snip ...
  <div class="header">
    <ul class="nav nav-pills pull-right">
      <li class="active"><a href="./index.html#notes">Home</a></li>
      <li><a href="#">About</a></li>
      <li><a href="#">Contact</a></li>
    </ul>
    <h3 class="text-muted">Practice</h3>
  </div>
  ... snip ...
```

## メモの新規作成機能の追加

### 仕様

* New Note ボタンを押すと新規メモ画面が開く。
* 新規作成したメモは保存できる。
* 保存したらメモ一覧画面に戻る。
* 画面はルーティングで切り替える。

### 新規作成ボタンの設置

* 雛形生成

```sh
  [devnote@hooni:~/documents/study/js/practice] % yo backbone:view note_control
     create app/scripts/templates/note_control.ejs
     create app/scripts/views/note_control.js
     create test/views/note_control.spec.js
```

* テンプレート
* app/scripts/templates/note_control.ejs

```javascript
  <div class="row">
    <div class="col-sm-6">
    </div>
    <div class="col-sm-6 text-right">
      <a href="#new" class="btn btn-primary btn-small js-new">
        <span class="glyphicon glyphicon-plus"></span>
        New Note
      </a>
    </div>
  </div>
```

* ビュー
* app/scripts/views/note_control.js

```javascript
  /*global Practice, Backbone, JST*/

  Practice.Views = Practice.Views || {};

  (function () {
    'use strict';

    Practice.Views.NoteControl = Backbone.View.extend({

      template: JST['app/scripts/templates/note_control.ejs'],

      render: function () {
        this.$el.html(this.template);
        return this;
      }

    });

  })();
```

### 新規作成画面の追加

* 雛形生成

```sh
  [devnote@hooni:~/documents/study/js/practice] % yo backbone:view note_form                                                                   (git)-[master] <U>
     create app/scripts/templates/note_form.ejs
     create app/scripts/views/note_form.js
     create test/views/note_form.spec.js
```

* メモのタイトルと本文の入力フォームのテンプレート

```javascript
  <form>
    <div class="form-group">
      <label for="noteTitle">Title</label>
      <input type="text" class="form-control js-noteTitle" id="noteTitle" value="<%= title %>">
    </div>
    <div class="form-group">
      <label for="noteBody">Body</label>
      <textarea class="form-control js-noteBody" rows="8">
        <%= body %>
      </textarea>
    </div>
    <button type="submit" class="btn btn-primary">Submit</button>
  </form>
```

* メモのタイトルと本文の入力フォームのビュー
  - preventDefault()：元々のイベントをキャンセルする。
  - フォームの入力値を任意のオブジェクトへ格納する。
  - submit:formイベントを発火する。

* app/scripts/views/note_form.js

```javascript
  /*global Practice, Backbone, JST*/

  Practice.Views = Practice.Views || {};

  (function () {
    'use strict';

    Practice.Views.NoteForm = Backbone.View.extend({

      template: JST['app/scripts/templates/note_form.ejs'],

      events: {
        'submit form': 'onSubmit'
      },

      onSubmit: function(e) {
        e.preventDefault();
        var attrs = {};
        attrs.title = this.$('.js-noteTitle').val();
        attrs.body = this.$('.js-noteBody').val();
        this.trigger('submit:form', attrs);
      },

      render: function () {
        this.$el.html(this.template(this.model.toJSON()));
        return this;
      }

    });

  })();
```

* 新規作成画面のルーティングの追加
  - newというルートにshowNewNote()が紐付ける。
  - submit:formイベントを監視する。

* app/scripts/routes/note.js

```javascript
  /*global Practice, Backbone*/

  Practice.Routers = Practice.Routers || {};

  (function () {
    'use strict';

    Practice.Routers.Note = Backbone.Router.extend({
      routes: {
        'notes/:id': 'showNoteDetail',
        'new': 'showNewNote',
        '*actions': 'defaultRoute'
      },

      ... snip ...

      showNewNote: function() {
        var self = this;
        // テンプレートの<%= title %>などの出力を空文字列で空欄にしておくため、
        // 新規に生成したNoteモデルを渡してNoteFormViewを初期化する。
        var noteFormView = new Practice.Views.NoteForm({
          model: new Practice.Models.Note()
        });

        noteFormView.on('submit:form', function(attrs) {
          // submit:formイベントで受け取ったフォームの入力値をNoteCollectionコレクションのcreate()に
          // 渡してNoteモデルの新規作成と保存を行う。
          Practice.noteCollection.create(attrs);

          // モデル一覧を表示してルートを#notesに戻す。
          self.showNoteList();
          self.navigate('notes');
        });

        Practice.mainContainer.show(noteFormView);

        // New Note ボタンはこの画面では必要ないので、ビューを破棄しておく。
        Practice.headerContainer.empty();
      }
    });

  })();
```

### なぜselfを使っているのか？

* thisをそのまま使わずになぜselfという変数を使っているのか気になって「入門Backbone.js」という本を調べたら、下記のように説明していた。

* selfとthis（83ページ）
  - コンテキストが変化したとしても、元のthisへの参照を残しておくための措置である。
  - イベントハンドラとクロージャでよく使用される。

* 読んでみてもパッとこなかったので、下記のようにソースコードを変更してdebuggerで確認した。

```javascript
  ... snip ...
  noteFormView.on('submit:form', function(attrs) {
    Practice.noteCollection.create(attrs);

    this.showNoteList(); // ここでエラーが発生したので、ブレイクポイントを設定しておいてselfとthisを確認する。
    this.navigate('notes');
  });
  ... snip ...
```

* その結果、次のようなことが分かった。
  - ルーティングで使われているthisとイベントが発生したときのthisが異なる。
  - selfはwindowつまりグローバル変数だったが、イベントが発生したときのthisはビューになっていた。
  - イベントが発生したときのコールバック関数の中のthisはイベントを発生させるところのthisなのだ。当然だよな。

* 結論
  - 本で言われた通りにイベントハンドラやクロージャを使う場合は、慣習的にselfを使おう。

## メモの編集機能の追加

### 仕様

* Edit ボタンを押すとメモの編集画面が開く。
* 編集したメモは保存できる。
* 編集をしたらメモの詳細画面に移る。
* 画面はルーティングで切り替える。

### 編集ボタンのリンク先を変更

* app/scripts/templates/note.ejs

```javascript
  ... snip ...
  <a href="#notes/<%= id %>/edit" class="btn btn-primary btn-sm js-edit">
    <span class="glyphicon glyphicon-edit"></span>
    Edit
  </a>
  ... snip ...
```

### 編集画面のルーティングの追加

* app/scripts/routes/note.ejs

```javascript
  /*global Practice, Backbone*/

  Practice.Routers = Practice.Routers || {};

  (function () {
    'use strict';

    Practice.Routers.Note = Backbone.Router.extend({
      routes: {
        'notes/:id': 'showNoteDetail',
        'new': 'showNewNote',
        'notes/:id/edit': 'showEditNote',
        '*actions': 'defaultRoute'
      },

      ... snip ...

      showEditNote: function(id) {
        var self = this;
        // 既存のNoteモデルを取得してNoteFormViewに渡す
        var note = Practice.noteCollection.get(id);
        var noteFormView = new Practice.Views.NoteForm({
          model: note
        });

        noteFormView.on('submit:form', function(attrs) {
          // submit:formイベントで受け取ったフォームの入力値をNoteモデルに保存する。
          note.save(attrs);

          // モデル詳細画面を表示してルートも適切なものに書き換える。
          self.showNoteDetail(note.get('id'));
          self.navigate('notes/' + note.get('id'));
        });

        Practice.mainContainer.show(noteFormView);
      }
    });

  })();
```

## 参考書籍

* JavaScriptエンジニア養成読本
* JavaScript徹底攻略
* 入門Backbone.js