angular.jsとknockout.jsを使ってみた

とても便利なangular.jsとknockout.js

これまでbackbone.jsを触ってきたのですが、正月休みでangular.jsとknockout.jsも試しに触ってみました。

backbone.jsほど学習に時間がかからず、特にknockoutはdata-bind属性内に予め定義されたバインディングや自作のバインディングを書いて、それにパラメータを渡すことで様々なイベントや処理が行えるようなかんじで、覚えるのがとても楽でした。

angular.jsは ng-hogehoge 属性でhogehogeをいろんな形式に書き換えることで様々なイベントや処理を行うことができるかんじになっており、ng-model属性で定義した識別子をng-valueに書くとその部分にリレーションができて<input data-model=”hoge”>とすると<span ng-value=”hoge”>の値がリアルタイムで書き換わるので便利。

knockout.jsでも同じことはできるけども、knockout.jsの場合は、observableでデータの監視、computedでデータの監視で変化があると実行する関数で明示的に定義してやらないといけないので注意。

backbone.js使わなくてもいいような・・・?

そんなことはないです。backbone.jsは様々なプラグインが用意されており、もしくは自分で自由に振舞いを書き換えることができるのでかなりカスタマイズがきくし自由度が高い。angular.jsやknockout.jsはビューの書き換えには特化してるけどモデルの扱いは弱いので、そういった面でもbackbone.jsは補えたりする。なので、一長一短なのだ。

使い分け

  • backbone.js → 大規模開発に向いている。フルカスタマイズしたい人向けで、骨さえ用意してくれればいいよ!っていう場合これ
  • knockout.js → イベントの処理かくのめんどくさいので楽に書きたい、それだけでいいという方むけ。
  • angular.js → 楽に書きたい! 楽にリッチなWebアプリ作りたい! 細かいカスタマイズは必要ない! という方はこれ!

最後に

どのフレームワークも「癖」みたいなものがあります。食わず嫌いはしないで一度様々なフレームワークに触れてみて自分、もしくはプロジェクトにあったフレームワークに手を出してみるのがいいと思います。

backbone.jsの公式ページを上から下まで眺めてみる

夜です。今回は何を思ったのか、backbone.jsの公式ページを眺めるという行為に及んでみたいと思います。
ちょっと見てみると、backbone.jsのルーティングがなんか便利そうじゃないですか。pushState触らなくていいとか!
ajax使うにはもうbackboneは手放せない存在なんじゃないでしょうか。githubは違うプラグイン使っているみたいですが…

スクリーンショット 2013-08-04 2.18.07
公式ページ。backbone.jsの配布ページとマニュアルページがひとつに繋がっているタイプ。Ctrl+Fで検索しやすいのでなかなかいいかも。

Introduction

Google翻訳を使ってIntroductionを読んでみます。要約すると(確実に翻訳ミスがあります)

  • jQueryをDOMを直接操作してHTMLを動的に動かす開発をしてるんでしょうけど、古いよ
  • フロントエンドとサーバー間でデータの受け渡しに四苦八苦してる
  • Backbone.jsを使うとデータの受け渡し楽よ
  • Backbone.jsを使うとデータに変化が合った場合changeイベントが走って画面が動的に書き換わるから楽よ
  • Backbone.jsを使うと手作業でHTML更新しなくてすむよ
  • Backbone.jsがどういうものなのか下にたくさんサンプルスクリプト用意したから見てね
  • 再生ボタン押すとサンプルスクリプトを直接実行できるよ

という感じな気がする。多分。

Upgrading to 1.0

1.0の変更点などが書いてある。あまり関係なさそうなので省略。

Backbone.Events

イベントは、任意のオブジェクトにイベントドリブンなメソッドを提供したりできるようです。

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

とするだけで、変数objectは object.on と object.trigger というメソッドを持つようになりました。onはイベントの登録、triggerはイベントの実行です。

var object = {};
// 変数objectで object.on と object.trigger を使えるようにした
_.extend(object, Backbone.Events);
// alertという名前でイベントを登録
object.on("alert", function(msg) { alert("Triggered " + msg);
});
// イベントの実行
object.trigger("alert", "an event");

なるほど便利、 ちなみに _.extend は underscore.jsの機能で、オブジェクトに異なるオブジェクトのメソッドをもたせることができる、いわゆるサブクラスっぽいことができるメソッド。triggerで引数をわたすこともできるようです。

また、

var dispatcher = _.clone(Backbone.Events);

でディスパッチャの切り分けができます。

object.on(event, callback, [context])Alias: bind

オブジェクトに登録したイベントが発生するたびにコールバック関数が呼び出されます。
オブジェクトに登録したイベントが多すぎるときに分類するときは”hoge:[attribute]”と書くと良いようです。

スペース区切りすると、複数のイベントを登録させることができます。

book.on("change:title change:author", ...);

第三パラメータに値またはオブジェクトを渡すと、それがコールバック関数内で、thisになります。

model.on('change', this.render, this);
model.on('change', this.render, {hoge: 1});

all という名前のイベントを登録すると、オブジェクトから様々なイベントが実行された後に一緒にallイベントのコールバック関数も実行されるようになります。

object.on("all", function(eventName) { // eventNameには実行されたイベントの名前が入っています console.log(eventName);
});

このような書き方(イベントマップ)でもイベントを登録することができます。これだと一括でイベントが登録できるので楽。

book.on({ "change:title": titleView.update, "change:author": authorPane.update, "destroy": bookView.remove
});

object.off([event], [callback], [context]) Alias: unbind

オブジェクトから以前登録されたイベントのコールバック関数を削除するメソッドです。
コンテキストが指定されていない場合は、コールバックのすべてのイベントが削除される。
コールバックが指定されていない場合は、イベントのすべてのコールバックが削除。
イベントが指定されていない場合は、すべてのイベントのコールバックは削除。

event→callback→contextと削除される条件と範囲が変わるようです。ということは、同じイベント名でもコールバック関数が違えば、上書きされず2つとも実行される仕様のようですね。下記スクリプトでテストしてみることにします。

var o = {};
_.extend(o, Backbone.Events);
o.on("hoge", function() { console.log("hoge1");
});
o.on("hoge", function() { console.log("hoge2");
});
o.trigger("hoge");
#=> hoge1
#=> hoge2

2つ出力されたので、2つのコールバック関数が実行されることが確認出ました。イベント名が同じ場合、オーバーライド(上書き)されるのではなく、順々に実行されるようです。

offメソッドの使い方は下記の通り。

// changeイベントに登録されているonChangeコールバック関数の登録解除
object.off("change", onChange);
// change イベント に登録されている全てのコールバック関数の登録解除
object.off("change");
// 全てのイベントのonChangeコールバック関数を登録解除
object.off(null, onChange);
// 全てのイベントの全てのコールバックのcontextを持っている全てのコールバック関数の登録解除
object.off(null, null, context);
// objectに登録されている全てのコールバック関数の登録解除
object.off();

onメソッドの使い方サンプルではよくわかりませんでしたが、offメソッドのサンプルを見ることで、backboneのイベントがどこまで柔軟なことができるか目に見えてわかりますね。。null = 全て の範囲はおそらく、backbone.Eventsを使って登録したイベントの範囲なのでしょうから、

var dispatcher = _.clone(Backbone.Events);

によってディスパッチャをわけることで、この全ての範囲をわけることも可能だということにメリットも感じられます。

object.trigger(event, [*args])

eventを実行します。”event event”の書き方であればそれぞれのイベントを実行します。
argsでは,区切りで引数を付けることができます。
ただそれだけ!とてもシンプル!!

object.once(event, callback, [context])

一度実行されたら削除されるイベントです。

var o = {};
_.extend(o, Backbone.Events);
o.on("hoge", function() { console.log("hoge1");
});
o.once("hoge", function() { console.log("hoge2");
});
o.trigger("hoge");
o.trigger("hoge");

イベントといってもコールバック関数が削除されるので、hoge1コールバック関数は消されずに残っています。hoge2だけが実行されません。なるほど便利。

object.listenTo(other, event, callback)

多分、後でlistenToについて説明した項があるんでしょうけど、やっぱり気になるのでここで説明すると、otherは var model = new Backbone.Model(); で作ったモデルに対してeventが発生すると、callbackを実行してくれるというもの。

var o = {};
_.extend(o, Backbone.Events);
var model = new Backbone.Model();
o.listenTo(model, 'change', function () { console.log("モデルデータに変化がありました", this);
});
model.set("hoge", "ほげほげ");
// => モデルデータに変化がありました 

サンプルだと、モデルに変化があると実行してくれます。 this の中身は o になっているので、モデルに対して o のメソッドが使えるので、 o のメソッドを使ってモデルに対していろんなことができます。なるほど、便利。

眠いけど、どんどんいきます。

object.stopListening([other], [event], [callback])

listenToの逆、イベントの監視をストップします。

// viewの監視を全てストップ
view.stopListening();
// viewのmodelに対する監視をストップ
view.stopListening(model);

object.listenToOnce(other, event, callback)

ちょうどlistenToを同じような振る舞いをしますが、一度監視でコールバック関数が実行されると、監視を終了します。

var o = {};
_.extend(o, Backbone.Events);
var model = new Backbone.Model();
o.listenToOnce(model, 'change', function () { console.log("モデルデータに変化がありました", this);
});
model.set("hoge", "ほげほげ");
// => モデルデータに変化がありました
model.set("hoge", "ほげほげ");
// => 

ずいぶんとやる気のないメソッドですね。

Catalog of Events

モデルやビューやコレクションに対して独自にイベントを定義し利用することは自由だけれど、そのほかにBackbone.jsが独自に追加しているイベントもあるようで、それが下記の通り。

add
モデルがコレクションに追加されたとき呼ばれます。
remove
モデルがコレクションから削除されたとき呼ばれます。
reset
コレクションが新しく作りなおされたときに呼び出されます。(あいまい)
sort
コレクションが再ソートされたとき呼ばれます。
change
モデルの属性が変更されたとき呼ばれます。(よく使われます)
change:[attribute]
モデルの持っている特定の値が変更されたときのみ呼ばれます
destroy
モデルが削除されたとき呼ばれます。
request
モデルまたはコレクションがサーバーにリクエストを送った時に呼ばれます。
sync
モデルまたはコレクションが正常にサーバーと同期されているときに呼ばれます。
error
モデルの内容をサーバーに送り保存しようとしたときに、サーバーエラーが発生した場合呼ばれます。
invalid
モデルの検証がクライアント上で失敗したときに呼ばれます。
route:[name]
指定されたルーティングが一致しているとき実行します
route
任意のルーティングが一致したとき、または履歴によって一致したときに呼び出されます。
all
このイベントは、第一パラメータにイベント名を渡し、あらゆるイベントに対しても呼び出されます。

わかるのもありけど、よくわからないのもありますね。ルーティング…?

また、これらイベントは model.set(), collection.add 等のメソッドによって呼ばれるので、 model[“hoge”] = 1 なんてしても呼ばれることはないようです。また、model.set等でモデルにデータを追加した場合に、イベントが呼ばれるのが嫌な場合は、

var o = {silent: true};
_.extend(o, Backbone.Events);
var model = new Backbone.Model({silent: true});
o.listenToOnce(model, 'change', function () { console.log("モデルデータに変化がありました", this);
});
model.set("hoge", "ほげほげ", {silent: true});
// => 

というよう第三パラメータ、 silent: true を渡すことで、イベントが発生しないようです。

Backbone.Model

ふぅ… いよいよModelへはいります。
Modelというのはデータ構造を表します。というか、データそのものです。身近なものだと、アドレス帳とか、日記とかソシャゲのガチャで集めたカードなどといったデータをまとめてモデルと言います。

