Asynch JS - $.Deferred の機能

スムーズで応答性の高い HTML5 アプリケーションを構築する際の最も重要な側面の 1 つは、データの取得、処理、アニメーション、ユーザー インターフェース要素など、アプリケーションのさまざまな部分の同期です。

デスクトップ環境やネイティブ環境との主な違いは、ブラウザではスレッドモデルにはアクセスできず、ユーザー インターフェース(DOM)にアクセスするすべてのものに 1 つのスレッドが提供されることです。つまり、ユーザー インターフェース要素にアクセスして変更するアプリケーション ロジックはすべて、常に同じスレッド内にあるため、すべてのアプリケーション ワークユニットを可能な限り小さく効率的に保ち、ブラウザの非同期機能を最大限に活用することが重要になります。

ブラウザの非同期 API

幸いなことに、ブラウザでは、一般的に使用されている XHR(XMLHttpRequest または 'AJAX')API、IndexedDB、SQLite、HTML5 Web Worker、HTML5 GeoLocation API など、数多くの非同期 API を提供しています。一部の DOM 関連アクションも、TransitionEnd イベントを介した CSS3 アニメーションのように非同期で公開されます。

ブラウザは、イベントまたはコールバックを介して、アプリケーション ロジックに非同期プログラミングを公開します。
イベントベースの非同期 API では、デベロッパーは特定のオブジェクト(HTML 要素や他の DOM オブジェクトなど)にイベント ハンドラを登録して、アクションを呼び出します。ブラウザは通常別のスレッドでアクションを実行し、必要に応じてメインスレッドでイベントをトリガーします。

たとえば、イベントベースの非同期 API である XHR API を使用したコードは次のようになります。

// Create the XHR object to do GET to /data resource  
var xhr = new XMLHttpRequest();
xhr.open("GET","data",true);

// register the event handler
xhr.addEventListener('load',function(){
if(xhr.status === 200){
alert("We got data: " + xhr.response);
}
},false)

// perform the work
xhr.send();

CSS3 transitionEnd イベントは、イベントベースの非同期 API のもう一つの例です。

// get the html element with id 'flyingCar'  
var flyingCarElem = document.getElementById("flyingCar");

// register an event handler 
// ('transitionEnd' for FireFox, 'webkitTransitionEnd' for webkit) 
flyingCarElem.addEventListener("transitionEnd",function(){
// will be called when the transition has finished.
alert("The car arrived");
});

// add the CSS3 class that will trigger the animation
// Note: some browers delegate some transitions to the GPU , but 
//       developer does not and should not have to care about it.
flyingCarElemen.classList.add('makeItFly') 

SQLite や HTML5 Geolocation など、他のブラウザ API はコールバック ベースです。つまり、デベロッパーは、基盤となる実装によって対応する解像度でコールバックされる関数を引数として渡します。

たとえば、HTML5 位置情報の場合、コードは次のようになります。

// call and pass the function to callback when done.
navigator.geolocation.getCurrentPosition(function(position){  
        alert('Lat: ' + position.coords.latitude + ' ' +  
                'Lon: ' + position.coords.longitude);  
});  

この場合は、メソッドを呼び出し、リクエストされた結果でコールバックされる関数を渡すだけです。これにより、ブラウザはこの機能を同期的または非同期的に実装し、実装の詳細に関係なく、デベロッパーに単一の API を提供できます。

アプリケーションを非同期対応にする

適切に設計されたアプリケーションでは、ブラウザの組み込み非同期 API だけでなく、特に I/O や計算量の多い処理を行う場合に、低レベル API を非同期方式で公開する必要があります。たとえば、データを取得する API は非同期で、次のようにしてはいけません。

// WRONG: this will make the UI freeze when getting the data  
var data = getData();
alert("We got data: " + data);

この API 設計では、getData() をブロックする必要があり、データがフェッチされるまでユーザー インターフェースがフリーズします。データが JavaScript のコンテキストでローカルである場合は問題にならないかもしれませんが、データをネットワークから、またはローカルの SQLite やインデックス ストアから取得する必要がある場合は、ユーザー エクスペリエンスに劇的な影響を及ぼす可能性があります。

同期アプリケーション コードを非同期にレトロフィットするのは手間のかかる作業であるため、処理に時間がかかる可能性があるすべてのアプリケーション API を最初から非同期にすることをおすすめします。

