2013年3月 5日

プログラミング

爆速でわかるjQuery.Deferred超入門

  • このエントリーをはてなブックマークに追加

Yahoo!デベロッパーネットワークの中野(@Hiraku)です。これまで、JavaScriptで非同期処理を書く上での問題として、コールバック地獄エラー処理に例外が使えないことなどを解説してきました。

これらの問題に対処するライブラリの1つであるjQuery.Deferredに関して、もう少し丁寧に解説いたします。なお、jQueryのバージョンは記事執筆時点の最新である、1.9.1を想定しています。

jQuery.Deferredとは

jQuery.DeferredとはjQueryのバージョン1.5から導入された、非同期処理をうまく扱うための標準モジュールです。使いこなすことで、以下のような効果が見込めます。

  • 非同期処理を連結する際、コールバック地獄から解放される(直列処理、並列処理が可能)
  • エラー処理をうまく記述できる
  • 一連の非同期処理を関数化して再利用しやすくできる

用語の整理:DeferredとPromise

jQuery.Deferredでは、非同期の処理それぞれに Promise と呼ばれるオブジェクトを割り当て、そのオブジェクトの状態を伝播させていくことで処理を進めます。

言い換えると、既存の非同期処理全てを、あらかじめjQuery.Deferredに対応した特別な形式に整えておかなければいけません。準備が面倒そうですが作業自体は簡単で、以下のようなラッパー関数を新たに作るだけです。

  1. $.Deferred()でDeferredオブジェクトを作る
  2. 非同期処理が終わったら作ったDeferredオブジェクトの状態を変更するように設定しつつ、処理を開始
  3. Deferredオブジェクトが持っているPromiseオブジェクトを即座にreturnする

Deferredの下準備

もちろん、jQuery自体が持っている非同期関数($.ajax()や$.getJSON()など)なら最初からDeferred対応済みです。用意されている関数を利用するだけであれば、このような準備作業は必要ありません。

ここで、 DeferredPromise という単語がいきなり2つ出てきました。2つともjQuery.Deferredが生成するオブジェクトで、DeferredはPromiseを内包しています。DeferredとPromiseは常に1対1で作成され、対応するDeferredだけがPromiseの内部状態を変更できます。

DeferredからPromiseだけを抜き出すことで、カプセル化して内部状態を勝手に変更できないようにしているのです。

…ちょっと複雑かもしれません。本記事では単語は使い分けますが、あまり両者の違いを意識しなくても読み進められるようにしています。

下準備

今回の記事では、説明のためにjQuery.Deferredに対応させた非同期関数を2つ用意します。本来はエラーかどうかで.resolve() と.reject() を使い分けるところですが、話を単純にするために常に.resolve()されるものと常に.reject()されるものの2種類とします。

/**
 * 1秒後にHello!を出力するDeferred対応関数。必ずresolveする
 *
 * @returns Promise
 */
function delayHello()
{
  var d = new $.Deferred;
  setTimeout(function(){
    console.log('Hello!');
    d.resolve();
  }, 1000);
  return d.promise();
}

/**
 * 1秒後にエラーを発生させるDeferred対応関数。必ずrejectする
 *
 * @returns Promise
 */
function delayError() {
  var d = new $.Deferred;
  setTimeout(function(){
    d.reject('Error!!');
  }, 1000);
  return d.promise();
}

ついでにダミーですが、Deferredではない、ただ適当に出力するだけの関数も用意しておきます。

function hello1() {
  console.log('Hello sync1');
}

function hello2() {
  console.log('Hello sync2');
}

Promiseオブジェクトの基本構造

Promiseオブジェクトは概念的には、

  • 状態(.state)
  • 状態がresolvedになった時に実行されるコールバック(.done)
  • 状態がrejectedになった時に実行されるコールバック(.fail)

の3つを持っています。(実際はもう一つ、処理途中の任意イベント通知に使える.progressを持っていますが、複雑になるので省略します)

Promiseの基本構造

Promiseが1つだけの場合

Promiseオブジェクトは、作ったばかりだと"pending"(処理中の状態)がセットされています。

成功時に処理されるコールバックは.done()メソッドで、失敗時に処理されるコールバックは.fail()メソッドでそれぞれ登録します。

var promise = delayHello();
promise.done(function(){ /* resolvedで実行 */ });
promise.fail(function(e){ /* rejectedで実行 */ });

非同期の関数が処理を終えると、成功失敗に応じてPromiseオブジェクトの状態が"resolved"(成功)または"rejected"(失敗)に変わります。この時、対応する コールバックが起動します。

Promiseオブジェクトが"resolved"になると、.done()に登録したコールバックが起動し、

resolvedになったとき

"rejected"になると、.fail()に登録したコールバックが起動します。