Backbone.jsにおけるモデルは、JavaScriptの心臓部です。Backbone.jsはモデルに対して変換や、検証や、プロパティの追加削除機能等 様々な機能を提供します。

以下のサンプルスクリプトは公式にあげられていたものをそのままコピーしたものに日本語コメントを付与したものです。サイドバーの背景色を変更するだけの簡単なスクリプトになっています。公式ページでは再生ボタンを押すことでスクリプトを実行することができます。一度実行すると、ChromeであればWeb Developper Toolsによってモデルの書き換えを行い自由に色を変更することができるので、モデルを変更することでコールバックを実行するといった一連のフローを理解することができます。

// Backboneモデルクラスを作成し、そのモデルクラスに色を変更するメソッドを付けて拡張します
var Sidebar = Backbone.Model.extend({ promptColor: function() { var cssColor = prompt("Please enter a CSS color:"); this.set({color: cssColor}); }
});
// Sidebarモデルクラスから window.sidebar モデルを作成します
window.sidebar = new Sidebar;
// change:color イベントが発生したら、コールバック関数によってjQueryを使用し背景色を変更するようにします
sidebar.on('change:color', function(model, color) { $('#sidebar').css({background: color});
});
// サイドバーの色を白に変更します
sidebar.set({color: 'white'});
// サイドバーの色を何色にするかクライアントに問いかけます
sidebar.promptColor();

ソースコードの動きが読めたでしょうか、通常のコーディングでは、ボタンを押したらここの背景を変えて…みたいな処理を書いていたとおもいますが、このソースコードでは、sidebarモデルのcolorプロパティの値をwhiteに変えただけで、サイドバーの背景色が変わっています。データだけ見ることによって、ロジックまで来にしなくて済むのです。なるほど、便利。

Backbone.Model.extend(properties, [classProperties])

extendによって、Backbone.Modelを拡張し、独自のモデルクラスを生成することができます。
このモデルクラスを利用してモデルを作ると、そのモデルはpropertiesのプロパティを持つようになります。

// Nodeモデルクラスを生成
var Note = Backbone.Model.extend({ initialize: function() {}, author: function() {}, coordinates: function() {}, allowedToEdit: function(account) { return true; }
});
// PrivateNoteモデルクラスを生成
var PrivateNote = Note.extend({ allowedToEdit: function(account) { return account.owns(this); }
});

また、定義済みのメソッドをオーバーライドしたい場合、簡単にオーバーライドできるようにな仕組みは存在ず、めんどくさいけども下記のようなやりかたで実現可能です。

var Hoge = Backbone.Model.extend({ set: function(attributes, options) { Backbone.Model.prototype.set.apply(this, arguments); console.log("値をセットしました"); }
});
var a = new Hoge();
a.set("hoge", "1");
console.log(a.get("hoge"));

constructor / initializenew Model([attributes], [options])

モデルを生成するときに、デフォルトの値を最初から定義したり、コンストラクタ(モデルを生成するタイミングで呼ばれるメソッド)を定義したりすることができます。

モデルを作成するタイミングでデフォルトの値を最初から定義する場合は、

// モデルに値を入れて生成
new Book({ title: "One Thousand and One Nights", author: "Scheherazade"
});

とします。

モデルを生成するタイミングでメソッドを呼び出したい場合は

var Library = Backbone.Model.extend({ constructor: function() { this.books = new Books(); Backbone.Model.apply(this, arguments); }, parse: function(data, options) { this.books.reset(data.books); return data.library; }
});

このようにします。

model.get(attribute)

モデルから指定されたattributeに定義されてある値を取得します。

model.set(attributes, [options])

モデルに値をセットします。モデルに値をセットすると、changeイベントが発生します。
change:[attribute] というイベントが登録されていた場合は、その[attribute]が変更された場合のみそのイベントに関連付けられたコールバック関数が実行します。

var a = new Backbone.Model();
a.on("change", function () { console.log("変更されましたA");
});
a.on("change:hoge", function () { console.log("変更されましたB");
});
a.on("change:hoge2", function () { console.log("変更されましたC");
});
a.set("hoge", "1");
// => 変更されましたB
// => 変更されましたA

モデルはオブジェクトのように定義することもできます。

note.set({title: "March 20", content: "In his eyes she eclipses..."});

model.escape(attribute)

getメソッドと似通ってはいるけど、これはHTMLをエスケープしたものを返すので、XSS対策を行うことができます。(エスケープは、HTMLエンティティ化です)

var a = new Backbone.Model({ hoge: "<script>alert(1);</script>"
});
a.escape("hoge");
// => &lt;script&gt;alert(1);&lt;&#x2F;script&gt;

model.has(attribute)

modelがattributeを持っている場合trueを返します

var a = new Backbone.Model({ hoge: "hoge"
});
console.log(a.has("hoge"));
// => true
console.log(a.has("hogehoge"));
// => false

model.unset(attribute, [options])

modelのattributeで指定されたプロパティを削除します。optionにsilent: true が指定されな限りchangeイベントが呼ばれます。

var a = new Backbone.Model({ hoge: "hoge"
});
a.unset("hoge");
console.log(a.has("hoge"));
// => false

model.clear([options])

modelからid属性を含む全てのプロパティを削除します。optionsにsilent:true が指定されていない限り、changeイベントが発生します。

var a = new Backbone.Model({ hoge: "hoge"
});
a.clear();
console.log(a.has("hoge"));
// => false

model.id

model.idにはユニークな値を指定することができます。特に使い方が今の段階では不明。。

model.idAttribute

model.idとして取得できる別な属性を指定することができます。これは、データベースのテーブルからデータを引っ張ってきたときにカラム名がidではない違う名前だった場合便利です。

var Meal = Backbone.Model.extend({ idAttribute: "_id"
});
var cake = new Meal({ _id: 1, name: "Cake" });
alert("Cake id: " + cake.id);

なるほど、データベースとのデータの整合性マッチとして model.id は使える。便利。

model.cid

自動的にすべてのモデルに割り当てられた一意の識別子です。
cidは、モデルがまだサーバーに保存されていない場合に便利です。(cidがそのままサーバーに登録するレコードidとして使える)

model.attributes

modelの内包しているオブジェクトがそのまま返ります。

var a = new Backbone.Model({ hoge1: "hoge1", hoge2: "hoge2"
});
console.log(a.attributes)
// => {hoge1: "hoge1", hoge2: "hoge2"}

attributesによってモデルデータを直接書き換えたりすることもできます。モデルデータをコピーしたい場合は

_.clone(model.attributes);

を使用します。

ただ、モデルデータを直接書き換える場合、プロパティ名にキーを含ませることが可能になってしまうので、それをやってしまうと、”イベントをスペースで区切る”

model.changed

最後に変更された属性がオブジェクト(ハッシュ)で返ります。変更差分のみを保存させるロジックを書くのに便利です。

var a = new Backbone.Model({ hoge1: "hoge1", hoge2: "hoge2"
});
a.set("hoge1", "new");
console.log(a.changed);
// => Object {hoge1: "new"}

model.defaults or model.defaults()

デフォルト値の取得および設定ができます。

var Meal = Backbone.Model.extend({ defaults: { "appetizer": "caesar salad", "entree": "ravioli", "dessert": "cheesecake" }
});
Meal.prototype.defaults["hoge"] = "hoge";
var a = new Meal();
console.log(a.defaults);
// => Object {appetizer: "caesar salad", entree: "ravioli", dessert: "cheesecake", hoge: "hoge"}

デフォルト値は、モデルクラスの参照によって渡されるので、書き換えには注意が必要です。

model.toJSON()

モデルをJSON文字列に変換するためのオブジェクトを返します。このメソッドは、一見JSON文字列を返してくれるように見えますが、JSON.stringifyによって変換するためのオブジェクトが用意されるだけのものです。また、JSON.stringifyを使用する場合、toJSONが暗黙的に呼び出されるようです。(省略できます)

var a = new Backbone.Model({ hoge1: "hoge1", hoge2: "hoge2"
});
console.log(a.toJSON());
console.log(JSON.stringify(a));
// => Object {hoge1: "hoge1", hoge2: "hoge2"}
// => {"hoge1":"hoge1","hoge2":"hoge2"}

model.sync(method, collection, [options])

サーバーへモデルの状態を保持する Backbone.syncを使用するためのオプションです。

model.fetch([options])

Backbone.syncを利用することで、モデルの状態を再設定します。
現在のモデルデータをサーバーから取得したモデルデータと比較して、差異が合った場合やローカル側のモデルデータが空になっている場合はchangeイベントが発生します。

// ajax通信方式
var BookClass = Backbone.Model.extend({ url: "/ajax/hogehoge"
});
var book = new BookClass({ title: "The Rough Riders", author: "Theodore Roosevelt"
});
book.fetch();
// Backbone.sync方式
Backbone.sync = function(method, model) { console.log(method + ": " + JSON.stringify(model));
};
var BookClass = Backbone.Model.extend({ url: "/ajax/hogehoge"
});
var book = new BookClass({ title: "The Rough Riders", author: "Theodore Roosevelt"
});
book.fetch();

model.save([attributes], [options])

Backbone.syncによってモデルデータを保存します。検証に成功した場合、jqueryXHRを返します。
変更したいプロパティのみを記述し、記述されないプロパティはサーバー上から変更されません。
save時には、modelのvalidateメソッドによってデータの検証が行われるので、検証に失敗した場合は保存されません。

モデルがすでにサーバー上にある場合、HTTPメソッドはPUTとなり、ない場合は、POSTとなります。

saveが実行されると、 モデルイベントが、 change、request、syncの順番で発生します。
基本的に、saveは非同期で行われますが、サーバーの処理を待ちたい場合は、optionsに wait: true を指定します。

// 同期処理
Backbone.sync = function(method, model) { console.log(method + ": " + JSON.stringify(model)); model.id = 1;
};
// モデル作成
var book = new Backbone.Model({ title: "The Rough Riders", author: "Theodore Roosevelt"
});
// モデルをサーバーに保存
book.save();
// プロパティを書き換えて保存
book.save({author: "Teddy"});
console.log(book.attributes);
// => create: {"title":"The Rough Riders","author":"Theodore Roosevelt"}
// => update: {"title":"The Rough Riders","author":"Teddy"}
// => Object {title: "The Rough Riders", author: "Teddy"}
検証に失敗した場合や、通信時にエラーがあった場合は error を指定するとエラー通知を行うことができます。
book.save("author", "F.D.R.", {error: function(){ ... }});

model.destroy([options])

Backbone.syncによって、HTTPメソッドのDELETEによってモデルをサーバー上のデータベーステーブルから削除してもらうよう命令することができます。

// 同期処理
Backbone.sync = function(method, model) { console.log(method + ": " + JSON.stringify(model));
};
// モデル作成
var book = new Backbone.Model({ id: 1, title: "The Rough Riders", author: "Theodore Roosevelt"
});
// 削除
book.destroy({success: function(model, response) { console.log("--");
}});
// => delete: {"id":1,"title":"The Rough Riders","author":"Theodore Roosevelt"}

モデルにid属性がない場合は、destroyは失敗しfalseが返ります。

Underscore Methods (6)

Underscoreの基本的なメソッドをモデルに対して使用することができます。_を使わなくても、モデルのメソッドとしてデリゲート(委任)されているのでそのままモデルに対して使用できます。

