Asynch JS - $.Deferred の機能

スムーズでレスポンシブな HTML5 アプリケーションを構築するうえで最も重要な要素の 1 つは、データの取得、処理、アニメーション、ユーザー インターフェース要素など、アプリケーションのさまざまな部分の同期です。

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

ブラウザの非同期 API

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

ブラウザで非同期プログラミングをアプリケーション ロジックに公開する方法は、イベントまたはコールバックを介して行われます。
イベントベースの非同期 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 Geolocation の場合、コードは次のようになります。

// 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 だけでなく、低レベルの 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 は、最初の実装が同期であっても、最初から非同期で公開する必要があります。

障害の処理

非同期プログラミングの 1 つの問題は、エラーは通常別のスレッドで発生するため、従来の 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 つは、中程度の高度な同期ロジックを記述するのが非常に面倒になる可能性があることです。

たとえば、3 つ目の API を実行する前に 2 つの非同期 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);
});

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

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

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

上記の 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)が返されます。これにより、呼び出し元は done 関数と fail 関数を登録できます。その後、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 が混在している場合(多くの場合そうである)非常に面倒です。アニメーション関数を遅延としてラップすると、コードの複雑さが大幅に軽減され、柔軟性が向上します。cssAnimation(className) のような単純な汎用ラッパー関数でも、TransitionEnd 時に解決される Promise オブジェクトを返すことがあります。

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

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

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

まとめ

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