rejectedになったとき

.then()を使うと、.doneと.failを一気に登録できます。 第2引数は省略可能で、その場合は.doneのみが登録されます。ただ、.then()は新たなPromiseを返すので、単に return thisしているだけの.done()や.fail()とは少し異なり、処理の連結にも使えます。連結については後で詳しく説明します。

delayHello()
.then(function(){ /* resolvedで実行 */ }, function(e){ /* rejectedで実行 */ });

対応するコールバックのみが起動するというのがポイントで、"resolved"になった場合.failは実行されず、逆に"rejected"になった場合.doneは実行されません。

//delayHello()が返すDeferredは1秒後にresolvedになる。
//なので、1秒後にhello1だけが実行される
delayHello()
.then(hello1, hello2);
//delayError()が返すDeferredは1秒後にrejectedになる。
//なので、1秒後にhello2だけが実行される
delayError()
.then(hello1, hello2);

なんとなく挙動が見えてきたでしょうか?

非同期処理が1つだけの場合、正直なところjQuery.Deferredを使うメリットはありません。jQuery.Deferredの真価は処理を連結できることにあります。

.then()による連結1:普通の関数を連結した場合

.then()は.doneと.failの登録を行うだけでなく、新しいPromiseオブジェクトを返します。途中の状態を見てみると、全部違うオブジェクトが返っていることがわかります。単にreturn thisしているわけではないのです。

var p1 = delayHello();
var p2 = p1.then(hello1);
var p3 = p2.then(hello1);

console.log(p1 === p2); //false
console.log(p2 === p3); //false
console.log(p3 === p1); //false

通常は一気にチェインさせて書くので、途中に生成されるオブジェクトは意識しなくてもいいようになっています。

delayHello()
.then(hello1)
.then(hello1);

上記のコードでは3つPromiseオブジェクトが作られて、紐づけられている状態です。

.then()が作る構造1

.thenにただの関数を渡している場合、.then()が返すPromiseはひとつ前のPromiseの状態を自動的に引き継ぎます。先頭のdelayHello()が返すPromiseが"resolved"になると、後ろに連結されたPromiseも連鎖的に"resolved"になり、一気に処理されます。

resolvedの連鎖

ここで、先頭のPromiseが"rejected"になる場合はどんな挙動になるでしょうか?

delayError()
.then(hello1)
.then(hello1);

Promiseオブジェクトは"rejected"の状態をリレーしていきますが、どのPromiseにも.failの登録がないので、ただ単に.done側の処理がスキップされたように見えます。

rejectedの連鎖

この、「エラー処理を書かなければ処理がスキップされ、エラー状態が伝播していく」という挙動ですが、try~catchによる例外処理に似ていると思いませんか?

実際、最後尾のDeferredオブジェクトに対して.failを登録することで、try~catchと似たような効果を得ることができます。

delayError()
.then(hello1)
.then(hello1)
.fail(function(e){
  console.log(e);
  console.log('エラーを処理しました');
});

.then()による連結2:Promiseを返す関数を連結した場合

先述しましたが、jQuery.Deferredでは非同期関数はすべて「Promiseを返す関数」というインターフェースに修正する必要があります。.then()には「Promiseを返す関数(=非同期関数)」を渡すこともでき、複数の非同期処理を連結できます。

この「Promiseを返す関数」を.then()でつないでいくと、非同期処理にも関わらず1つずつ順番に実行でき、コールバックの入れ子を解消できます。

delayHello()
.then(delayHello)
.then(delayHello)
.then(delayHello)
.then(delayHello);
//一秒おきに'Hello!'が出力される

これで生成されるPromiseオブジェクトの構造は少し複雑です。単に2つのdelayHelloをつないだ場合でも、3つのPromiseオブジェクトが絡み合う構造ができます。

delayHello().then(delayHello);

Promiseの連結

先頭のdelayHello()が返すPromise(p1)と、そのPromiseの.doneが返すPromise(p2)と、.then()によって生成されたPromise(p3)で3つです。p3だけはフレームワークが勝手に生成するオブジェクトですが、p2の状態が即座にp3に伝播するため、あまり意識する必要はありません。

複雑な挙動に見えますが.then()がこの構造を作るおかげで、「順番に非同期処理を行う」という処理を、.then()を次々につなげるスタイルで書くことができます。

.then()の代わりに.done()を使うと、単に.doneのコールバックを二つ登録しただけの状態になってしまい、順番に実行されません。2つ目以降の非同期処理が同時に始まってしまいます。

//これは直列連結の書き方として間違い
delayHello().done(delayHello).done(delayHello);

/*
1秒後に"Hello!"が出力
さらに1秒後に"Hello!"が2つ同時に出力
*/