var a = new Backbone.Model({ hoge1: "hoge1", hoge2: "hoge2", hoge3: "hoge3"
});
// 許可されたキーだけの抽出したハッシュを生成
console.log(a.pick("hoge1", "hoge2"));
// => Object {hoge1: "hoge1", hoge2: "hoge2"}
// 指定したキーを除外したハッシュを生成
console.log(a.omit("hoge1"));
// => Object {hoge2: "hoge2", hoge3: "hoge3"}
// モデルの全てのキー名を取得
console.log(a.keys());
// => ["hoge1", "hoge2", "hoge3"]
// モデルの全ての値を取得
console.log(a.values());
// => ["hoge1", "hoge2", "hoge3"]
// オブジェクトを [key, value] の形式に変換したものを生成します
console.log(a.pairs());
// => [["hoge1","hoge1"],["hoge2","hoge2"],["hoge3","hoge3"]]
// キーとバリューを入れ替えたものを生成します
console.log(a.invert());
// => Object {hoge1: "hoge1", hoge2: "hoge2", hoge3: "hoge3"}

model.validate(attributes, options)

モデルの検証を定義するためのメソッドです。検証ロジックはデフォルトでは未定義(特に何もしない)担っています。
モデルデータによってJavaScriptの任意のコードが実行できるようなものやバックエンドに何らかの影響を及ぼすようなものに対してvalidateの定義が推奨されています。

検証は、saveメソッドを使用する際に呼び出されますが、 model の options に {validate:true} を指定することで、saveする前に呼び出すことができます。また、検証に失敗するとデータベースに変更は保存しません。

var Chapter = Backbone.Model.extend({ validate: function(attrs, options) { if (attrs.end < attrs.start) { return "開始時間より終了時間のほうが早くなっているため保存出来ません"; } }
});
var one = new Chapter({ title : "test"
});
one.on("invalid", function(model, error) { console.error(model.get("title") + ": " + error);
});
one.set("start", 15);
one.set("end", 10, {validate:true});
// => 開始時間より終了時間のほうが早くなっているため保存出来ません
console.log(one.get("end"));
// => undefined
one.save({ start: 15, end: 10
});
// => 開始時間より終了時間のほうが早くなっているため保存出来ません

検証に失敗したモデルに対して invalid イベントが発生されます。

model.validationError

検証に最後に失敗した際のエラー情報が返ります。

one.validationError
// => "開始時間より終了時間のほうが早くなっているため保存出来ません"

model.isValid

検証を直接実行します。

var Chapter = Backbone.Model.extend({ validate: function(attrs, options) { if (attrs.end < attrs.start) { return "開始時間より終了時間のほうが早くなっています"; } }
});
var one = new Chapter({ title : "test"
});
one.set({ start: 15, end: 10
});
if (!one.isValid()) { console.error(one.get("title") + ": " + one.validationError);
}

model.url()

モデルに紐付けられるURLを返します。(サーバー上のリソース)
urlRoot属性がモデルクラスにない場合このメソッドは失敗します。

model.urlRoot or model.urlRoot()

モデルをHTTP経由のリソースの場合対応するURLが必要になってきます。その際に便利なのが。urlRootです。urlRootを定義することでモデルからURLを取得することができるようになります。また、このurlRootはコレクションに対しても有効です。

var Book = Backbone.Model.extend({urlRoot : '/books'});
var solaris = new Book({id: "1083-lem-solaris"});
console.log(solaris.url());

model.parse(response, options)

parseはデフォルトではresponseをそのまま戻り値として返すだけのメソッドです。extendで処理をオーバーライドして使用します。fetchやsaveを実行する際に、取得する前のデータをちょっと加工したりするのに使います。

// 同期処理
Backbone.sync = function(method, model) { console.log(method + ": " + JSON.stringify(model)); // サーバーからデータが送られてきたと仮定 if (method == "read") { // model.parseしてからmodelにセット model.set(model.parse({ data: { "key1": 1, "key2": 2, "key3": 3 } })); }
};
// モデルクラス作成
var modelClass = Backbone.Model.extend({ parse: function (obj) { return obj["data"]; }
});
// モデル作成
var book = new modelClass({ id: 1, title: "The Rough Riders", author: "Theodore Roosevelt"
});
// サーバーからデータを受信
book.fetch();
// テスト
console.log(book.get("key1"));
// => 1

また、モデル作成時のデフォルトのデータ指定時に、オプションに { parse: true } を指定すると、内部でparseが呼び出されデータを加工してからモデルにデータが追加されます。

// モデルクラス作成
var modelClass = Backbone.Model.extend({ parse: function (obj) { return obj["data"]; }
});
// モデル作成
var book = new modelClass({ data: { id: 1, title: "The Rough Riders", author: "Theodore Roosevelt" }
},
{ parse: true
});
// テスト
console.log(book.get("title"));
// => The Rough Riders

model.clone()

モデルのディープコピーを行います。モデルは完全に別々のものとして扱われます。

var a = new Backbone.Model({ hoge1: "hoge1", hoge2: "hoge2", hoge3: "hoge3"
});
var b = a.clone();
console.log(b.attributes);
// => Object {hoge1: "hoge1", hoge2: "hoge2", hoge3: "hoge3"}

model.isNew()

サーバー上に保存されておらず、新しく作られたモデルである場合 true を返します。

var a = new Backbone.Model({ hoge1: "hoge1", hoge2: "hoge2", hoge3: "hoge3"
});
console.log(a.isNew());
// => true

model.hasChanged([attribute])

モデルのプロパティである [attribute] に変更があった場合、 true を返します。

var a = new Backbone.Model({ hoge1: "hoge1", hoge2: "hoge2", hoge3: "hoge3"
});
a.on("change", function () { if (a.hasChanged("hoge1")) { console.log("hoge1に変更がありました"); } if (a.hasChanged("hoge2")) { console.log("hoge2に変更がありました"); } if (a.hasChanged("hoge3")) { console.log("hoge3に変更がありました"); }
});
a.set("hoge1", "1");
// => hoge1に変更がありました

model.changedAttributes([attributes])

modelの変更点を返します。[attributes]にハッシュを指定すると、そのハッシュが返ります。

var a = new Backbone.Model({ hoge1: "hoge1", hoge2: "hoge2", hoge3: "hoge3"
});
a.set("hoge3", "1");
console.log(a.changedAttributes());
// => Object {hoge3: "1"}

model.previous(attribute)

プロパティの値が変更される前の値を取得します。

var bill = new Backbone.Model({ name: "スーパーサイヤ人"
});
bill.on("change:name", function(model, name) { console.log(bill.previous("name")); console.log("↓"); console.log(name);
});
bill.set({name : "スーパーサイヤ人2"});
// => スーパーサイヤ人
// => ↓
// => スーパーサイヤ人2

model.previousAttributes()

モデルの変更前の全ての属性を返します。
前の状態に戻るとき(ロールバック)に便利です。

var bill = new Backbone.Model({ name: "スーパーサイヤ人"
});
bill.on("change", function(model) { console.log(bill.previousAttributes()); console.log("↓"); console.log(bill.attributes);
});
bill.set({name : "スーパーサイヤ人2"});
// => Object {name: "スーパーサイヤ人"}
// => ↓
// => Object {name: "スーパーサイヤ人2"}

Backbone.Collection

やっとモデルが終わりました。覚えること多くて大変です。次に、コレクションを眺めていきます。
コレクションは、名前の通りコレクションで、何をコレクションするかというとモデル。複数のモデルを一つにまとめたものをコレクションと言うらしい。コレクションにも、モデルと同じようにイベントを登録したりすることが可能で、イベントの扱いがもっと簡単になるような概念です。

例えば、今まで人モデルにイベントを登録するには、一人一人にイベントを登録しなければいけませんでしたが、一人一人を一つのコレクションにまとめることによって、一括したイベント管理が行えるようになるということらしい。

Backbone.Collection.extend(properties, [classProperties])

モデルクラス同様、コレクションも独自のコレクションクラスを生成することができます。

collection.model

コレクションクラスには、モデルクラスを含めることが可能で、それを実現するにはcollection.modelをオーバーライドします。

var Book = Backbone.Model.extend({ defaults: { key1: "1", key2: "2" }, hoge: function () { console.log("hoge!"); }
});
var Library = Backbone.Collection.extend({ model: Book
});

また、modelを関数としてオーバライドすると、関数でモデルを生成することもできます。

var Library = Backbone.Collection.extend({ model: function(attrs, options) { if (condition) { return new PublicDocument(attrs, options); } else { return new PrivateDocument(attrs, options); } }
});

constructor / initialize new Collection([models], [options])

Collectionのconstructorによる初期化では、モデルを渡すことができます。

var Book = Backbone.Model.extend({ defaults: { name: "", price: "", author: "", genre: "" }
});
var books = new Backbone.Collection([ [], [], [], [] ], { model: Book
});
console.log(books.models[0].attributes);
// => Object {name: "", price: "", author: "", genre: ""}

collection.models

Collection内部への直接直接アクセスを行います。本来であればgetを使用するべきです。

console.log(books.models);

collection.toJSON()

これもモデルと同じように、JSON文字列にシリアライズ可能なハッシュを返します。
一見toJSONだけで文字列にできるように見えますができません。JSON.stringifyする際に暗黙的に呼ばれるので、あえて明示的に使用する必要はありません。

var collection = new Backbone.Collection([ {name: "Tim", age: 5}, {name: "Ida", age: 26}, {name: "Rob", age: 55}
]);
console.log(JSON.stringify(collection));

collection.sync(method, collection, [options])

サーバーへモデルの状態を保持する Backbone.syncを使用するためのオプションです。

Underscore Methods (28)

Underscoreの基本的なメソッドをコレクションに対して使用することができます。_を使わなくても、コレクションのメソッドとしてデリゲート(委任)されているのでそのままコレクションに対して使用できます。

