Edited at

結局jQuery.Deferredの何が嬉しいのか分からない、という人向けの小話

More than 5 years have passed since last update.

結局jQuery.Deferredの何が嬉しいのか分からない、という人向けの小話

一年ほど前に JavaScript - jQuery.Deferredを使って楽しい非同期生活を送る方法 - Qiita [キータ] という記事を書きました。

で、一年経って、ふと、「もっと分かりやすくjQuery.Deferredの便利さを説明できるんじゃないか」と思い立ってざざざっと書いてみました。

小話と言うにはちょっと長いけど。

--

jQuery.Deferredを使うと嬉しいのは、jQuery.Deferredの仕様を満たす部品同士を簡単に組み合わせることが可能だからです。中には処理を書き下すことができるとかコールバックのネストを防げるのがいいとか言う人もいますが、個人的にこっちのほうがよっぽど重要だと感じます。

例えるならレゴブロックです。レゴブロックはあの凸と凹を持ってるブロックを自由に組み合わせて、実に様々な物を表現することができますよね。まさにあんな感じです。

と言ってもなかなかピンとこないと思うので、具体例を使って説明しましょう。


jQuery.Deferredを使わない場合

注:これから書くのはダメなJavaScriptコードなので真似しないでください。

ここに一つのリンクがあります。

<a href="/path" id="link">リンク</a>

このボタンがどれくらいクリックされているのか計測したくなりました。そこであなたはサーバにリンククリックを計測するためのAPIを作成し、こんな風にJavaScriptのコードを書きました。

$('#link').on('click', function (e) {

e.preventDefault(); // リンク遷移を一旦中止
$.ajax({
, // 自前の計測APIを叩く
success: function () {
// XHRが終わったら改めて遷移させる
document.location = e.target.href;
}
});
});

ある日、会社の上司に突然「Mixpanelってのが便利らしいからそっちも使ってみて」と言われました。曰く「Mixpanelと自前の両方で計測して、あとで比較したい」らしいです。

とりあえずこんな感じに実装しました。( $('#link') とかは冗長なので省略します)

$.ajax({

, // 自前の計測APIを叩く
success: function () {
// mixpanelにデータを送る
mixpanel.track('click', {}, function () {
// 自前APIとmixpanelへの送信が終わったら遷移させる
document.location = e.target.href;
});
}
});