たとえば、単純な getData() API は次のようになります。

getData(function(data){
alert("We got data: " + data);
});

このアプローチの利点は、アプリの UI コードを最初から非同期中心にする必要があり、基盤となる API が後の段階で非同期が必要かどうかを判断できる点です。

すべてのアプリケーション API が非同期である必要も、非同期にする必要もありません。原則として、あらゆる種類の I/O や負荷の大きい処理(15 ミリ秒を超える可能性がある処理)を行う API は、最初の実装が同期的であっても、最初から非同期で公開する必要があります。

失敗の処理

非同期プログラミングの問題点の一つは、エラーは通常別のスレッドで発生するため、障害を処理する従来の try/catch 方法が、実際には機能しなくなったことです。したがって、呼び出し先には、処理中に何か問題が発生した場合に呼び出し元に通知する構造化された方法が必要です。

イベントベースの非同期 API では、多くの場合、アプリケーション コードがイベントの受信時にイベントまたはオブジェクトをクエリすることでこれを実現します。コールバック ベースの非同期 API では、障害時に適切なエラー情報を引数として呼び出される関数を受け取る 2 つ目の引数を設定することをおすすめします。

getData 呼び出しは次のようになります。

// getData(successFunc,failFunc);  
getData(function(data){
alert("We got data: " + data);
}, function(ex){
alert("oops, some problem occured: " + ex);
});

$.Deferred でまとめ

上記のコールバック アプローチの 1 つの制限は、ある程度高度な同期ロジックを記述するのが非常に面倒になる可能性があることです。

たとえば、2 つの非同期 API が完了するまで待ってから 3 つ目の API を実行する場合、コードの複雑度が急速に高まります。

// first do the get data.   
getData(function(data){
// then get the location
getLocation(function(location){
alert("we got data: " + data + " and location: " + location);
},function(ex){
alert("getLocation failed: "  + ex);
});
},function(ex){
alert("getData failed: " + ex);
});

アプリがアプリの複数の部分から同じ呼び出しを行う必要がある場合、事態はさらに複雑になる可能性があります。すべての呼び出しでこれらのマルチステップ呼び出しを実行するか、アプリが独自のキャッシュ メカニズムを実装する必要があるためです。

幸いなことに、Promise という比較的古いパターン(Java の Future に類似しています)と、$.Deferred と呼ばれる堅牢で最新の jQuery コアの実装があります。これは非同期プログラミングにシンプルで強力なソリューションを提供します。

わかりやすく説明するために、Promises パターンでは、非同期 API が「結果が対応するデータで解決される Promise」という Promise オブジェクトを返すことを定義しています。解決のために、呼び出し元は Promise オブジェクトを取得し、done(successFunc(data)) を呼び出して「データ」が解決されたときにこの successFunc を呼び出すように Promise オブジェクトに指示します。

したがって、上記の getData 呼び出しの例は次のようになります。

// get the promise object for this API  
var dataPromise = getData();

// register a function to get called when the data is resolved
dataPromise.done(function(data){
alert("We got data: " + data);
});

// register the failure function
dataPromise.fail(function(ex){
alert("oops, some problem occured: " + ex);
});

// Note: we can have as many dataPromise.done(...) as we want. 
dataPromise.done(function(data){
alert("We asked it twice, we get it twice: " + data);
});

ここでは、まず dataPromise オブジェクトを取得し、次に .done メソッドを呼び出して、データが解決されたときにコールバックする関数を登録します。また、.fail メソッドを呼び出して、最終的な失敗を処理することもできます。基になる Promise の実装(jQuery コード)が登録とコールバックを処理するため、.done または .fail の呼び出しを必要な数だけ使用できます。

このパターンでは、より高度な同期コードを実装することが比較的容易になり、jQuery には $.when といった最も一般的なコードがすでに用意されています。

たとえば、上記のネストされた getData/getLocation コールバックは、次のようになります。

// assuming both getData and getLocation return their respective Promise
var combinedPromise = $.when(getData(), getLocation())

// function will be called when both getData and getLocation resolve
combinePromise.done(function(data,location){
alert("We got data: " + dataResult + " and location: " + location);
});  

jQuery.Deferred を使用すると、デベロッパーは非同期関数を非常に簡単に実装できます。たとえば、getData は次のようになります。