var books = new Backbone.Collection([ {id:1, title: "aaa", group: "A", price: 1000}, {id:2, title: "bbb", group: "B", price: 1980}, {id:3, title: "ccc", group: "A", price: 980}, {id:4, title: "ddd", group: "B", price: 498}
]);
// モデルを一つづつ反復処理をする
books.each(function (m) { console.log(m); // => a.Model, a.Model, a.Model, a.Model
});
// モデルのプロパティを利用しマッピングを行い配列を返す
books.map(function (m) { return [ m.get("id"), m.get("title") ];
});
// => [[1,"aaa"],[2,"bbb"],[3,"ccc"],[4,"ddd"]]
// モデルの指定したプロパティの数値を足して合計を出す ( nは前回値, mは現在のモデル, i はカウント, ms はモデルの配列, reduce第二パラメータは初期値)
books.reduce(function (n, m, i, ms) { n += m.get("price"); return n;
}, 0);
// => 4458
// モデルを逆から反復処理をし値を取得していく
books.reduceRight(function (n, m, i, ms) { n += m.get("price"); return n;
}, 0);
// => [498, 980, 1980, 1000]
// 条件式がtrueになる全ての値の最初のモデルのみを抽出します
books.find(function (m) { return m.get("group") == "A"
});
// => a.Model
// 条件式がtrueになる全てのモデルを抽出
books.filter(function (m) { return m.get("group") == "A"
});
// => [ a.Model, a.Model ]
// プロパティのキーと値のペアが一致するすべてのモデルの配列を返す
books.where({ group: "B"
});
// => [ a.Model, a.Model ]
// プロパティのキーと値のペアが一致する全てのモデルの最初のモデルのみを抽出します
books.findWhere({ group: "A"
});
// => a.Model
// 条件式でfalseになる全てのモデルを抽出
books.reject(function (m) { return m.get("group") == "A"
});
// => [ a.Model, a.Model ] ※ groupはBが抽出される
// コレクションの全てのモデルの条件式がtrueだった場合最終的にtrueを返す
books.every(function (m) { return m.get("group") == "A"
});
// => false
// コレクションの反復処理中に条件式でひとつでもtrueになった場合、最終的にtrueを返す
books.some(function (m) { return m.get("group") == "A"
});
// => true
// コレクションの中に指定したモデルが含まれているか調べる
books.contains(books.models[0]);
// => true
// 指定された関数によってコレクションを反復処理します。第二パラメータ移行の引数は関数の引数になります
books.invoke(function(c){ c.log(this); return this;
}, console);
// プロパティ値の一覧を指定したキーから抽出する
books.pluck("title")
// => ["aaa", "bbb", "ccc", "ddd"]
// 反復処理の条件式のうちもっとも値の高いモデルを返す
books.max(function (m) { return m.get("price");
});
// 反復処理の条件式のうちもっとも値の低いモデルを返す
books.min(function (m) { return m.get("price");
});
// 値の低い順に並び替える
books.sortBy(function (m) { return m.get("price");
});
// => [ a.Model(498), a.Model(980), a.Model(1000), a.Model(1980) ]
// グループ分けする
books.groupBy(function (m) { return m.get("group");
});
// => Object {A: Array[2], B: Array[2]}
// 値を元にグループ分けします
books. countBy(function (m) { return m.get("group");
});
// => Object {A: 2, B: 2}
// モデルをシャッフルしたものを返します
books.shuffle();
// => [a.Model, a.Model, a.Model, a.Model]
// 配列にして返す
books.toArray();
// => [a.Model, a.Model, a.Model, a.Model]
// モデルの個数を調べる
books.size();
// => 4
// 先頭のモデルを返す
books.first();
// => a.Model
// 最後のモデルを除外した配列を返す
books.initial();
// => [a.Model, a.Model, a.Model]
// 最後の2モデルを除外した配列を返す
books.initial(2);
// => [a.Model, a.Model]
// 最後のモデルを返す
books.last();
// => a.Model
// 先頭のモデルを除外したものを返す
books.rest();
// => [a.Model, a.Model, a.Model]
// 先頭の2モデルを除外したものを返す
books.rest(2);
// => [a.Model, a.Model, a.Model]
// 特定のモデルを除外します
books.without(books.models[0]);
// => [a.Model, a.Model, a.Model]
// 特定のモデルが何番目にあるのか調べる(存在しない場合 -1 が返る)
books.indexOf(books.models[1]);
// => 1
// 特定のモデルが何番目にあるのか調べる。ただし、最後から走査する。(存在しない場合 -1 が返る)
books.lastIndexOf(books.models[1]);
// => 1
// 特定のモデルの特定のプロパティを元にコレクションをソートしたとき、その特定プロパティが何番目かを返す
books.sortedIndex(books.models[1], "price");
// => 4
// コレクションが空か調べる
books.isEmpty();
// => false
// モデルが空か調べる
books.isEmpty(books.models[0]);
// => false
// ラップされたオブジェクトを返します
books.chain();
// => j {_wrapped: Array[4], _chain: true, after: function, all: function, any: function…}

collection.add(models, [options])

コレクションにモデルを追加します。modelsにはそのままインスタンスとして生成済みのモデルを渡すこともできますが、ハッシュをそのまま渡すことで、内部でモデルを生成して追加させることもできます。モデルを追加するとcollectionには add イベントが発生します。

var ships = new Backbone.Collection();
ships.on("add", function(ship) { console.log(ship.get("name")+" さんが追加されました");
});
ships.add([ {name: "佐藤"}, {name: "木村"}, {name: "吉田"}, {name: "吉田"}
]);
// => 佐藤 さんが追加されました
// => 木村 さんが追加されました
// => 吉田 さんが追加されました
// => 吉田 さんが追加されました

collection.remove(models, [options])

指定したモデルをコレクションから削除します。
モデル削除時には、 remove イベントが collection に対して発生します。

var ships = new Backbone.Collection();
ships.on("add", function(ship) { console.log(ship.get("name")+" さんが追加されました");
});
ships.on("remove", function(ship) { console.log(ship.get("name")+" さんが消されました");
});
ships.add([ {name: "佐藤"}, {name: "木村"}, {name: "吉田"}, {name: "吉田"}
]);
// => 佐藤 さんが追加されました
// => 木村 さんが追加されました
// => 吉田 さんが追加されました
// => 吉田 さんが追加されました
ships.remove(ships.models[0]);
// => 佐藤 さんが消されました

collection.reset([models], [options])

コレクションをハッシュを元に作り直します。実質的にリセットです。

var ships = new Backbone.Collection();
ships.add([ {name: "佐藤"}, {name: "木村"}, {name: "吉田"}, {name: "吉田"}
]);
ships.reset(ships.toJSON());

collection.set(models, [options])

新しいコレクションのハッシュをセットします。オプションにdelete: falseを指定すると、既存のモデルは削除されることはありません。merge:false merge:true add:false 等もあるようです。

// 新しいコレクションをセットしなおす
var ships = new Backbone.Collection([], {});
ships.add([ {name: "佐藤"}, {name: "木村"}, {name: "吉田"}, {name: "吉田"}
]);
ships.set([ {name: "田村"}, {name: "後藤"}, {name: "樋口"}, {name: "桑原"}
]);
// => 佐藤, 木村, 吉田, 吉田 削除され 田村, 後藤, 樋口, 桑原が追加される
// 削除せずに追加を行う
var ships = new Backbone.Collection([]);
ships.add([ {name: "佐藤"}, {name: "木村"}, {name: "吉田"}, {name: "吉田"}
]);
ships.set([ {name: "田村"}, {name: "後藤"}, {name: "樋口"}, {name: "桑原"}, {name: "佐藤"}, {name: "木村"}, {name: "吉田"}, {name: "吉田"}
], { remove:false });
// => a.Collection {length: 12, models: Array[12], _byId: Object, on: function, once: function…}

collection.get(id)

コレクションからidまたはcidで指定されたモデルを取得します。

var ships = new Backbone.Collection([], {});
ships.add([ {name: "佐藤"}, {name: "木村"}, {name: "吉田"}, {name: "吉田"}
]);
ships.get("c396");
// => a.Model

collection.at(index)

コレクションで、indexから指定されたモデルを取得します。

ships.at(1);
// => a.Model

collection.push(model, [options])

コレクションの最後にモデルを追加します。

var ships = new Backbone.Collection([], {});
ships.add([ {name: "佐藤"}, {name: "木村"}, {name: "吉田"}, {name: "吉田"}
]);
ships.push({name: "室伏"});

collection.pop([options])

コレクションの最後からモデルを取り出して、コレクションから削除します。

var ships = new Backbone.Collection([], {});
ships.add([ {name: "佐藤"}, {name: "木村"}, {name: "吉田"}, {name: "吉田"}
]);
ships.pop();
// => a.Model(吉田)

collection.unshift(model, [options])

コレクションの先頭にモデルを追加します。戻り値に追加したモデルが返ります。

var ships = new Backbone.Collection([], {});
ships.add([ {name: "佐藤"}, {name: "木村"}, {name: "吉田"}, {name: "吉田"}
]);
ships.unshift({name: "前田"});
// => a.Model(前田)

collection.shift([options])

コレクションの先頭からモデルを取り出して、削除します。

var ships = new Backbone.Collection([], {});
ships.add([ {name: "佐藤"}, {name: "木村"}, {name: "吉田"}, {name: "吉田"}
]);
ships.shift();
// => a.Model(佐藤)

collection.slice(begin, end)

begin番目からend番目までのモデルを切り出したものを返します。(コレクションから削除されるわけではありません)

var ships = new Backbone.Collection([], {});
ships.add([ {name: "佐藤"}, {name: "木村"}, {name: "吉田"}, {name: "吉田"}
]);
ships.slice(2, 3);
// => a.Model(吉田)

collection.length

コレクションの保有しているモデルの数を返します。

var ships = new Backbone.Collection([], {});
ships.add([ {name: "佐藤"}, {name: "木村"}, {name: "吉田"}, {name: "吉田"}
]);
ships.length;

collection.comparator

コンパレータはデフォルトでは定義されていません。このコンパレータをオーバーライドし、コンパレータとして使用するキーを設定すると、そのキーによって自動的にモデルをソートしてくれるようになります。

// モデルクラスとコレクションインスタンスの定義
var Chapter = Backbone.Model;
var chapters = new Backbone.Collection;
// コンパレータとして使用するキーを設定する
chapters.comparator = function(chapter) { return chapter.get("page");
};
// モデルの追加
chapters.add(new Chapter({page: 9, title: "The End"}));
chapters.add(new Chapter({page: 5, title: "The Middle"}));
chapters.add(new Chapter({page: 1, title: "The Beginning"}));
// titleキーの値を抽出
console.log(chapters.pluck('title'));

collection.sort([options])

明示的にこれを呼び出す必要はありません。ソート機能を使いたい場合、コンパレータによる自動ソートの使用をおすすめします。もし自動ソートをやめたい場合は、オプションに、{sort: false}を指定します。

// モデルクラスとコレクションインスタンスの定義
var Chapter = Backbone.Model;
var chapters = new Backbone.Collection;
// ソートやめる
chapters.sort({sort: false});
// コンパレータとして使用するキーを設定する
chapters.comparator = function(chapter) { return chapter.get("page");
};
// => Error: Cannot sort a set without a comparator
// モデルの追加
chapters.add(new Chapter({page: 9, title: "The End"}));
chapters.add(new Chapter({page: 5, title: "The Middle"}));
chapters.add(new Chapter({page: 1, title: "The Beginning"}));
// titleキーの値を抽出
console.log(chapters.pluck('title'));

collection.pluck(attribute)

// コレクション作成
var stooges = new Backbone.Collection([ {name: "Curly"}, {name: "Larry"}, {name: "Moe"}
]);
// name属性の値抽出
stooges.pluck("name");
// => ["Curly", "Larry", "Moe"]

collection.where(attributes)

渡された{key:value}を持つモデルを複数返します。

var friends = new Backbone.Collection([ {name: "Athos", job: "Musketeer"}, {name: "Porthos", job: "Musketeer"}, {name: "Aramis", job: "Musketeer"}, {name: "d'Artagnan", job: "Guard"},
]);
friends.where({job: "Musketeer"});
// => [a.Model, a.Model, a.Model]

collection.findWhere(attributes)

渡された{key:value}を持つモデルで一番最初に見つかったモデルのみを返します。

var friends = new Backbone.Collection([ {name: "Athos", job: "Musketeer"}, {name: "Porthos", job: "Musketeer"}, {name: "Aramis", job: "Musketeer"}, {name: "d'Artagnan", job: "Guard"},
]);
friends.findWhere({job: "Musketeer"});
// => a.Model(Musketeer)

collection.url or collection.url()

サーバー上のコレクションに結びつく独自のURLを設定します。

// プロパティとして指定
var Notes = Backbone.Collection.extend({ url: '/notes'
});
// 関数を使用する場合
var Notes = Backbone.Collection.extend({ url: function() { return this.document.url() + '/notes'; }
});

collection.parse(response, options)

fetchでサーバーからのレスポンスを得る際に、得たデータをコレクションとして取り入れる前に、少し加工してからモデルに取り入れるための処理を行います。

// 同期処理
Backbone.sync = function(method, model) { console.log(method + ": " + JSON.stringify(model)); // read の場合 if (method == "read") { // モデルを追加 (データをサーバーから取得したと仮定) this.add(this.parse({data: {name: "test"}})); }
};
// コレクションクラス作成
var Tweets = Backbone.Collection.extend({ parse: function(response) { console.log("parseが呼び出されました", response); return response["data"]; }
});
// コレクションインスタンス生成
var t = new Tweets([], {parse: true});
// 受信開始
t.fetch();
console.log(t);