しばらくすると今度は「Google Analyticsも(ry」と言われました。

やれやれ、コードをこう修正しました。

$.ajax({

, // 自前の計測APIを叩く
success: function () {
// Mixpanelにデータを送る
mixpanel.track('click', {}, function () {
// Google Analyticsへデータを送る
ga('send', 'event', 'link', 'click', {
hitCallback: function () {
// 自前APIとmixpanelとgaへの送信が終わったら遷移させる
document.location = e.target.href;
}
});
});
}
});

するとユーザから「リンクをクリックしたときの反応が悪い」と苦情が出ました。それもそのはず、リンククリックしたら「自前API」→「Mixpanel」→「Google Analytics」と順番に通信を行なって、初めてリンク遷移を実行するからです。

次なる課題は、この3つの通信を並列実行させることですが、さてさてどうしようかなーと考えているあなたのもとに驚くべき情報がもたらされました。「特定のAdblock系プラグインを入れている環境ではmixpanelなどへの通信が行われずコールバックも実行されない」。つまり3つのうちどれかが通信に失敗しても大丈夫なようにしなければなりません。

うわーーーもうやってられん!!!


jQuery.Deferredを使う場合

先ほどの事例をjQuery.Deferredを使うとどのようにうまい具合に実装できるか見て行きましょう。

最初に「レゴブロックを組み合わせるみたいに書ける」と書きました。とりあえず「jQuery.Deferredを使った自前APIにデータを送る関数」、「jQuery.Deferredを使ったMixpanelにデータを送る関数」、「jQuery.Deferredを使ったGoogle Analyticsにデータを送る関数」を用意することから始めましょう。

ところで、Javaとかを使っている人には馴染み深いと思いますが、このようなある振る舞いを実装しているオブジェクトを、インタフェースを備えているとか、実装していると言います。なので、以後「jQuery.Deferredを使っ」ていることを「Deferredインタフェースを備えている」と表現することにします。


復習:Deferredインタフェースの実装方法

Deferredインタフェースを備えた関数を実装するのは極めて簡単です。守らなければならないのはたった1つだけ。それは jQuery.Deferred#promise を返すこと それだけです。


Deferredインタフェースを備えた最も単純な関数

var dfdFunc = function () {

var dfd = jQuery.Deferred();
return dfd.promise();
};

promise は状態を持っていてこのままでは初期状態である「実行中」状態になります。普通は resolvereject で状態を確定させます。resolveは正常に終了した状態、rejectは失敗した状態を表します。


引数に応じて状態が変化するDeferredインタフェースを持った関数

var dfdFunc = function (cond) {

var dfd = jQuery.Deferred();
cond ? dfd.resolve() : dfd.reject();
return dfd.promise();
};

jQuery.Deferredというと非同期処理に使うものだという認識が一般的だと思いますが、これだって立派なDeferredインタフェースを備えた関数なのです。

dfdFunc(true)

.done(function () { console.log('Resolved!'); })
.fail(function () { console.log('Rejected!'); });
// => Resolved!

promise#donepromise#fail はそれぞれ resolvedrejectedpromise に対して実行されるコールバックを設定するメソッドです。


非同期関数を含む場合

非同期関数の場合は promise の状態を確定するのが非同期関数のコールバックの中になります。

例えばMixpanelへのデータ送信をDeferred化するとこうなります。 mixpanel.track の第1引数と第2引数は気にしないでください。ここで重要なのは、データを送信が完了したら第3引数の関数を実行する、ということだけです。


Deferredインタフェースを備えたmixpanel関数

var dfdMixpanel = function (event, props) {

var dfd = jQuery.Deferred();
mixpanel.track(event, props, function () {
dfd.resolve();
});
return dfd.promise();
};

簡単ですね。


さきほどの事例をjQuery.Deferredを使って実装してみる

「jQuery.Deferredを使わない場合」で取り上げた事例をjQuery.Deferredを使うとどう解決できるか見て行きましょう。

まずは「自前APIだけにデータを送っている」頃のコードです。実は jQuery.ajax はDeferredインタフェースを備えています。なので、以下のように書けます。

$.ajax({  }) // 自前APIにデータを送る

.done(function () {
// XHRが終わったら改めて遷移させる
document.location = e.target.href;
});

さて次にMixpanelにもデータを送ります。先ほど作った dfdMixpanel を使うとこのように書けますね。

$.ajax({  }) // 自前APIにデータを送る

.done(function () {
dfdMixpanel('click', {}) // mixpanelにデータを送る
.done(function () {
// 自前APIとmixpanelへの送信が終わったら遷移させる
document.location = e.target.href;
});
});

しかしこれだと結局どんどん階層が深くなっていってしまっています。このように done の中で done を実行するような場合は、代わりに then を使うといいでしょう。 then を使うと先ほどのコードは次のように書き換えられます。

$.ajax({  }) // 自前APIにデータを送る

.then(function () {
return dfdMixpanel('click', {}) // return を忘れないように
})
.done(function () {
// 自前APIとmixpanelへの送信が終わったら遷移させる
document.location = e.target.href;
});

次はGoogle Analyticsです。 dfdGa という関数を作ってあるとします。

$.ajax({  })  // 自前APIにデータを送る

.then(function () {
return dfdMixpanel('click', {}); // mixpanelにデータを送る
})
.then(function () {
return dfdGa('link', 'click'); // Google Analyticsにデータを送る
})
.done(function () {
// 自前APIとmixpanelとGoogle Analyticsへの送信が終わったら遷移させる
document.location = e.target.href;
});

さて、ここまではjQuery.Deferredを使わない場合でもなんとか書くことができました。この時点で既に元々のコードより読みやすいという利点はあると思うのですが、Deferredの真価が発揮されるのはここからです。


並列化

まずは自前APIとMixpanelとGoogle Analyticsへのデータ送信を並列化しましょう。

Deferredインタフェースを備えた関数を並列実行するのは実に簡単です。 jQuery.when を使うだけです。

$.when(

$.ajax({ }), // 自前APIにデータを送る
dfdMixpanel('click', {}), // mixpanelにデータを送る
dfdGa('link', 'click') // Google Analyticsにデータを送る
).done(function () {
// 自前APIとmixpanelとGoogle Analyticsへの送信が終わったら遷移させる
document.location = e.target.href;
});

そしてよくよくみてみると jQuery.when の返り値に done を実行していますね?つまり jQuery.when もDeferredインタフェースを備えていることが分かります。


エラー処理

続いて特定条件下でMixpanelにデータが送れない問題を解決しましょう。やるべきことはデータ送信に失敗した場合は dfdMixpaneldfdGa が返した promisereject する、それだけです。

実装方法は色々考えられると思いますが、ここでは1秒間待って、データ送信が完了しなかった場合は reject することにします。 dfdMixpanel を次のように書き換えます。

var dfdMixpanel = function (event, props) {

var dfd = jQuery.Deferred();
mixpanel.track(event, props, function () { dfd.resolve(); });
setTimeout(function () { dfd.reject(); }, 1000);
return dfd.promise();
};

「これだと resolve した後に reject も実行されるんじゃない?」と思った方もいると思いますが、 promise は一旦 resolvereject されたらそれ以降状態が変化しなくなるので、これで問題ありません。

こんな感じのタイムアウト処理を自前APIとGoogle Analytics送信にも実装したら、done の代わりに always を使うようにして完了です。 always は resolve でも reject どちらの場合も実行される関数を登録するためのものです。

$.when(

dfdAjax(), // 自前APIにデータを送る
dfdMixpanel('click', {}), // mixpanelにデータを送る
dfdGa('link', 'click') // Google Analyticsにデータを送る
).always(function () { // doneの代わりにalwaysを使う
// 自前APIとmixpanelとGoogle Analyticsへの送信が終わったら遷移させる
// ただし、どれか1つでも送信に失敗したら即座に遷移する
document.location = e.target.href;
});


jQuery.Deferredの便利さを言葉にする

さてjQuery.Deferredを使うと、一見複雑な処理を、簡単に書けることがなんとなく分かったと思います。この「なんとなく分かった」jQuery.Deferredの便利さを、きちんと言葉にしてみます。


処理が関数の中に隠蔽される

jQuery.Deferredだから、という訳ではないですが、意識しなくても関数の中に処理がひとまとめにされるので、メンテナンス性が高まると言えます。


インタフェースが統一される

ここまで読んだ人なら分かると思いますが、jQuery.Deferredは「成功」と「失敗」になりうる関数のための統一されたインタフェースを提供してくれます。もちろん非同期処理に使うのが特に便利なわけですが、非同期関数・同期関数問わず同じように扱えることが便利です。

また、いちいちインタフェースを考える手間が省けるので開発速度も向上します。


非同期処理のエラー処理が容易

JavaScriptには try〜catch 文がありますが、非同期処理では非常に扱いにくいです。

var asyncError = function () {

setTimeout(function () {
throw "Error";
}, 0);
};

try {
asyncError();
} catch (e) {
// キャッチされない
}
//=> Uncount Error

非同期関数の中でエラー処理をしようと思ってエラーを throw しても、その非同期関数を try〜catch してもエラーを補足できないのです。仮に正常系と異常系の2つのコールバックを受け付けるようにしたとしても、例えば finally のような機能をもたせるのはなかなか難しいです。

このような場合もDeferredインタフェースなら単に reject すればよく、呼び出し側で finally 的なことをやりたければ always で実現できます。


再利用性が高まる

「処理が関数の中に隠蔽」され、「インタフェースが統一」されると再利用性が高まります。


jQuery.Deferredを使わない方がいい場面

ここまでベタ褒めしてきたjQuery.Deferredですが、なんでもかんでもDeferred化すればいいというものではないです。非同期関数はとりあえずDeferred化してしまえばいいですが、同期関数は場合によりけり。

じゃあどういう同期関数をDeferred化すべきかといえば、それはDeferred化された非同期関数と同じように扱いたい関数です。具体的には、キャッシュが無ければ非同期にサーバからデータを取ってきて、キャッシュがあったらそれを使う、というような場合、キャッシュからデータを取ってくる処理をDeferred化するとよいでしょう。実装については JavaScript - jQuery.Deferredを使って楽しい非同期生活を送る方法 - Qiita [キータ] にちょろっと書いてあるので、そちらを参照してください。


おまけ

また上司がやってきました。

「MixpanelとGoogle Analyticsは並列でいいが、自前APIは直列にしたい」

Deferred化された今、恐れることは何もありません。

dfdAjax()

.then(function () {
return $.when(dfdMixpanel(), dfdGa());
})
.always(function () {
document.location = e.target.href;
});

「あー順番が逆だ、MixpanelとGoogle Analyticsが先で、自前APIが後。」

$.when(dfdMixpanel(), dfdGa())

.then(dfdAjax)
.always(function () {
document.location = e.target.href;
});

「ぐぬぬ…!!」

歯ぎしりする上司を尻目に、今日も華麗に定時退社を決めたのでした。

おわり