backbone.localstorage.jsとBackbone.Syncのお話

このエントリはBackbone.js Advent Calendar 2012の2日目の記事です。

Backbone.jsにはBackbone.SyncというModelとサーバ側のリソースを常に同期させる仕組みがあり、これをOverrideすることで同期させる仕組みを自体を柔軟に変えることができます。
今回はbackbone.localstorage.jsのユニットテストを通じて、Backbone.SyncをOverrideする仕組みについて少しお話したいと思います。

このエントリでお伝えしたいこと。

  1. backbone.localstorage.jsはどのようにBackbone.SyncとOverrideしているか。
  2. backbone.localstorage.jsはBackbone.SyncをOverrideするコードの良いお手本だと思う。。

はじめに

まず、きっかけですが、backbone.localstorage.jsを使ってlocalstorageにBackbone.Modelを保存する簡単なサンプルを作ってユニットテストしたところ、思わぬところでfailしてしまったことです。
Backbone.js側のコードとユニットテストのコードは次のとおりです。
(ちなみにユニットテストはJasmineを使ってます。)

app.js

1
2
3
4
5
6
7
8
var model = Backbone.Model.extend({
title: ''
});

var collection = Backbone.Collection.extend({
model: model,
localStorage: new Backbone.LocalStorage('backbone-blog-post')
});

test.model.before.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
describe('test localstorage', function() {
beforeEach(function() {
this.model = new model();
});

it('test model save()', function() {
//Error: A "url" property or function must be specified
this.model.save({
title: 'hello'
});
expect(this.model.collection.localStorage.find(this.model)).not.toBe(null);
expect(this.model.collection.localStorage.find(this.model).title).toBe('hello');
});
});

ユニットテストを実行した結果は、次のようなエラーが発生しました。

Error: A “Url” property or function must be specified

初めはlocalstorageに保存しているのに、なぜURLが必要なのかわかりませんでした。

localstorageに保存するはずなのになぜURLが必要??

その前に、Backbone.Modelのsave()Backbone.Syncの関係、Backbone.Syncのデフォルトの挙動について抑えておく必要があります。

まず、Backbone.Modelのsave()とBackbone.Syncの関係についてですが、Backbone.Modelのsave()を呼び出した際に、内部でsyncイベントが発生して、Backbone.Syncに定義されているfunctionが実行されるようになっています。
その際に、Backbone.Syncはデフォルトでサーバサイド側のREST API
(GET/PUT/POST/DELETE)とAjax(jQueryかZepto依存)で通信をするようになっています。

これらの事により、先のエラーはAjax通信をしているため発生していることが容易に予想できるのですが、そもそもbackbone.localstorage.jsはBackbone.SyncをOverrideしているので、なぜAjaxのコードが生きているのか分かりませんでした。

なぜAjaxが動いているのか?

この問題を理解するためにbackbone.localstorage.jsのソースを読みました。
(以下、核心部分だけ抜粋します。)

1
2
3
4
5
// Override 'Backbone.sync' to default to localSync,
// the original 'Backbone.sync' is still available in 'Backbone.ajaxSync'
Backbone.sync = function(method, model, options, error) {
return Backbone.getSyncMethod(model).apply(this, [method, model, options, error]);
};

上はbackbone.localstorage.jsの134行目辺り。
Backbone.SyncをOverrideしているところです。中でBackbone.getSyncMethod()をreturnしています。
自分でカスタムする場合は、ここに直接Overrideするコードを書いても良さそうです。

1
2
3
4
5
6
7
8
9
10
Backbone.ajaxSync = Backbone.sync;

Backbone.getSyncMethod = function(model) {
if(model.localStorage || (model.collection && model.collection.localStorage))
{
return Backbone.LocalStorage.sync;
}

return Backbone.ajaxSync;
};

上はbackbone.localstorage.jsの123行目辺り。本問題の核心部分です。
読めば一目瞭然なのですが、デフォルトのBackbone.SyncをBackbone.ajaxSyncという別名で保存してました。

しかも、Backbone.getSyncMethodでは

  • model.localStorage
  • model.collection.localStorage

いずれかのプロパティが存在しない場合、Backbone.ajaxSyncがreturnされます。

これですべての謎が解けました。

最初のテストコードはどうあるべきだったのか?

先に結論ですが、次のコードでテストが通りました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
describe('test localstorage', function() {
beforeEach(function() {
this.model = new model();
//collectionをmodelにセットする
this.collection = new collection();
this.model.collection = this.collection;
});

it('test model save()', function() {
this.model.save({
title: 'hello'
});
expect(this.model.collection.localStorage.find(this.model)).not.toBe(null);
expect(this.model.collection.localStorage.find(this.model).title).toBe('hello');
});
});

ただ、なんとなく違和感が残ります。

Modelだけをテストする目的であればこれも有りだと思いますが、テストを通すためにコードを足したようでなんとなく気持ち悪いですし、何か使い方が間違っている気がします。
次回はこの違和感を取り除いて行く過程を書きます。)

ちなみに、backbone.localstorage.jsはコードが140行足らずなので読むのは非常に楽でした。
実際にBackbone.SyncをOverrideするコードを書く場合は、ぜひ参考にしたいと考えています。

Backbone.js Advent Calendar 2012