urlを使用する場合はこのようにします。

Backbone.sync = function(method, model) { console.log(method + ": " + model.url);
};
var accounts = new Backbone.Collection;
accounts.url = '/accounts';
accounts.fetch();

何も削除せずに、追加と更新のみを行う処理をしたい場合はこのようにします。

collection.fetch({remove: false});

また、ページネーションを表すのに

documents.fetch({data: {page: 3}});

が使用できるそうですが、どう使えばいいのか不明。。

collection.create(attributes, [options])

新しくモデルを作成すると同時に、データベース上にも新しく作成したモデルデータを登録します。
なるほど便利。

Backbone.sync = function(method, model) { console.log(method + ": " + model.url);
};
var Book = Backbone.Model.extend({ url: "/document",
});
var Library = Backbone.Collection.extend({ url: "/document", model: Book
});
var nypl = new Library;
var othello = nypl.create({ title: "Othello", author: "William Shakespeare"
});

Backbone.Router

長い…長すぎる。上から下へ眺めるだけなのに何時間だったのだろうか。サンプルスクリプトをちまちま書いてたからか、今一番見たくないのはスクロールバー。

それはさておき、いよいよルーティングです。ブラウザのページを動的に切り替えるためのロジックなのでかなり期待大!です。

本題へ移ります。
ルーティングというのはブラウザのページ切り替えのURLのことです。ページを切り替えるとURLが代わり、URLひとつひとつに割り当てられているページが違う。当たり前のことではありますが、このURLはこれ、このURLはこっちと、振り分けていくことをルーティングと言います。

これまでのWebサービスは、ページを切り替える度に画面がフラッシュし、DNS解決やHTTPリクエストを送ったりとページ切り替えに少なからずストレスを感じずにはいられませんでした。Twitterがhashchangeによって動的なページ切り替えを実現はしましたが、URLが「https://hogehoge.com/#!hoge」というようにhash=#を使わなければならず、URLとしては少し見栄えが悪いものでした。

そこで、History API というものが登場し、HTTPリクエストを発生させなくても、URLを書き換えられるようになりました。URLを書き換えられるということは、URLを書き換えるタイミングで、HTMLもJavaScriptを使って書き換えるようにすれば、クライアントにとってあたかもページが高速に切り替わった印象を与えることができるようになります。

それに、ajaxの技術を組み合わせることで、リッチなWebアプリケーションの開発を行うことができます。そんなことを実現させてくれるのが、Backbone.Routerです。

Backbone.Routerを使用するには、 ページのロードが完了したら
Backbone.history.start();
または
Backbone.history.start({pushState: true});
によって、ルーティング初期URLとするために呼び出してください。

Backbone.Router.extend(properties, [classProperties])

特定のURLフラグメントが一致したときに実行されるアクションを定義した
ルーティングクラスを作成します。
先頭に / は必要ありません。

// カスタムルータクラスの生成
var WorkspaceRouter = Backbone.Router.extend({ // ルーティング routes: { "help": "help", // #help "search/:query": "search", // #search/kiwis "search/:query/p:page": "search" // #search/kiwis/p7 }, //ヘルプアクション help: function() { console.log("ヘルプアクションです"); }, //サーチアクション search: function(query, page) { console.log("サーチアクションです"); }
});

router.routes

このハッシュオブジェクトによってルーティングを行います。
:param の部分がパラメータになり、 *splat の部分が /hoge/hoge/hoge/ と構造を表すことができます。
ルーティング時、あってもなくても良いような指定を行う場合は (/:optional) と書きます。

例えば
“search/:query/p:page”

search/obama/p2
にアクセスすることができ、:query にはobama、 :page には 2が入ります。

“file/*path”

file/nested/folder/file.txt
としてアクセスできます。

具体的には、ルーティングは

routes: { "help/:page": "help", "download/*path": "download", "folder/:name": "openFolder", "folder/:name-:mode": "openFolder"
}

のように書くことができ、

router.on("route:help", function(page) { ...
});

としてアクションを登録することもできます。

new Router([options])

オプションにハッシュを渡すことで初期化メソッドを実行することができます。

initialize: function(options) { // Matches #page/10, passing "10" this.route("page/:number", "page", function(number){ ... }); // Matches /117-a/b/c/open, passing "117-a/b/c" to this.open this.route(/^(.*?)\/open$/, "open");
},
open: function(id) { ... }

router.route(route, name, [callback])

手動でルーティングのルートを作成します。 router.routes による定義やアクションメソッドの定義はできません。

new Backbone.Router({ // 初期化 initialize: function(options) { this.route("page/:number", "page", function(number){}); this.route(/^(.*?)\/open$/, "open"); }, open: function(id) { console.log("openがよばれました"); }
});

router.navigate(fragment, [options])

URLの変更とアクションの実行を行い、ページ遷移の挙動を振る舞います。

// カスタムルータクラスの生成
var RouterClass = Backbone.Router.extend({ // ルーティング routes: { "help": "help", // #help "search/:query": "search", // #search/kiwis "search/:query/p:page": "search" // #search/kiwis/p7 }, //ヘルプアクション help: function() { console.log("ヘルプアクションです"); }, //サーチアクション search: function(query, page) { console.log("サーチアクションです"); }
});
// ルーティングインスタンス生成
var router = new RouterClass();
// History API 開始
Backbone.history.start({pushState: true});
// ナビゲーション: helpアクション実行
router.navigate("help", {trigger: true});
// ナビゲーション: searchアクション実行
router.navigate("search", {trigger: true, replace: true});

Backbone.history

hashchange または pushState によるコールバック関数としての機能を果たします。
Hisory APIに対応していないブラウザの場合はhashchangeによるページ遷移を行います。

Backbone.history.start([options])

ルーティングが正しく設定してある場合において、 hashchangeイベントを監視するようにするには、
Backbone.history.start()
を実行します。

HTML5のpushStateを利用したい場合は、
Backbone.history.start({pushState: true});
を指定します。

ルートURLを指定するには、
Backbone.history.start({pushState: true, root: “/public/search/”})
とします。

Backbone.history.start() は 定義されたルートURLが一致するとtrueを返し、一致しない場合はfalseを返します。

hashchangeの場合、iframeに依存しているので、DOMの生成が完了しおわってから実行するようにしてください。

Backbone.sync

Backbone.syncがサーバーからデータを読み込んだり、保存したりする関数です。デフォルトでは、RESTfulなJQueryなAjaxが呼び出されるようになっていますが、この関数をオーバーライドすることによって、WebSocketだったり、localStorageだったりと、データストアを自由に切り替えることができます。

オーバーライドするには、

// 同期処理
Backbone.sync = function(method, model) { console.log(method + ": " + JSON.stringify(model));
};

とすることで、自由に処理を設定できます。console.logにて、どのようなmethodやmodelが呼ばれているのかを確認することができます。modelには model.urlが割り当てられていることがあるので、model.urlからURLを取得し、そのURLに対して通信を行うこともできます。

  • method – the CRUD method (“create”, “read”, “update”, or “delete”)
  • model – the model to be saved (or collection to be read)
  • options – success and error callbacks, and all other jQuery request options

また、 モデルやコレクションがサーバーに通信を行うとき、 それぞれに対して、 request イベントが発生します。

RESTfulな実装の場合、各種HTTPメソッドはこのような割り当てになっています。

    create → POST /collection
    read → GET /collection[/id]
    update → PUT /collection/id
    delete → DELETE /collection/id

Backbone.ajax = function(request) { … };

ajaxをカスタマイズします。

Backbone.emulateHTTP = true

REST/HTTPをサポートしていない従来のWebサーバーを利用している場合、 Backbone.emulateHTTP を trueにすると、 X-HTTP-Method-Override header を付与し、 HTTPメソッドは _method に割り当てられます。

Backbone.emulateHTTP = true;
model.save(); // POST to "/collection/id", with "_method=PUT" + header.

Backbone.emulateJSON = true

Backbone.emulateJSON を trueにすると、
JSONとしてエンコードのリクエストを扱うことができない従来のWebサーバで application/x-www-form-urlencoded として取得するようになります。

Backbone.View

モデルに登録したイベントのchangeイベントを監視して、モデルに変更があるタイミングでテンプレートエンジンを利用して画面を再描画します。

Backbone.View.extend(properties, [classProperties])

カスタムビュークラスの作成を開始します。

// ビュークラスを作成
var DocumentRow = Backbone.View.extend({ tagName: "li", className: "document-row", events: { "click .icon": "open", "click .button.edit": "openEditDialog", "click .button.delete": "destroy" }, initialize: function() { this.listenTo(this.model, "change", this.render); } render: function() { }
});

eventsには、

  • tagName
  • id
  • className
  • el
  • event

が定義できます。

constructor / initialize

ビューインスタンスの生成を行います。

// ビュークラスを作成
var DocumentRow = Backbone.View.extend({ tagName: "li", className: "document-row", events: { "click .icon": "open", "click .button.edit": "openEditDialog", "click .button.delete": "destroy" }, initialize: function() { this.listenTo(this.model, "change", this.render); } render: function() { }
});
// モデル作成
var model = new Backbone.Model({ id: 1 name: "山田"
});
// ビューインスタンス生成
new DocumentRow({ model: model, id: "document-row-" + model.id
});

view.el

ここに生成済みのdomが格納されます。

var ItemView = Backbone.View.extend({ tagName: 'li'
});
var BodyView = Backbone.View.extend({ el: 'body'
});
var item = new ItemView();
var body = new BodyView();
console.log(item.el + ' ' + body.el);

view.$el

DOM要素がjQueryでラッピングされたものです。

view.$el.show();
listView.$el.append(itemView.el);

view.setElement(element)

別のDOM要素にViewを適用したい場合に使用します。

view.attributes

view.elの属性のハッシュオブジェクトらしいが、undefinedしか帰ってこないため不明。

view.$(selector)

view.$で jQueryを利用することができます。

ui.Chapter = Backbone.View.extend({ serialize : function() { return { title: this.$(".title").text(), start: this.$(".start-page").text(), end: this.$(".end-page").text() }; }
});

view.render()

デフォルトでは何もしませんが、this.elによって生成されたHTMLとテンプレートとモデルデータをレンダリングするようにこの関数をオーバーライドします。
レンダリング終了時にこの関数を呼び出すと良いです。

var Bookmark = Backbone.View.extend({ template: _.template(…), render: function() { this.$el.html(this.template(this.model.attributes)); return this; }
});

ここではアンダースコアのテンプレートエンジンを使用していますが、 Mustache.js や Haml-js など様々なテンプレートエンジンがあるので、使ってみることをおすすめします。

view.remove()

ビューとビューの監視イベントを削除します。

delegateEvents([events])

var DocumentView = Backbone.View.extend({ events: { "dblclick" : "open", "click .icon.doc" : "select", "contextmenu .icon.doc" : "showMenu", "click .show_notes" : "toggleNotes", "click .title .lock" : "editAccessLevel", "mouseover .title .date" : "showTooltip" }, render: function() { this.$el.html(this.template(this.model.attributes)); return this; }, open: function() { window.open(this.model.get("viewer_url")); }, select: function() { this.model.set({selected: true}); }, ...
});

undelegateEvents

ビューに委任されている全てのイベントを削除します。

サンプルスクリプト

よくわからなかったのでサンプルスクリプトを書いてみました。

// ビュークラスを作成
var DocumentView = Backbone.View.extend({ // tenplate template: _.template('<div><%= name %><div class="icon">開く</div><div class="button edit">編集</div><div class="button delete">削除</div></div>'), // イベント events: { "click .icon": "open", "click .button.edit": "openEditDialog", "click .button.delete": "destroy" }, // 初期化 initialize: function() { console.log(this.model, "を監視します"); this.listenTo(this.model, "change", this.render); this.render(); }, // 描画 render: function() { console.log("描画を行います"); this.$el.html(this.template(this.model.attributes)); $(document.body).html(""); $(document.body).append(this.$el); }, open: function () { alert("open"); }, openEditDialog: function () { alert("openEditDialog"); }, destroy: function () { alert("destroy"); }
});
// モデル作成
var model = new Backbone.Model({ id: 1, name: "山田"
});
// ビューインスタンス生成
var view = new DocumentView({ model: model, id: "document-row-" + model.id
});

モデルの変更によって動的にビューが書き換わっているはずなのに、イベントが死なない…
すごい…
また、サンプル中では、テンプレートを一つしか使ってないので、これをうまくいろんなテンプレートを使えるようにオブザーバを工夫する必要があります。

Utility

var backbone = Backbone.noConflict();

既存のbackboneオブジェクトを破壊せずに、もう一つbackboneを作成して使用します。

var localBackbone = Backbone.noConflict();
var model = localBackbone.Model.extend(...);

Backbone.$ = $;

jQueryのエイリアスです。

最後に

ようやく下まで眺め終わりました。眺めるレベルじゃない気もしますが、とりあえず….眠いです。おやすみなさい。

テストとjQueryのeachっぽい何かを書いてみた

jQueryは便利だけど、わざわざjQueryをロードせずともできるだけ同じようなことがしたいというときに便利。jQueryほどセレクタの指定ができないが、forEachでぐるぐると実行してくれるので便利。おまけにテストも書いてみました。

使う場面としたら、Chrome拡張やブックマークレットくらいでしょうか。

<script>
/* スクリプト
-------------------------------------------------------------------------------*/
var tags, classes, id;
(function(){ var a = function(o){return Array.prototype.slice.apply(o)}, d = document, g = function(t){return d.getElementsByTagName(t)}; tags = function(t, f, h) { var c = 0; a((h == undefined ? g(t) : h.getElementsByTagName(t))).forEach(function(h) { f.call(h, c); c++; }); }; classes = function(l, f, h) { var o = 0; a((h == undefined ? g("*") : h.getElementsByTagName("*"))).forEach(function(h) { var c = h.className.split(" "); for (var i = 0; i < c.length; i++) { if (c[i] == l) { f.call(h, o); o++; break; } }; }); }; id = function(i, f) { f.call(d.getElementById(i)); };
}).call(this);
/* テストスクリプト
-------------------------------------------------------------------------------*/
var testResult = "";
var print = function(_print) { testResult += _print;
};
var testData = function() { document.write(new Array(100).join("-")+"<br/>"); document.write("テストデータ<br/>"); document.write(new Array(100).join("-")+"<br/><br/>");
};
var testLog = function(){ document.write("<br/><br/>"); document.write(new Array(100).join("-")+"<br/>"); document.write("テスト結果<br/>"); document.write(new Array(100).join("-")+"<br/>"); document.write(testResult);
};
var suite = function(_suite) { print("<br/>■ "+_suite+"<br/>");
};
var test = function(_name){ var result = 1; for (var i = 1; i < arguments.length; i++) { if (typeof(arguments[i]) == "function") { try { result = result && arguments[i](); } catch (_error) { result = 0; throw (_error); } break; } }; print((result ? "○" : "×")+" "); print(_name); print("<br/>");
};
testData();
</script>
<!-- テスト用データ
=============================================================================== -->
<meta charset="UTF-8">
<div>hoge</div>
<div class="hoge">hoge</div>
<div>hoge</div>
<div id="fufu">hoge</div>
<div class="hage hoge test"> <span>aaa</span> <span>aaa</span> <span>aaa</span> <span>aaa</span>
</div>
<div class="test"> <span>bbb</span> <span>bbb</span> <span>bbb</span> <span class="test">bbb</span> <span class="test">bbb</span> <span>bbb</span> <span>bbb</span>
</div>
<script>
/* テスト実行
-------------------------------------------------------------------------------*/
suite("tags");
test("ちゃんとtagsが実行された", function(){ var count = 0; tags("div", function(){ count++; }); return count == 6;
});
test("インデックスが一致している", function(){ var count = 0; var flag = 1; tags("div", function(i){ count++; if (count == i) { flag = 0; } }); return flag;
});
test("二階層目のspanを取得できる", function(){ var count = 0; tags("div", function(i){ if (i == 5) { tags("span", function() { count++; }, this); } }); return count == 7;
});
suite("classes");
test("classesが実行できた", function(){ var count = 0; classes("hoge", function(){ count++; }); return count == 2;
});
test("インデックスが一致している", function(){ var count = 0; var flag = 1; classes("hoge", function(i){ count++; if (count == i) { flag = 0; } }); return flag;
});
test("カウンタが数値である", function(){ var flag = 1; classes("hoge", function(i){ if (typeof(i) != "number") { flag = 0; } }); return flag;
});
test("二階層目のclassを取得できている", function(){ var count = 0; classes("test", function(i){ if (i == 1) { console.log(this); classes("test", function() { console.log(this); count++; }, this); } }); return count == 2;
});
suite("id");
test("idが実行できた", function(){ var count = 0; id("fufu", function(){ count++; }); return count == 1;
});
/* テスト結果出力
-------------------------------------------------------------------------------*/
testLog();
</script>

