JSDeferredで、面倒な非同期処理とサヨナラ

第3回JSDeferredを用いたアプリケーション開発(その2)

前回に引き続き、Wikipediaから緯度経度を取得しGoogle Maps上にプロットするアプリケーションを作っていきます。

今回は透過的なキャッシュの仕組みを入れるのと、geolocation APIを用いて、データのロードが終わり次第自分の近くのマーカーを表示させてみたいと思います。

完成コードは以下のようになります。

透過的なキャッシュ

キャッシュのための領域として、localStorageというものを使います。これはFirefoxやSafariなどで実装済みの、簡単にいってしまえば大容量のCookieみたいなものです。

localStorageは同期的に値を取得やセットをできますが、キャッシュするデータはXMLHttpRequestで取得される非同期なデータです。この二つの条件分けを最小限にして実装するために、JSDeferredを間に挟むことにします。

前回は、WikipediaからJSONのデータを取得するために、以下のようなコードを記述しました。

リスト1
$.getJSON(url).
next(function (data) {
   // ...
});

はじめに、最終的にどうしたいのかを考えます。この場合、キャッシュはあってもなくても最終的にやることは変わらないようにしたいため、getJSONのあたりで何もかもを終わらせてやりたいですね。そこで、以下のようなコードになることを目標にしてみます。

リスト2
withCache('wikipediajson', function () { return $.getJSON(url) }).
next(function (data) {
   // ...
});

withCache(key,fun);はkeyをキャッシュキーにしてfunが返すデータをキャッシュする関数で、Deferredオブジェクトを返します。このような関数を作れれば、他の部分でキャッシュしたいときも同じようなコードでいくらでもキャッシュできそうですね。

withCache関数を作るために、Deferredオブジェクトを自分で作ってみることにします。Deferredオブジェクトを自分で作る場合は最終的に、非同期処理の中でDeferredオブジェクトのcall()メソッドを呼びだすようにします。簡単な例を示すと以下のようになります。

リスト3
function foobar () {
    var d = new Deferred;
    setTimeout(function () { d.call('done!') }, 0);
    return d;
}

foobar().next(function (r) {
    alert(r); //=> 'done!'
});

これでDeferredオブジェクトを返す関数を作ることができます。

withCacheでは、キャッシュのあるなしによって条件分岐して、最終的には上記コードのようにcall()メソッドを呼びます。まずはコードをご覧ください。

リスト4
function withCache (key, fun) {
    var d = new Deferred();

    var val = localStorage.getItem(key);
    if (val) {
        next(function () {
            d.call(eval(val));
        });
    } else {
        fun().next(function (data) {
            try {
                localStorage.setItem(key, uneval(data));
            } catch (e) {
                localStorage.clear(); // キャッシュが溢れた
                localStorage.setItem(key, uneval(data));
            }
            d.call(data);
        });
    }

    return d;
}

localStorageにデータがあるときはnext()関数で呼び出しを非同期化した上でcall()メソッドを呼んでいます。JavaScriptで非同期処理を同期化することは決してできないのは先に言及したとおりですが、同期処理を非同期処理に変換するのはできるので、非同期の流儀にあわせてあげることで非同期処理と同期処理を混在させることができます。

この同期処理をnext()で非同期にするということをせずに、d.call()を呼んでしまうと、この時点ではまだ作られたDeferredオブジェクトは「次にすべき処理」を知らないため、何も起こりません。⁠次にすべき処理」はこの関数がreturnされたあとにreturnされたDeferredオブジェクトのnext()メソッドを呼ぶことで設定されます。そのため、そのあとにd.call()を呼ぶようにしてあげる、というわけです。

localStorageにデータがないときは、渡されたfunを実行し、返ってきたデータをlocalStorageに設定して、最後はデータがあるときと同じようにcall()メソッドを呼んでいます。

geolocationによる現在位置表示

次に、geolocationを使って現在位置を表示します。

まずは、geolocationをクロスブラウザ化させて、より簡単に利用するためのgetCurrentLocationという関数のsnippetを作ります。

リスト5
function getCurrentLocation (callback) {
  var geo = navigator.geolocation || google.gears.factory.create('beta.geolocation');
  var id = geo.watchPosition(
    function (pos) {
      geo.clearWatch(id);
      callback(pos);
    },
    function (e) {
    },
    {
      enableHighAccuracy: true,
      maximumAge: 0,
      gearsLocationProviderUrls: []
    }
  );
}

