4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

勉強会JS編<8> yeoman + backbone.js 実践 - その1

Last updated at Posted at 2016-02-09

関連記事

雛形生成

  • モデル
    • Practice.Models.Note
    • titleとbody属性を持っている。
    • データの永続化のため、backbone.localStorageを使う。
  • コレクション
    • Practice.Collections.Note
    • モデルを格納する。
  • ビュー
    • Practice.Views.Note:個々のメモ項目を表示する子ビュー(レコード)
    • Practice.Views.NoteList:メモ一覧のビュー(テーブル)
    • Practice.Views.Container:古いビューがカベージコレクションの対象になるように破壊する。
  [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
  /*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
  /*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
  /*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
  /*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
  /*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
  <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
  <thead>
    <th class="col-md-2" colspan="2">Title</th>
  </thead>
  <tbody id="noteList"></tbody>
  • app/index.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
  /*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
  <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
  ... 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
  /*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);
    })
  });

メモの詳細画面の表示

仕様

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

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

  • 雛形生成
  [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
  /*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
  <h2><%= title %></h2>
  <p><%= body %></p>

ルータ

  • メモタイトルのリンク先の変更
  • app/scripts/templates/note.ejs
  ... snip ...
  <td>
    <a href="#notes/<%= id %>">
      <%= title %>
    </a>
  </td>
  ... snip ...
  • ルーティングの追加
  • app/scripts/routes/note.js
  /*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
  ... 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
  /*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
  ... 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
  ... 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 ボタンを押すと新規メモ画面が開く。
  • 新規作成したメモは保存できる。
  • 保存したらメモ一覧画面に戻る。
  • 画面はルーティングで切り替える。

新規作成ボタンの設置

  • 雛形生成
  [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
  <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
  /*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;
      }

    });

  })();

新規作成画面の追加

  • 雛形生成
  [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
  • メモのタイトルと本文の入力フォームのテンプレート
  <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

  /*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

  /*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で確認した。

  ... 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
  ... 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
  /*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
4
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?