スムーズで、レスポンシブなHTML5アプリケーションを構築する際に一番大事な側面の1つとして、全ての異なったアプリケーションのパーツ、例えばデータの取得・加工・アニメーション・UI要素などの間を同期させるという事があります。
デスクトップやネイティブ環境との主な違いとして、ブラウザはスレッディングモデルにはアクセスできず、全てのUI(例えばDOM)に対してはシングルスレッドでしかアクセスが許されていない点があります。これは全てのアプリケーションロジックが常に同じスレッドに対してしか、アクセスや変更が出来ないという事を意味しています。このため、全てのアプリケーションは小さな単位で効率的に、またブラウザが提供する非同期な処理を最大限に使うように動作している事が大切になります。
ブラウザの非同期API
幸いにもブラウザは一般的に使用されるXHR(XMLHttpRequestか'AJAX')API、そしてIndexedDB、SQLite、HTML5 Web workersや、わずかに知られる例としてはHTML5 GeoLocation APIなどのようないくつかの非同期APIを備えています。transitionEndイベントのようなCSS3アニメーションなどのDOMに関連したいくつかのアクションも非同期にされています。
ブラウザがアプリケーションロジックに提供する非同期プログラミングにはイベントまたはコールバックという手段があります。イベントベースの非同期APIでは、開発者は与えられたオブジェクト(例えばHTMLエレメントもしくは他のDOMオブジェクト)に対してイベントハンドラを登録して、適切な時にメインスレッドのイベントを発火させます。
例として、イベントベースの非同期APIであるXHR APIを使用したコードはこのような感じになります:
// /data以下のリソースをGETで取得する為にXHRオブジェクトを作成します var xhr = new XMLHttpRequest(); xhr.open("GET","data",true); // イベントハンドラを登録します xhr.addEventListener('load',function(){ if(xhr.status === 200){ alert("We got data: " + xhr.response); } },false) // 処理の実行 xhr.send();
CSS3のtransitionEndイベントを使ったイベントベースの非同期APIの他の例です。
// idが'flyingCar'のHTMLエレメントを取得する var flyingCarElem = document.getElementById("flyingCar"); // イベントハンドラの登録 // ('transitionEnd' for FireFox, 'webkitTransitionEnd' for webkit) flyingCarElem.addEventListener("transitionEnd",function(){ // transitionが完了したら呼ばれます alert("The car arrived"); }); // アニメーションの発火のためCSS3のクラスを付けます // 注意: いくつかのブラウザはいくつかのtransitionをGPUに譲ってしまいます。 // しかし、開発者はこの事を気にしてはいけないし、するべきでもありません。 flyingCarElemen.classList.add('makeItFly')
SQLiteやHTML5ジオロケーションのような他のブラウザAPIはコールバックベースになります。これは開発者が引数として関数を渡す事により、問題を解決する実装の基本とするという意味です。
例えば、HTML5ジオロケーションではこのようなコードになります。
// 完了した際に呼び出すコールバック関数を渡す navigator.geolocation.getCurrentPosition(function(position){ alert('Lat: ' + position.coords.latitude + ' ' + 'Lon: ' + position.coords.longitude); });
この場合、メソッドを呼び出し、要求の解決の為コールバックとして関数を渡すだけです。ブラウザはこれを同期・非同期に関わらず単一の機能として実装し、開発者には実装の詳細に関わらず、単一のAPIとして提供できるわけです。
非同期化しやすいアプリケーションを作る
ブラウザ組み込みの非同期APIは当然として、優れたアーキテクチャのアプリケーションは、低レベルのAPIも非同期に呼び出せるようにするべきです。I/OのソートやCPUに負荷がかかる処理をする場合は特にそうです。例えば、APIがデータを取得する際は非同期であるべきですし、 この例のようにはすべきでありません。:
// 間違い: この処理はデータの取得時にUIをフリーズさせてしまいます。 var data = getData(); alert("We got data: " + data);
このAPIの設計ではgetData()をデータを取得するまでUIをフリーズさせてしまうブロッキング状態にしてしまいます。 もしデータがJavaScriptのコンテキストの中でローカルなものであればそれ程の問題にはなりませんが、データの取得に ネットワークやローカルのSQLiteやインデックスストアでの操作が必要ならば、UIにとても大きな影響を与えかねません。
正しい設計としては、時間がかかる可能性のある全てのアプリケーションAPIを積極的に非同期にする事ですが、 最初から同期的に書かれているアプリケーションのコードを非同期にするのは大変な作業になります。
例として単純化されたgetData() APIはこのようになるでしょう。:
getData(function(data){ alert("We got data: " + data); });
このアプローチで良い事は、アプリケーションUIのコードを最初から非同期中心のコードにできる事と、基本APIを非同期が必要かどうかを後から決める事ができる点です。
注意点として、全てのアプリケーションAPIを非同期にする必要もすべきでも無いという事です。重要なルールはどのようなタイプのI/O、または重たい処理(15ms以上のどのような処理でも)でもそうですが、最初の実行が同期的だったとしても、初めから非同期処理として公開されるべきという点です。
例外の処理
非同期プログラミングの一つの問題として、エラーが別のスレッドで起きる可能性があるため、伝統的な例外処理である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を使ってまとめる
上記のコールバックアプローチの限界として、ちょっとした同期ロジックを書きたいだけでも、非常に面倒になってしまう点が挙げられます。
例えば、もし3番目の実行をする前に2個のAPIの実行完了を待たなければならないとすると、コードの複雑さは飛躍的に高まります。
// 最初のデータ取得 getData(function(data){ // その後に場所の取得 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があります)と、jQueryコアで$.Deferredと呼ばれる強固でモダンな実装が、非同期プログラミングのシンプルで強力な解決策として存在しています。
単純にする為、Promiseパターンは非同期APIが「対応するデータによって解決するはずである結果を保障」するようなPromiseオジェクトを返すように定義します。問題解決の為、関数呼び出し元でPromiseオブジェクトを取得し、Promiseオブジェクトが"データ"が分析完了した時に呼び出すsuccessFuncをdone(successFunc(data))として呼び出します。
ですので、上記のgetDataの呼び出し例はこのようになります:
// このAPIのために、Promiseオブジェクトを取得する var dataPromise = getData(); // データの分析完了した時に呼び出される関数を登録する dataPromise.done(function(data){ alert("We got data: " + data); }); // エラー時の関数を登録する dataPromise.fail(function(ex){ alert("oops, some problem occured: " + ex); }); // 注意:登録したいだけdataPromise.done(...)は登録できます。 dataPromise.done(function(data){ alert("We asked it twice, we get it twice: " + data); });
さて、dataPromiseオジェクトを最初に取得してから、.doneメソッドをデータを取得した時にコールバックさせたい関数を登録するために呼んでます。また.failメソッドを処理の結果起こるエラーの処理の為に呼んでいます。注意点として、.doneと.failは基本のPromeseの実装(jQueryのコード)が関数の登録やコールバックを処理し続ける限り、必要なだけ呼べるという点です。
このパターンでは、比較的簡単にもっと高度な同期的コードの実装ができますし、jQueryは一番知られている実装である$.whenのようなものを既に用意しています。
例として、ネストされたgetData/getLocationの上記のコールバックはこのような感じになります:
// getDataとgetLocationはそれぞれPromiseを返す前提 var combinedPromise = $.when(getData(), getLocation()) // getDataとgetLocation両方が解決された時に関数が呼ばれる combinePromise.done(function(data,location){ alert("We got data: " + dataResult + " and location: " + location); });
また、これらの実装の優れた点はjQuery.Deferredによって開発者がとても簡単に非同期関数を作る事ができるという点です。先程のgetDataはこのような感じにする事ができるでしょう:
function getData(){ // 1) 使用するjQuery Deferredオブジェクトを作る var deferred = $.Deferred(); // ---- AJAX呼び出し ---- // XMLHttpRequest xhr = new XMLHttpRequest(); xhr.open("GET","data",true); // イベントハンドラの登録 xhr.addEventListener('load',function(){ if(xhr.status === 200){ // 3.1) Deferredの解決 (全てのdone()...がトリガーになる) deferred.resolve(xhr.response); }else{ // 3.2) Deferredの拒否 (全てのfail()...がトリガーになる) deferred.reject("HTTP error: " + xhr.status); } },false) // 処理の実行 xhr.send(); // 注意点: jQuery.ajaxを使えますし、使うべきです // 注意点: jQuery.ajaxはPromeseを返しますが、他のDeferred/Promise中のアプリケーションの意図を // ラップするという点でとても良いアイデアです。 // ---- /AJAX 呼び出し ---- // // 2) このDeferredのPromiseを返す return deferred.promise(); }
これで、getData()が呼び出された時に、一番最初に新しいjQuery.Deferredオブジェクトが(1)で作られ、 次に(2)でPromiseが返されるので、呼び出し先でdoneとfail関数を登録できるようになります。 次にXHR呼び出しが返ってきた時に、(3.1)でDeferredを解決するか、(3.2)で拒否します。 deferred.resolveは全てのdone(...)関数や他のPromise関数(例えば、thenやpipe)がトリガーになり、 deferred.rejectは全てのfail()関数から呼ばれる事になります。
使用例
ここではDeferredが大変便利に使える、良い使用例をいくつか見ていきます。
データアクセス:データアクセスAPIを$.Deferredで実装するのは、大抵の場合正しい選択と言えます。 このことはリモートデータの扱いにおいては明らかで、同期的なリモートの呼び出しはユーザーエクスペリエンスを完璧に壊してしまいます。 また、ローカルデータにおいてもそうであることは、(SQLiteやIndexedDBのような)低レベルAPIが非同期で処理されることからも分かります。 Deferred APIの$.whenや.pipeは、同期や非同期のサブクエリを連鎖させたい場合に、大変な威力を発揮します。
UIアニメーション:1つ、またはそれ以上のtransitionEndイベントを使ったアニメーションの編集するとなると、とても長ったらしいものになりかねません。特にCSS3アニメーションとJavaScriptを混ぜ合わせたアニメーションの場合はそうなります(良くあるケースですが)。 アニメーション関数をDeferredとしてラップするとコードの複雑さをかなり減らす事ができ、柔軟さを改善させます。 Promiseオジェクトを返し、transitionEndの解決をするcssAnimation(class名)というようなシンプルで一般的なラッパーでさえ、非常に助けになるでしょう。
UIコンポーネント表示:こちらはほんの少し高度な例ですが、先進的なHTMLコンポーネントフレームワークもDeferredを使うべきでしょう。詳細はここには書きませんが(他のエントリの題材になるでしょう)、アプリケーションが別々のユーザーインターフェースのパーツを表示する必要があった場合に、それらのコンポーネントのライフサイクルをDeferredで内包する事によりタイミングの調整が非常にしやすくなります。
ブラウザの全ての非同期API:ブラウザAPIの呼び出しをDeferredでラップするのは、APIの均一化という意味で、多くの場合良いアイデアと言えます。それぞれのコードにたった4~5行書くだけですが、どんなアプリケーションのコードでもとてもシンプルにしてくれます。 上で紹介した、getData/getLocationの疑似コードのように、アプリケーションのコードの全てのタイプのAPI(ブラウザ、アプリケーション固有のもの、これらを組み合わせたもの)を1つの非同期モデルでまとめて書くことができます。
キャッシュ:どちらかというと副作用的な利点となりますが、場合によってはとても便利です。理由は、Promise API(例えば、.done(...)や.fail(...)など)は非同期呼び出しが実行された前でも後でも呼び出せるからです。Deferredオジェクトは非同期呼び出しのキャッシュ処理として使えるのです。例えば、与えられたリクエストに対するDeferredを追跡しつづけ、無効でなければマッチしたDeferredのPromiseを返します。これの優れた点は呼び出し元は既に解決されたのか、解決の途中なのかを知る必要が無いという点です。コールバック関数は全く同じ方法で呼ばれます。
まとめ
$.Deferredのコンセプトはシンプルですが、これを使って良い処理をするのは時間がかかるかもしれません。 しかし、ブラウザ環境の性質上、JavaScriptでの非同期プログラミングを真剣なHTML5アプリケーション開発者にとっては必須ですし、Promiseパターン(と、そのjQueryの実装)は非同期プログラミングをする上で頼りがいがあり、パワフルな、とてつもない道具です。