ここではまだJSDeferredを使用しません。なぜなら、これを単体のsnippetとしてJSDeferredに依存しない形で使いたいと思うかもしれないからです。この段階ではプリミティブなcallbackをうけとる関数にしておきます。

この関数はgearsによるgeolocationの実装と、ブラウザネイティブによるgeolocationの実装両方に対応するためのいくつかの処理をしているのと、確実にGPSの位置情報を取得するためにwatchPositionを使っています。詳しい説明はしませんが、とりあえずこう記述しておく上手に動作します(そういう意味で、JSDeferredから独立したsnippetというわけです⁠⁠。

さて、これをDeferredを返す関数にしたいと思います。withCacheと同じように、自分でDeferredオブジェクトを作ってd.callを呼ぶ、という方法でももちろん良いのですが、JSDeferredはこういった⁠コールバックをとる関数をDeferredを返すように簡単に変換する関数⁠を持っているため、それを使うことにします。

Deferred.connectは既存のコールバックをとる非同期関数をDeferredを返す関数に変換する関数です。これにより、既存の非同期関数を簡単にDeferredの処理の中に組込むことができます。簡単な使いかたは以下のとおりです。

リスト6
var getLocation = Deferred.connect(getCurrentLocation);
getLocation().next(function (loc) {
});

簡単ですね。これで他のJSDeferredの関数にようにnext()で繋いでいくことができます。では、これをアプリケーションに組みこんでみましょう。

geolocationの情報取得には多少時間がかかるため、同じく時間がかかるWikipediaの情報取得と同時に実行させたいものです。察しの良いかたはお気づきかもしれませんが、ここで第1回にでてきたparallel関数を使うことにします。

parallelは複数の非同期処理を実行し、結果を集める関数です。このアプリケーションの場合は以下のように用います。

リスト7
parallel({
  wikipedia: CollectLatLongFromCategory(...) // 前回書いた関数
  mylocation: getLocation()
}).
next(function (result) {
  // どちらの実行も終わったあとに呼ばれる
  alert(result.mylocation.coords.latitude, result.mylocation.coords.longitude);
});

CollectLatLongFromCategoryは前回と今回で取り上げた、Wikipediaから情報を取得し、Google Mapにプロットする関数です。これはDeferredを返すようにしていました。

getLocationは今Deferred.connectで変換した関数です。

parallelは両方の結果が揃ってから、すなわちWikipediaから取得したデータを全てプロットしてgeolocationで位置が確定してから、そのあとの処理を実行します。あとはここで自分の位置をGoogle Mapsにプロットし、ズームレベルなどを調整すれば完成です。

リスト8
parallel({
  wikipedia: CollectLatLongFromCategory(...) // 前回書いた関数
  mylocation: getLocation()
}).
next(function (result) {
  var lat = result.mylocation.coords.latitude;
  var lng = result.mylocation.coords.longitude;

  var myPos  = new google.maps.LatLng(lat, lng);
  var marker = new google.maps.Marker({
    position : myPos,
    map      : map,
    icon     : markerImageMe,
    title    : 'あなた'
  });

  var nearest, nearestPos;
  for (var i = 0, len = markers.length; i < len; i++) {
    var pos = markers[i].getPosition();
    var dis = distance(pos, myPos);
    if (!nearestPos || dis < nearestPos) {
      nearestPos = dis;
      nearest    = pos;
    }
  }

  var bounds = new google.maps.LatLngBounds();
  bounds.extend(myPos);
  bounds.extend(nearest);

  map.fitBounds(bounds);
  map.setZoom(map.getZoom() - 1);
});

マーカーの初期化などは省略しましたが、だいたいこのようになります。

まとめ

前回と今回でJSDeferredを使った簡単なJavaScriptアプリケーションを作ってみました。もしよろしければ、この例をJSDeferredを使わずに記述してみてください。おそらく、めんどうくさくて途中で投げだすか、JSDeferredのようなものを作りたくなるはずです。

煩雑な非同期処理が多くなりがちなJavaScriptでは、ある程度以上のアプリケーションを作ろうと思ったとき、JSDeferredや、あるいは何かしら似たようなものが必要になります。

次回はJSDeferredによるパフォーマンスの最適化と、その他応用方法にをご紹介したいと思います。

おすすめ記事

記事・ニュース一覧