こんなかんじでブックマークレットも作れる

var tags,classes,id;(function(){var a=function(o){return Array.prototype.slice.apply(o)},d=document,g=function(t){return d.getElementsByTagName(t)};tags=function(t,f,h){var c=0;a(h==undefined?g(t):h.getElementsByTagName(t)).forEach(function(h){f.call(h,c);c++})};classes=function(l,f,h){var o=0;a(h==undefined?g("*"):h.getElementsByTagName("*")).forEach(function(h){var c=h.className.split(" ");for(var i=0;i<c.length;i++)if(c[i]==l){f.call(h,o);o++;break}})};id=function(i,f){f.call(d.getElementById(i))}}).call(this);
tags("td",function(){var that=this;tags("a",function(){if(this.innerHTML.indexOf("\u4ed9\u53f0")+1)that.style.background="orange"},this)});

HTML5 History API を徹底的に試してみる

History APIとは

History APIはHTML5の機能のひとつで、ブラウザの戻る進むボタンのイベントを取得してページの内容を動的に変えることができるものです。
なかなか便利な機能ではあったのですが、ちょっとつまずきポイントが多く、癖もかなりあるっぽいので徹底的に試してみようと思います。

スタック

ブラウザの履歴の一つ一つの記録をスタックといい、一つ履歴が増える度にスタックが増える。そしてHistoryAPIを使うとこのスタックをページ推移を行わなくても増やすことができます。

HistoryAPIによってスタックが増えた場合は、ブラウザの戻るボタンを押してもページ遷移が発生せず、何も起きない。それがHistory API。

スタックの追加

スタックを追加するには、このようにやります。

history.pushState("hoge", null, "/hoge");

これで、履歴が一つ分増えます。これを実行すると、ブラウザの戻るボタンを一度押しても何も起きません。(ページ推移が発生しません)
しかし、二回ボタンを押すといつもどおりページ推移が発生します。

しかし、このhistory.pushStateを使う前に必ず

history.replaceState("index");

とします。

これをすることでGoogle Developer Tools もしくは Firebug上で

history

と入力し、エンターを押すと、historyオブジェクトが見えるので、historyオブジェクトを展開し、stateプロパティを確認すると、そこがindexになっていることが確認できます。

スクリーンショット 2013-01-18 0.11.15

必ずこれをやっておかないとめんどくさいことになります。

ブラウザの戻る・進むイベントを監視する

jQueryを使っていますが、jQueryの場合はこのように書くことでイベントが実装できます。

$(window).on("popstate", function(_event){ var state = _event.originalEvent.state; console.log("_event", _event); console.log("state", state);
});

これをソースコード内に記述して、

history.pushState("hoge", null, "/hoge");

を実行して、ブラウザの戻るボタンをクリックします。

Google Developper Tools または Firebugのコンソールで確認すると、 state がindexになっていることがわかります。

スクリーンショット 2013-01-18 0.16.45

これがハマりポイントでした。最初に history.replaceState(“index”); をしたことで、index と表示されたわけですが、これをやらないとここが、 null になってしまいます。

最初このstateの値は hoge になるものだと思っていたのですが、実はそうではなく、戻るページに設定されているstateらしいです。

構造的には「index -> hoge」 という構造になっています。

このあたりがややこしいので注意が必要です。

Tips

History APIは基本的に以上の仕組みさえ理解すればそれだけですんなりと使えますが、他にも覚えておくと便利な点をあげておきます。

history.state

現在のstateを確認できます。indexなのかhogeなのか。

history.back()

これを実行するとブラウザを戻ります。ページ内に戻るボタンを設置する場合はこのメソッドを使用します。ただ、対応していないブラウザもあるので機能を切り分ける必要があります。

history.forward()

これを実行するとブラウザを進みます

history.go(num)

指定した履歴へジャンプします。

history.length

現在の履歴の数を表示します

location.pathname

現在のドメインを覗いたディレクトリパスを取得します

location.search

現在のURLの?の後ろのパラメータを取得します

Javascriptテストフレームワーク Jasmineを試す

日頃からJavascriptで開発をしているのにも関わらずあまりテストを書かないので、ここは本格的にテストを書こうと調べてみました。JavascriptのテストフレームワークといったらJsUnitなのかなーと思っていたが、調べてみると結構いろんな種類のテストフレームワークがあったりして、その中で得に人気なのかどうやらJasmineらしい。

Jasmine ~ JavaScript Test フレームワーク より引用:
今回は, JavaScript のテストを行うためのフレームワークJasmine の紹介です。
JavaScript のテストといえば, JSUnit が有名です。
JSUnit は, JUnit とに似たような, Matcher が利用できたりしてわかりやすいのですが,
開発やメンテナンスがストップしており, またWebプロジェクトに組み込まないと利用できないことが
ちょっと残念です。

JUnit のページでも紹介があるように, 今後は Jasmine というフレームワークを開発していくようです。

なるほど、JsUnitはかつて人気だったテストフレームワークだったけども開発ストップしてて古くなっており、今はJasmineということなのか。

とりあえずやってみた

しかし、初めて触るライブラリというのはどうにもこうにも敷居が高い。少しずつ調べてみる。なんにせよまずはインストールだ。

公式ページ: http://pivotal.github.com/jasmine/
公式ページ(の翻訳): http://mitsuruog.github.com/jasmine/
ダウンロード: https://github.com/pivotal/jasmine/downloads

ダウンロードしたファイルは、jasmine-standalone-1.3.1.zip です。

二種類の使い方

Jasmineは使い方が二種類あるそうです。

  • standalone
  • rubygems + rake

standaloneはRubyを使わずにJavascriptだけで使える環境、 rubygems + rake はテストを自動化させたい場合はこちらを選ぶ。ただ、standaloneでもPhantomJSを使えば自動化できるようです。

ここではstandaloneについて実験

rubygemsを使った方法だと、テストを実行させるためのHTMLを自動生成してくれて大変便利らしいのですが、ここでは導入としてstandaloneを使ったやりかたについて試してみたいとおもいます。

ディレクトリの構造はこのようになっています。

.
├── SpecRunner.html
├── lib
│   └── jasmine-1.3.1
│   ├── MIT.LICENSE
│   ├── jasmine-html.js
│   ├── jasmine.css
│   └── jasmine.js
├── spec
│   ├── PlayerSpec.js
│   └── SpecHelper.js
└── src ├── Player.js └── Song.js

