関連記事
- 勉強会JS編<1> オブジェクト指向言語としてのJavaScriptを理解
- 勉強会JS編<2> クライアントサイドMVCフレームワーク
- 勉強会JS編<3> フロントエンド開発環境構築
- 勉強会JS編<4> yeoman + backbone.model + grunt
- 勉強会JS編<5> yeoman + backbone.collection + backbone.localStorage
- 勉強会JS編<6> yeoman + backbone.view
- 勉強会JS編<7> yeoman + backbone.router
- 勉強会JS編<8> yeoman + backbone.js 実践 - その1
- 勉強会JS編<9> yeoman + backbone.js 実践 - その2
雛形生成
- モデル
- 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