間違った連結

.then()でつなぐことで、うまく順番を取り扱ってくれるようになります。

delayHello().then(delayHello).then(delayHello);

正しい連結

.then()による連結3:エラーからの復帰

try~catchの場合は例外を捕捉するとエラーは終息しますが、Promiseは.failを設定しただけではエラーが継続します。

.failに設定したコールバックが別のPromiseを返し、なおかつそのPromiseが"resolved"になったとき、後続のPromiseをエラー状態から復帰させることができます。

delayError()
.then(hello1,
  function(e){
    console.log(e);
    console.log('エラーから回復しました');
    return new $.Deferred().resolve().promise();
  })
.then(hello1, hello2);

/*
一秒後に
'Error!!'
'エラーから回復しました'
'Hello sync2'

…と一気に出力される
*/

エラーの復帰

この点はtry~catchによる例外処理とは性質が異なるので、注意が必要です。

$.when()による並列連結

$.when()は複数のPromiseをまとめた、新しいPromiseを返します。$.when()が返すものもまたPromiseオブジェクトなので、.then()や.done()や.fail()などが使えます。

$.when(delayHello(), delayHello(), delayHello())
.done(hello1);

3つのPromiseをとりまとめたイメージになります。

$.when()が作るPromiseの構造

$.when()に渡したPromiseがすべて"resolved"になったとき、とりまとめたPromiseが"resolved"になります。逆に言うと、ひとつでも"pending"が残っている限りは処理が進みません。非同期処理を並列実行しつつ、全ての処理が終わるのを待つことができます。

$.when()がresolvedになるケース

$.when() に渡したPromiseのどれか1つでも"rejected"になった場合、とりまとめたPromiseは即座に"rejected"状態になります。"pending"や"resolved"のPromiseが含まれていても関係なしに"rejected"に変化します。

$.when()がrejectedになるケース

なお、$.when()のPromiseが"rejected"になっても、並列実行中の他の処理に関しては引き続き実行されたままです。もし「どれか一つでも失敗すると、他のすべての処理を中断させたい」という場合は、自前でそのロジックを作り、.fail()に登録する必要があります。

もちろん、$.when()が返すPromiseを.then()でつなぎ合わせることもできます。

関数化

.then()のチェインは、好きな部分を切り出して関数化し、再利用可能にすることができます。たとえば以下のような並列直列が入り乱れた複雑な連結でも、

delayHello()
.then(delayHello)
.then(function(){
  return $.when(delayHello(), delayHello(), delayHello());
})
.then(delayHello)
.then(function(){
  return $.when(delayHello(), delayHello(), delayHello());
})
.then(delayHello);

一部を関数化すれば少しすっきりします。

//一部分だけ抜き出して関数化
function delayHelloParallel() {
  return $.when(
    delayHello(), delayHello(), delayHello()
  )
  .then(delayHello);
}

//delayHelloParallelを使って少し簡略化できた!
delayHello()
.then(delayHello)
.then(delayHelloParallel)
.then(delayHelloParallel)

ここまで極端に複雑な連結はあまりないかもしれませんが、やろうと思えばできるのだと覚えておけば、役立つ場面があると思います。

デモ

以上を組み合わせていけば、複雑な非同期処理を書くことができます。

しばらく概念的な話が続いたので、少し実践的な例を挙げてみましょう。次のようなお題を考えます。

Yahoo! JavaScriptマップAPIで、クリックした地点の郵便番号を表示したい

Yahoo! JavaScriptマップAPI単体では、クリックした地点の情報を取得することができるのですが、緯度経度までしかわかりません。ここから郵便番号を調べるには、

  1. リバースジオコーダAPIで緯度経度を住所文字列に
  2. 郵便番号検索APIで住所文字列を郵便番号に

と、2つのWebAPIを順番に使って変換する必要があります。「APIのレスポンスを待って次の処理を行う」ということでjQuery.Deferredの.then()による連結を使ってみました。ソースコードはデモページをご覧ください。

YOLP+jQuery.Deferredの使用例

それなりの行数なので、ここからどう関数化するかを考えてみるのも面白いでしょう。

まとめ

jQuery.Deferredの.then()と$.when()を使いこなすことで、可読性を保ったまま非同期処理を複雑に組み合わせることが可能です。

ライブラリを使わないプレーンなJavaScriptとは違う独特の世界観ですが、jQuery.Deferredを使った世界ではコールバック地獄や例外の問題から解放されます。せっかくjQueryを使っているのであれば、積極的に活用していくといいのではないでしょうか。

Yahoo! JAPANでは情報技術を駆使して人々や社会の課題を一緒に解決していける方を募集しています。詳しくは採用情報をご覧ください。

  • このエントリーをはてなブックマークに追加