これをWebサーバーにあげて、SpecRunner.html を開いてみます。

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

なるほど、わからん。

見た感じ、上の5つの丸がテストの成功か失敗かを表し、下段の黒字がテストスイート(カテゴリ分け)、緑の文字がテストケースという具合のようです。

Specの意味

「仕様」 = テストケース

Suiteの意味

「一式(分類)」 = テストスイート

SpecRunner.htmlの中身を見る

SpecRunner.htmlの中身を見てみます。

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head> <title>Jasmine Spec Runner</title> <link rel="shortcut icon" type="image/png" href="lib/jasmine-1.3.1/jasmine_favicon.png"> <link rel="stylesheet" type="text/css" href="lib/jasmine-1.3.1/jasmine.css"> <script type="text/javascript" src="lib/jasmine-1.3.1/jasmine.js"></script> <script type="text/javascript" src="lib/jasmine-1.3.1/jasmine-html.js"></script> <!-- include source files here... --> <script type="text/javascript" src="src/Player.js"></script> <script type="text/javascript" src="src/Song.js"></script> <!-- include spec files here... --> <script type="text/javascript" src="spec/SpecHelper.js"></script> <script type="text/javascript" src="spec/PlayerSpec.js"></script> <script type="text/javascript"> (function() { var jasmineEnv = jasmine.getEnv(); jasmineEnv.updateInterval = 1000; var htmlReporter = new jasmine.HtmlReporter(); jasmineEnv.addReporter(htmlReporter); jasmineEnv.specFilter = function(spec) { return htmlReporter.specFilter(spec); }; var currentWindowOnload = window.onload; window.onload = function() { if (currentWindowOnload) { currentWindowOnload(); } execJasmine(); }; function execJasmine() { jasmineEnv.execute(); } })(); </script>
</head>
<body>
</body>
</html>

よくわからなかったのでコメントを振ってみる