function getData(){
// 1) create the jQuery Deferred object that will be used
var deferred = $.Deferred();

// ---- AJAX Call ---- //
XMLHttpRequest xhr = new XMLHttpRequest();
xhr.open("GET","data",true);

// register the event handler
xhr.addEventListener('load',function(){
if(xhr.status === 200){
    // 3.1) RESOLVE the DEFERRED (this will trigger all the done()...)
    deferred.resolve(xhr.response);
}else{
    // 3.2) REJECT the DEFERRED (this will trigger all the fail()...)
    deferred.reject("HTTP error: " + xhr.status);
}
},false) 

// perform the work
xhr.send();
// Note: could and should have used jQuery.ajax. 
// Note: jQuery.ajax return Promise, but it is always a good idea to wrap it
//       with application semantic in another Deferred/Promise  
// ---- /AJAX Call ---- //

// 2) return the promise of this deferred
return deferred.promise();
}

したがって、getData() が呼び出されると、まず新しい jQuery.Deferred オブジェクト(1)を作成してから、その Promise(2)を返します。これにより、呼び出し元は完了済み関数を登録して、関数を失敗させることができます。その後、XHR 呼び出しが戻ると、遅延を解決(3.1)するか拒否(3.2)します。deferred.resolve を実行すると、すべての done(...) 関数と他の Promise 関数(then や pipe など)がトリガーされ、deferred.reject を呼び出すとすべての fail() 関数が呼び出されます。

ユースケース

Deferred は非常に便利なユースケースをいくつか紹介します。

データアクセス: データアクセス API を $.Deferred として公開するのが適切な設計です。これは、同期リモート呼び出しによってユーザー エクスペリエンスが完全に損なわれるため、リモートデータでは明白ですが、低レベル API(SQLite や IndexedDB など)は、それ自体が非同期です。Deferred API の $.when と .pipe は、非同期サブクエリの同期とチェーンに非常に効果的です。

UI アニメーション: transitionEnd イベントを使用して 1 つ以上のアニメーションをオーケストレートすることは非常に面倒な作業です。特に、アニメーションが CSS3 アニメーションと JavaScript が混在している場合には、その傾向が強くなります。アニメーション関数を Deferred としてラップすると、コードの複雑さが大幅に軽減され、柔軟性が向上します。cssAnimation(className) のように、TransitionEnd で解決される Promise オブジェクトを返すような単純な汎用ラッパー関数でも大いに役立ちます。

UI コンポーネントの表示: これはやや高度な方法ですが、高度な HTML コンポーネント フレームワークでは Deferred も使用する必要があります。アプリがユーザー インターフェースのさまざまな部分を表示する必要がある場合、詳細をあまり深く踏まなくても(これは別の投稿で取り上げます)、それらのコンポーネントのライフサイクルを Deferred にカプセル化することで、タイミングをより細かく制御できます。

任意のブラウザの非同期 API: 正規化を目的として、ブラウザの API 呼び出しを Deferred としてラップすることをおすすめします。それぞれ文字どおり 4 ~ 5 行のコードが必要ですが、アプリケーション コードが大幅に簡素化されます。上記の getData/getLocation 疑似コードに示すように、これによりアプリケーション コードで、すべてのタイプの API(ブラウザ、アプリケーション固有、複合)に対して 1 つの非同期モデルを使用できます。

キャッシュ保存: これは副次的なメリットですが、場合によっては非常に役立つことがあります。Promise API(例:.done(...) と .fail(...)) は、非同期呼び出しの実行前または実行後に呼び出すことができます。Deferred オブジェクトを非同期呼び出しのキャッシュ ハンドルとして使用できます。たとえば、CacheManager は、指定されたリクエストについて Deferred を追跡し、一致する Deferred の Promise が無効化されていない場合、それを返します。呼び出し元は、呼び出しがすでに解決されているか、解決中であるかを知る必要がないため、コールバック関数がまったく同じ方法で呼び出される点が便利です。

まとめ

$.Deferred のコンセプトはシンプルですが、適切に対処するには時間がかかることがあります。ただし、ブラウザ環境の性質上、本格的な HTML5 アプリケーション デベロッパーには JavaScript による非同期プログラミングを習得しておくことが必須です。また、Promise パターン(および jQuery 実装)は、非同期プログラミングの信頼性と利便性を高める優れたツールです。