よくわからなかったのでコメントを振ってみます。

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head> <title>Jasmine Spec Runner</title> <!-- ショートカットアイコン --> <link rel="shortcut icon" type="image/png" href="lib/jasmine-1.3.1/jasmine_favicon.png"> <!-- スタイルシート --> <link rel="stylesheet" type="text/css" href="lib/jasmine-1.3.1/jasmine.css"> <!-- Jasmineの読み込み --> <script type="text/javascript" src="lib/jasmine-1.3.1/jasmine.js"></script> <!-- Jasmineの読み込み --> <script type="text/javascript" src="lib/jasmine-1.3.1/jasmine-html.js"></script> <!-- テストを行う対象ファイルを読み込み --> <script type="text/javascript" src="src/Player.js"></script> <script type="text/javascript" src="src/Song.js"></script> <!-- 独自定義のマッチャを読み込み --> <script type="text/javascript" src="spec/SpecHelper.js"></script> <!-- テストケースファイルを読み込み --> <script type="text/javascript" src="spec/PlayerSpec.js"></script> <script type="text/javascript"> (function() { // Jasmineの環境設定オブジェクトを読み込み var jasmineEnv = jasmine.getEnv(); // アップデートの間隔を1秒に設定? jasmineEnv.updateInterval = 1000; // テスト結果を取得するためのオブジェクトを取得 var htmlReporter = new jasmine.HtmlReporter(); // 不明・・・ jasmineEnv.addReporter(htmlReporter); // 個別のテストスイートやテストケースをクリックすることで、テストスイートの一部分のみを実行して結果を取得する jasmineEnv.specFilter = function(spec) { return htmlReporter.specFilter(spec); }; // ウィンドウをリロードするオブジェクト生成 var currentWindowOnload = window.onload; window.onload = function() { if (currentWindowOnload) { currentWindowOnload(); } // テスト実行 execJasmine(); }; // テスト実行関数の定義 function execJasmine() { jasmineEnv.execute(); } })(); </script>
</head>
<body>
</body>
</html>

このファイルは、テストを行いたいJavascriptを読み込み、それに対してテストを定義し実行するというもののようです。しかし、「jasmineEnv.updateInterval = 1000;」という行が気になった。これはもしかして1秒ごとにテストを実行するということなのだろうか。もしそうなら、このHTMLファイルを開きっぱなしで常にテスト結果を確認できるということなのでとても嬉しいのだけれど・・。

バグらせてみる

まだなんだか感覚がつかめないので、テスト対象のファイルをバグらせてみることにします。

Player.jsをバグらせる。

function Player() {
}
Player.prototype.play = function(song) { this.currentlyPlayingSong = song; this.isPlaying = true;
};
Player.prototype.pause = function() { this.isPlaying = false;
};
Player.prototype.resume = function() { if (this.isPlaying) { throw new Error("song is already playing"); } this.isPlaying = true;
};
Player.prototype.makeFavorite = function() { this.currentlyPlayingSong.persistFavoriteStatus(true);
};

中身はとてもシンプルで、本当にプレイヤー機能が入っているのかと思えばそんなことはなくて、模倣したオブジェクトのようです。

コメントを振ってみます。

function Player() {
}
// 再生する
Player.prototype.play = function(song) { this.currentlyPlayingSong = song; this.isPlaying = true;
};
// ポーズする
Player.prototype.pause = function() { this.isPlaying = false;
};
// 途中から再生する
Player.prototype.resume = function() { if (this.isPlaying) { throw new Error("song is already playing"); } this.isPlaying = true;
};
// お気に入りに登録
Player.prototype.makeFavorite = function() { this.currentlyPlayingSong.persistFavoriteStatus(true);
};

お気に入りに登録のコメントが合っているのか不安ですが、これの途中から再生するをバグらせてみます。

...
// 途中から再生する
Player.prototype.resume = function() { if (this.isPlaying) { throw new Error("song is already "); } this.isPlaying = true;
};
...

「song is already playing」からplaying を消してみました。これでいったいなにが起きるか・・・。
先ほどの SpecRunner.html を開いて確認してみます。

バグがある場合のテスト結果

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

おお! なんか出ました!エラーがでました!これはエラー詳細ページで、全体のテストケース一覧を見るには「5 specs」をクリックします。

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

エラーがあるテストケースは赤字になっています。
また、自動的にリロードしてくれるのか期待していたのですが、自動リロード機能はないようなので、ブラウザの自動リロード機能を使って常に監視しておくといいです。

テストのほうを弄る

テストケースが定義されているファイルをいじってみます。弄るファイルはspecフォルダにある「PlayerSpec.js」です。

中身はこんなかんじ

describe("Player", function() { var player; var song; beforeEach(function() { player = new Player(); song = new Song(); }); it("should be able to play a Song", function() { player.play(song); expect(player.currentlyPlayingSong).toEqual(song); //demonstrates use of custom matcher expect(player).toBePlaying(song); }); describe("when song has been paused", function() { beforeEach(function() { player.play(song); player.pause(); }); it("should indicate that the song is currently paused", function() { expect(player.isPlaying).toBeFalsy(); // demonstrates use of 'not' with a custom matcher expect(player).not.toBePlaying(song); }); it("should be possible to resume", function() { player.resume(); expect(player.isPlaying).toBeTruthy(); expect(player.currentlyPlayingSong).toEqual(song); }); }); // demonstrates use of spies to intercept and test method calls it("tells the current song if the user has made it a favorite", function() { spyOn(song, 'persistFavoriteStatus'); player.play(song); player.makeFavorite(); expect(song.persistFavoriteStatus).toHaveBeenCalledWith(true); }); //demonstrates use of expected exceptions describe("#resume", function() { it("should throw an exception if song is already playing", function() { player.play(song); expect(function() { player.resume(); }).toThrow("song is already playing"); }); });
});

うおお..よくわからない…

RSpecというruby界隈の方にはお馴染みの書き方のようです。そしてこの書き方がテストをやる上ですごいやりやすいんだとか。
また同じようにコメントを振ってみます。

// テストスイート定義
describe("プレイヤー", function() { // テストを行うJavascriptのオブジェクトを読みこませる変数の宣言 var player; var song; // テストを開始する準備を行う beforeEach(function() { // テスト対象となるオブジェクトの読み込み player = new Player(); song = new Song(); }); // テストケースを定義 it("テストケース名", 無名関数) it("曲を再生することができる", function() { // 音楽を再生 player.play(song); // player.currentlyPlayingSong が song と同値であることを期待する expect(player.currentlyPlayingSong).toEqual(song); // プレイヤーが再生中であることを期待する -> SpecHelper.js に定義されています expect(player).toBePlaying(song); }); // テストスイート定義(階層になっていて見やすい) describe("曲が一時停止されたときの挙動", function() { // テストを開始する準備を行う beforeEach(function() { // プレイヤーを再生 player.play(song); // プレイヤーをポーズ player.pause(); }); // テストケース定義 it("曲が一時停止しているか確認する", function() { // player.isPlaying が false になっている expect(player.isPlaying).toBeFalsy(); // プレイヤーが再生されているマッチャに対しnotメソッドによって評価が逆になり、プレイヤーは音楽を再生していない となる expect(player).not.toBePlaying(song); }); it("途中から再生可能かどうか", function() { // プレイヤーをレジュームする player.resume(); // player.isPlaying は true になっている expect(player.isPlaying).toBeTruthy(); // player.currentlyPlayingSong は song と 同じ値であることを期待する expect(player.currentlyPlayingSong).toEqual(song); }); }); // テストケース定義 it("ユーザーがお気に入りにしていた場合、その曲を教える", function() { // オブジェクトのメソッドの呼び出しをスパイを使って監視 spyOn(song, 'persistFavoriteStatus'); // プレイヤーを再生 player.play(song); // プレイヤーがお気に入りに設定(内部で this.currentlyPlayingSong.persistFavoriteStatus(true); が実行されます ) player.makeFavorite(); // song.persistFavoriteStatusメソッド は 引数に true を伴って実行されたか expect(song.persistFavoriteStatus).toHaveBeenCalledWith(true); }); // テストスイート定義 describe("#レジューム", function() { it("曲が既に再生されている場合に例外をスルーする", function() { // プレイヤーを再生 player.play(song); // プレイヤーをレジュームしてその例外が同じものであることを期待する -> player.resume(); では throw new Error("song is already"); として例外が送出されている。 expect(function() { // レジューム player.resume(); }).toThrow("song is already playing"); }); });
});

頭を使いすぎた・・・。だいたいこんなかんじで合っているとおもいます。
流れはコメントの通りで、expect の期待が外れると、そのテストケースは「失敗」となるようです。describeによるテストスイートのカテゴリ分けや階層化もなかなかおもしろいと思いました。

そして、とくに気になった点を2つあげると、独自定義マッチャと、スパイ機能です。これについては次に書きます。そしてその前に、マッチャってなんだ・・・・・。

マッチャ

マッチャとは、「AはBであることを期待する」というように正しいかどうかを評価するためのものです。
マッチャはいくつかの種類があります。

expect(x).toEqual(y);xとyが等しいことを期待する
expect(x).toBe(y);xとyが同じオブジェクトであることを期待する
expect(x).toMatch(pattern);文字列または正規表現パターンでxと比較し、一致することを期待する
expect(x).toBeDefined();xがundefinedではない場合ことを期待する
expect(x).toBeUndefined();xがundefinedであることを期待する
expect(x).toBeNull();xがnullであることを期待する
expect(x).toBeTruthy();xがtrueであることを期待する
expect(x).toBeFalsy();xがfalseであることを期待する
expect(x).toContain(y);配列化か文字列であるxに対して、yが含まれていることを期待する
expect(x).toBeLessThan(y);xがy未満であることを期待する
expect(x).toBeGreaterThan(y);xがyよりも大きいことを期待する
expect(function(){fn();}).toThrow(e);無名関数が実行された時に関数fnが例外eを投げることを期待する
.not.(matcher)(matcher)に他のマッチャを指定することでそのマッチャを逆に評価します(trueをfalseに falseをtrueに)

独自マッチャの定義

マッチャは予め定められたものだけではなく、「SpecHelper.js」によって独自に定義することもできます。

// テストを実行する準備をする
beforeEach(function() { // マッチャの追加 this.addMatchers({ //toBePlayingを追加 toBePlaying: function(expectedSong) { // this.actual は expect(player).toBePlaying(song); の player の部分 var player = this.actual; // 評価 return player.currentlyPlayingSong === expectedSong && player.isPlaying; } });
});

スパイ

次はスパイを見ていきます。

 // テストケース定義 it("ユーザーがお気に入りにしていた場合、その曲を教える", function() { // オブジェクトのメソッドの呼び出しをスパイを使って監視 spyOn(song, 'persistFavoriteStatus'); // プレイヤーを再生 player.play(song); // プレイヤーがお気に入りに設定(内部で this.currentlyPlayingSong.persistFavoriteStatus(true); が実行されます ) player.makeFavorite(); // song.persistFavoriteStatusメソッド は 引数に true を伴って実行されたか expect(song.persistFavoriteStatus).toHaveBeenCalledWith(true); });

スパイのテストケースはこうなっています。「spyOn(song, ‘persistFavoriteStatus’);」で song オブジェクトの persistFavoriteStatusメソッドを監視します。

songのpersistFavoriteStatusメソッドはこのようになっています。

function Song() {
}
Song.prototype.persistFavoriteStatus = function(value) { // something complicated throw new Error("not yet implemented");
};

「player.play(song);」でプレイヤーを再生するときには特に内部でスパイは活動を行いませんが、「player.makeFavorite();」で活動を行います。

makeFavoriteメソッドは内部ではこのようになっており、「this.currentlyPlayingSong」には 「player.play(song);」内部で定義された songオブジェクトが入っているため、「persistFavoriteStatus」が実行できます。

// お気に入りに登録
Player.prototype.makeFavorite = function() { this.currentlyPlayingSong.persistFavoriteStatus(true);
};

このときに、スパイが活動を始め、このメソッドが実行されたことをしっかりと記憶します。

そして、

// song.persistFavoriteStatusメソッド は 引数に true を伴って実行されたか expect(song.persistFavoriteStatus).toHaveBeenCalledWith(true);

スパイの活動を見るために、toHaveBeenCalledWithメソッドがスパイから songオブジェクトがpersistFavoriteStatusメソッドを実行したときに引数にtrueを使用したかを聞き出します。

true が引数に使われたことがわかったので、これで期待通りの結果になっているということがわかります。

スパイのためのマッチャ

このように toHaveBeenCalledWith のようなスパイのためのマッチャがいくつか用意されています。

expect(x).toHaveBeenCalled()xメソッドがスパイ中に呼び出されていたメソッドであることを期待
expect(x).toHaveBeenCalledWith(arguments)xメソッドがスパイ中に呼び出された時にそのメソッドに使用していた引数がargumentsであることを期待
expect(x).not.toHaveBeenCalled()xメソッドがスパイ中に呼び出されなかった
expect(x).not.toHaveBeenCalledWith(arguments)xメソッドがスパイ中に呼び出された時にそのメソッドに使用していた引数がargumentsでないことを期待

さまざまなスパイの呼び出し方法

スパイが監視しているメソッドの実行を検知したときに細かい挙動を指定することができます。

spyOn(x, ‘method’).andCallThrough();デフォルト機能。スパイ活動を開始します。
spyOn(x, ‘method’).andReturn(arguments);スパイが呼び出されたときに決められた引数であるargumentsを返します。
spyOn(x, ‘method’).andThrow(exception);スパイが呼び出された時に渡された例外をスルーします。
spyOn(x, ‘method’).andCallFake(function);スパイが呼び出された時に指定された関数へ実行を移譲します。

最後に

とても使いやすくて、ひとつのHTMLページに詰め込むこともできるし、デバッグモードをONにした場合にのみ表示なんてことも不可能ではないので、いろんな応用が効くテストフレームワークだと思いました。こんなに柔軟性のあるテストフレームワークだということに今更ながら驚いています。

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

jQuery UI も Twitter Bootstrapもテーマカスタマイズができた

jQuery UI や Twitter Bootstrapのテーマを自由に変える

jQuery UI やTwitter Bootstrapのスタイルはcssによって描画されていますが、そのCSS(と画像ファイル)を自由にカスタマイズできるページがあったみたいです。

いやはやもう少し早く気づいておくべきでした・・。

jQuery UI

20120802040246

jQuery UI ThemeRoller
http://jqueryui.com/themeroller/

左ペインのメニューからいろいろ設定を変更してテーマを変えていきます。ギャラリーにはテーマ一覧もあったりして便利。設定完了したら「Download theme」ボタンをクリック。

Twitter Bootstrap

20120802040347

StyleBootstrap.info

http://stylebootstrap.info/

ここも同じようにカスタマイズした後最後にテーマをダウンロードできる。ダウンロードボタンがページ一番下にあるのでわかりにくいです。

bit.ly API を試してみる

短縮URLといったら

bitly
https://bitly.com/

Twitterが突発的に流行りだした時に一緒に有名になった短縮URLサービス。bit.ly ちょっとAPI調べて使ってみます。

bit.lyで短縮URL出すにはAPIキーが必要

これAPIリクエストの制限とかあるのかな。

とりあえずアカウント作る

create account のところをクリック
20120802033214

アカウント作ったらトップページへ

20120802033248

右上メニューからSETTINGS

20120802033601

APIキーを取得

20120802033601

当て込んでブラウザのアドレスバーにコピペ

http://api.bit.ly/shorten?version=2.0.1&login=ログインアカウント名&apiKey=APIキー&longUrl=http://www.yahoo.co.jp/

JSONでなんかデータでてくる

{
errorCode: 0,
errorMessage: "",
results: { http://www.yahoo.co.jp/: { userHash: "Qflg0g", shortKeywordUrl: "", hash: "INPsu", shortCNAMEUrl: "http://bit.ly/Qflg0g", shortUrl: "http://bit.ly/Qflg0g" }
},
statusCode: "OK"
}

ここまでJavascriptコードにする

var xh = new XMLHttpRequest();
xh.open("GET", "http://api.bit.ly/shorten?version=2.0.1&login=ログインアカウント名&apiKey=APIキー&longUrl=http://www.yahoo.co.jp/", false);
xh.send();
var obj = JSON.parse(xh.responseText);
for (var i in obj.results){ var url = obj.results[i].shortUrl;
}
alert(url);

結果

“http://bit.ly/Qflg0g”

API制限について詳しくはこちら

bit.lyのAPI制限は開発者にやさしい?
http://ken.quoit.jp/2011/02/10/bit-ly%E3%81%AEapi%E5%88%B6%E9%99%90%E3%81%AF%E9%96%8B%E7%99%BA%E8%80%85%E3%81%AB%E3%82%84%E3%81%95%E3%81%97%E3%81%84%EF%BC%9F/

Javascriptでオブジェクトのディープコピー

参照型

Javascriptでオブジェクトをコピーということは基本的にできないみたい。Javascriptにおけるオブジェクトというのは、DOM要素と配列({} と [])などはオブジェクトとして扱われ、 1 とか “1” は 数値と文字列として扱われる。

var a = [1, 2, 3 ];
var b = a;
b[0] = 100;
alert(a);

こうすると、bの変数の配列を一番目を変更したのにも関わらずaの配列の値が変わってる。これは変数aと変数bに格納されているデータを「参照」しているアドレスが同じ場所になってしまっているからなんだそうです。これを参照渡しと言って、Javascriptではどうあがいてもオブジェクトは参照渡しになってしまう。

ただ、DOM要素や関数が配列内に含まれていなければある方法でオブジェクトをディープコピーできる。

JSON

var a = [1, 2, 3 ];
var b = JSON.parse(JSON.stringify(a));
b[0] = 100;
alert(a);

idとclassを使用しないjQueryコーディング

idとclass

通常HTMLとJavascriptの関係といえば、予めHTML要素としてidまたはclass属性を指定しておき、その属性に対してjavascript側からidやclassを指定して要素を特定しますが、jQueryを使うことによってidやclassを一切かかなくてもjavascriptで操作することが可能になるみたいです。

jQueryで要素を作成

よくある方法として、

var hoge = $(document.body).append('<div>test</div>');

のようにして要素を追加をするやり方がありますが、これだとhogeには要素の追加先であるdocument.bodyの要素が参照されます。

しかしこれだと追加した要素を操作することができないためidかclass属性を使用するか $(“document.body:last”)というスマートじゃない方法を使用するしかないです。

これを解決するには、こうします。

var hoge = $('<div>test</div>');
$(document.body).append(hoge);

やってることは上と一緒ですが、このやり方だとhogeにはjavascriptによって操作したい要素がそのまま参照されるので、このhogeを自由に書き換えることができます。しかも、javascriptで準備してから、appendで画面に表示ということができるので、準備時間があり、ローディング時において画面が瞬間的に点滅したり表示が崩れている様子を隠すこともできます。

jQueryプラグイン作成テンプレートがものすごく便利

jQueryプラグイン

有名どころで[coliss.com/:title=コリス]とかあるけども、jQueryプラグインを紹介しているブロガーの方が最近すごく増えてきているとおもいます。いろんなプラグインがあるんだなぁと見て回っていたりしていたのですが、ふと、functionなんかで関数定義せずに全部jQueryプラグインにしてしまったほうが早いんじゃ・・と気づきjQueryプラグインを効率よく作る方法はないかと探したらありました。

jQuery Boilerplate

jQuery Boilerplate

http://jqueryboilerplate.com/

このテンプレートはMITライセンスで配布されているもので、MITなので著作権表示さえすれば自己責任で自由に使えるというものです。なので再頒布も可能です。

というわけでjQuery Boilerplateにコメントを割り振ったものをここに載せちゃいます。

ライセンス MIT License

;(function ($, window, undefined) { //プラグインネームの定義 var pluginName = 'defaultPluginName', //ドキュメント document = window.document, //デフォルト値 defaults = { propertyName: "value" }; //プラグインの初期化 function Plugin(element, options) { //要素 this.element = element; //オプション this.options = $.extend({}, defaults, options); //デフォルト値設定 this._defaults = defaults; //プラグインネーム設定 this._name = pluginName; //初期化 this.init(); } Plugin.prototype.init = function () { //プラグインの初期化 console.log("プラグインの初期化を行っています", this.element, this.options); }; $.fn[pluginName] = function (options) { return this.each(function () { //初期化 if (!$.data(this, 'plugin_' + pluginName)) { console.log("初めてプラグインが実行されたのでプラグインの初期化を行います"); $.data(this, 'plugin_' + pluginName, new Plugin(this, options)); } //実装 console.log("実装部分をここに書きます", this, options); }); };
}(jQuery, window));
$(document.body).defaultPluginName({ propertyName: 'a custom value'
});

初期化関数

このプラグインが使用されたHTML要素が初めての使用だった場合必ず初期化関数が呼び出されます。同じ要素に対して二回目の実行を行なっても「プラグインの初期化を行っています」と書かれている場所は呼ばれません。

「実装部分をここに書きます」と書いてあるところは毎回呼ばれるので、初期化関数には要素のスタイルを適用したりして、毎回実行されるところにはデータの更新などの記述をすると便利になると思います。