Angular.js の $q から Promise を覚えた
ES2015 (ES6) から登場した Promise を今更覚えたというお話。
Promise とは
Promise、もしくは Promise パターンとは、非同期処理を同期的に扱うための仕組みで、非同期に処理する関数を Promise というオブジェクトで包み込んでおくことで、「ある非同期処理が終わったら、次の処理を行う」といった「約束 (Promise)」を取り付けることができるようになるもの。
ES6 において仕様が策定されており、Promise チックなことができるライブラリのほとんどは、この仕様に準拠して実装されている。
Promise がない時代はどうしていたか … コールバック地獄
これまで Ajax など非同期処理の中で「アレが終わったらコレする」をやろうとすると、「アレ」の処理に対して、終わった後に行わせたい「コレ」関数を渡しておき、処理の最後に呼び出す、ということをしてやらないといけなかった。このような扱いをコールバックと呼ぶ。要するに以下のようなコードにしないと、処理の順番を管理できなかったのだ。
function hoge() {
// hoge() の何か非同期処理…
// hoge() が終わったら行わせたい処理 : fuga()
// fuga() には hoge() の実行結果を渡したい
(function fuga(hogeResult) {
// fuga() の何か次の処理…
// fuga() の後にやらせたい処理 : foo()
// foo() には fuga() の実行結果を渡したい
(function foo(fugaResult) {
// foo() の何か処理……
})(fugaResult);
})(hogeResult);
見て分かるとおりインデントが深くなっていく。関数をそれぞれ宣言しておいて平たく並べたとしても、「どの関数からどの関数を呼ぶ (コールバック) すればいいのか」を管理する必要があり、大変だ。
function hoge() {
// hoge() の何か非同期処理…
// hoge() が終わったら行わせたい処理 : fuga()
// fuga() には hoge() の実行結果を渡したい
fuga(hogeResult);
}
function fuga(hogeResult) {
// fuga() の何か次の処理…
// fuga() の後にやらせたい処理 : foo()
// foo() には fuga() の実行結果を渡したい
foo(fugaResult);
}
function foo(fugaResult) {
// foo() の何か処理……
}
// 全て準備したら最初に呼ぶ関数を実行する
hoge();
このとおり。これが、俗にいう「コールバック地獄」と呼ばれる状態のコードだ。
Promise は如何にこの問題を解決するか
じゃあ Promise で実装するとどういう風になるのか、見てもらおう。
function hoge() {
return new Promise(function(resolve, reject) {
// hoge() の何か非同期処理…
// 処理結果として hogeResult を返す
resolve(hogeResult);
});
}
function fuga(inputData) {
// 何か事前処理したければココに書く
// (基本的には同期処理のみ。非同期処理の順序を守るためにこの書き方をするので…)
return new Promise(function(resolve, reject) {
// fuga() の何か次の処理…
// inputData をアレコレして fugaResult を返す
resolve(fugaResult);
});
}
function foo(inputData) {
return new Promise(function(resolve, reject) {
// foo() の何か処理……
if(inputData > 1) {
// inputData をアレコレして結果となる fooResult を返す
resolve(fooResult);
}
else {
例外があった場合はエラーを出力する
reject('inputData は1以上でないとダメ');
}
});
}
// 実行する際は以下のように書く
hoge()
.then(function(hogeResult) {
// 何か前処理が必要ならココに…
// fuga() を呼ぶ
return fuga(hogeResult);
})
.then(function(fugaResult) {
// foo() を呼ぶ
return foo(fugaResult);
})
.catch(function(errorMessage) {
// hoge()・fuga()・foo() のどこかでエラーが起きたらこのブロックに入る
console.log(errorMessage);
});
注目してほしいのは以下の点。
hoge()
・fuga()
・foo()
それぞれの関数内に、お互いの関数名などが登場せず、それぞれの関数が独立している。- 実行する時のコードを簡略化して読むと、
hoge().then( fuga() ).then( foo() )
といった構成になっており、「hoge()
したら (Then)fuga()
する」というように自然言語に近い書き方で読みやすい。いくら処理を繋げてもインデントが増えない。
出来ることは最初のコードと同じだが、より簡潔に、柔軟に非同期処理を扱えるようにしてくれるのが Promise というオブジェクトなのである。
Promise のお約束
Promise を使うためのお約束は簡単。
先ほどの例で、それぞれの関数は、中に function
を持った Promise オブジェクトを return
返していることが分かるだろう。つまり、処理を必ず Promise オブジェクトで包み込むことで、
- 「Promise の中に書かれた処理が終わるまで、Promise 様が待ってあげましょう」
- 「Promise の中の処理が終わったら、
then()
メソッドで次の処理に繋げられるようにしましょう」
というルールになっている。then()
メソッドを持つオブジェクトは Thenable オブジェクトと呼ばれる。
なお、その関数が返却する値は、return hogeResult;
ではなく resolve(hogeResult);
というように、resolve()
というメソッドを使う。resolve()
に渡した値は、その後に繋ぐ .then(function(hogeResult) { })
の部分で受け取れる仕組みになっているので、
hoge()
.then(function(hogeResult) {
return fuga(hogeResult);
})
.then(function(fugaResult) {
return foo(fugaResult);
});
このような芸当が可能になっている。
関数化しているそれぞれの部分を中に展開すると、Promise が必ず返されていることが直感的に分かるようになるだろう。
// 元々の hoge() 処理
new Promise(function(resolve, reject) {
resolve(hogeResult);
})
.then(function(hogeResult) {
// 元々の fuga() 処理
return new Promise(function(resolve, reject) {
resolve(hogeResult);
});
})
.then(function(fugaResult) {
// 元々の foo() 処理
return new Promise(function(resolve, reject) {
resolve(fugaResult);
});
});
なお、reject()
も resolve()
と同様に、値を次のメソッドに返せるが、then()
では受け取れず、catch()
メソッドで受け取れる (厳密には then()
の第2引数に設定した関数で拾えたりするのだが、話を簡単にするためココでは触れない)。
簡単にまとめると、resolve()
は Promise における return
、reject()
は Promise における throw
と思って良い。Promise は return
(= resolve()
) された値を then()
で受け取って次の処理に受け渡すことができるし、throw
(= reject()
) された内容は try / catch
の catch
句ならぬ catch()
メソッドで受け取れる、というワケだ。
ES2015 以前から Promise を実現していたライブラリ
最近はどのブラウザも Promise に対応するようになったが、Promise の仕様を策定しているような段階の時代は、当然ブラウザ実装はなかったので、各ライブラリが頑張って Promise チックな非同期処理の管理を行っていた。
- 参考 : Can I use... Support tables for HTML5, CSS3, etc … Promise は IE11 だけ対応していない。
よく知られているのは jQuery の Ajax 処理だろうか。jQuery v1.4 ぐらいまでは以下のような書き方をしていた。
$.ajax{({
type: 'POST',
url: url,
data: data,
success: function(data, textStatus, jqXHR) {
// Try 区内の最後に呼びたい処理と同等
alert('Success!');
},
error: function(data) {
// Catch 句と同等
alert('Error!');
},
complete: function(jqXHR, textStatus) {
// Finally 句と同等
alert('Complete!');
}
});
これはまだ Promise な書き方ではなく、事前にコールバック関数 (処理が終わったら呼びたい関数) を連想配列で渡しているだけである。
これが jQuery v1.5 になると以下のようにメソッドチェーンが可能になった。
$.ajax({
url: url
}).success(function(data) {
// Try 区内の最後に呼びたい処理と同等
alert('Success!');
}).error(function(data) {
// Catch 句と同等
alert('Error!');
}).complete(function() {
// Finally 句と同等
alert('Complete!');
});
ついでに言うと jQuery v1.8 からはメソッドの名前が変わった。
$.ajax({
url: url
}).done(function(data) {
// Try 区内の最後に呼びたい処理と同等
alert('Done! ← Success');
}).fail(function(data) {
// Catch 句と同等
alert('Fail! ← Error');
}).always(function() {
// Finally 句と同等
alert('Always! ← Complete!');
});
こうした書き方ができるようになったのは、jQuery が jQuery.Deferred()
という仕組みを用意したからで、文字どおり、指定のコールバック関数の実行を延期 (Deferred) させられるようになったから、メソッドチェーンとして書けるようになった、ということ。
jQuery.Deferred()
には .then()
メソッドもあるので、こうした Promise にかなり近い書き方もできる。
$.ajax({
url: "ajax.html",
}).then(
function(data) {
alert('Success!');
},
function(data) {
alert('Error!');
}
);
まだちゃんと紹介していなかったが、Promise と同じく、then()
メソッドの第2引数に渡した関数が、直前の処理のエラーをキャッチしたりできる。
- 参考 : jQueryのDeferredを用いたモダンなAjax処理の書き方 - Hack Your Design! …
jQuery.Deferred()
について。
なお、jQuery.Deferred()
が実現する Promise は、ES6 で策定されている Promise オブジェクトの仕様とは厳密には異なるため、他の Promise 処理と連結する際は変換が必要だったりする。
他にも、「Dojo」というライブラリや、後述する「Q」ライブラリだったり、new Promise()
を IE11 でも解釈させる Polyfill があったりと、ブラウザが実装するまで自前で Promise を何とかするライブラリが数多くあった。
- 参考 : IEでPromiseを利用する - Qiita … es6-promise という Polyfill の紹介。
自前で Promise しようと思ったら… setTimeout()
?
ところで、「ある関数の終了を監視して、その関数の処理が終わったら次の関数を呼び出す」という処理は、setTimeout()
なんかを使えば書けるんじゃないだろうか。
実は既存のライブラリでも、setTimeout()
によって処理の終了を待っていたりする (自身が動作する環境を判別し、Node.js で動作していれば別のやり方を使ったりしていて、他にやりようがなければ setTimeout()
で制御する、というやり方が多い)。自前でやろうとすればできなくもないが、それを規格化してネイティブで対応するようにしたのが Promise というワケだ。
「Q」ライブラリと Angular.js の「$q」サービス
「Q」ライブラリのことは、Angular.js に組み込まれている「$q」サービスから知った。Promise が非同期処理の順序 (キュー) を管理するから「Q」なのだろうか。1文字なので検索しづらい。w
ES6 で策定された new Promise()
するパターンとは異なる、「Deferred/Promise パターン」という実装パターンを採用しており、書き方が若干違う。この書き方だと、既存のメソッドを Promise なメソッドに置き換えるのが容易だったりするかもしれない。
まずは Promise オブジェクトを返す関数を書いてみる。ココでは Angular.js の「$q」サービスを使う前提なので、$q.defer()
などと書いているが、「Q」ライブラリを直接使う場合は Q.defer()
のように読み替えてもらえば良い。
function hoge() {
// コレが「new Promise()」の代わりのようなモノ。$q に Deferred オブジェクトを生成させる
var deferred = $q.defer();
// 何か処理…
// ただの resolve() ではなく、deferred.resolve() に受け渡したい値を持たせる
deferred.resolve(hogeResult);
// Promise オブジェクトは Deferred オブジェクトの中に内包されているので、
// コレを return することで、この関数 hoge() は Promise を返す関数になる
return deferred.promise;
}
通常の関数の最初に var deferred
を宣言し、返したい値は deferred.resolve()
に詰め、return
するのは deferred.promise
にする。先ほどの new Promise()
で書く ES6 Promise と比べると、以下のような違いがある。
- 「Q」ライブラリの場合は Promise オブジェクトを内包する「Deferred」オブジェクトが主体となって、Promise オブジェクトを「操作」している。
- 「Q」ライブラリを使う方が、
new Promise(function(resolve, reject) { })
内に処理を書くより、関数内のインデントが1段低く済ませられている。
ES6 の Promise の場合は、Promise オブジェクトを生成し、そのコンストラクタに引数として渡す関数が、非同期処理をラップしたものでないといけない。そのため、どうしても return new Promise(function...
と書いてからの行が伸びていく傾向にある。hoge()
関数内のインデントが Promise 生成によって1段階増えてしまうのも違和感が残る。
一方、「Q」ライブラリの場合は、関数の最後に return deferred.promise;
とさえすれば良く、hoge()
関数全体を Promise オブジェクト内の関数のように扱えるので、他の同期的な処理のメソッドと見た目に差異が生まれにくい。既存の関数をそのまま Promise 化しやすいと思う。
- 参考 : GitHub - kriskowal/q: A promise library for JavaScript
- 参考 : JavaScriptのPromiseオブジェクトについて調べた事 (11) | QUARTETCOM TECH BLOG
- 参考 : JavaScriptのPromiseオブジェクトについて調べた事 (13) | QUARTETCOM TECH BLOG
- 参考 : AngularJS $q サービスで覚える Promise | Developers.IO
Promise を理解する上で、このような「ライブラリによる実装方法の差異」があったり、そもそも仕様がコレという一つに決めきれていなかったり (そういう時代が割と長くて、ググった時に情報が混在してしまっている)、といったことが障壁になっていると思う。
だからまずは「ES2015 で策定されている Promise の方法と実装方法」だけ学び、まずは Promise 単体の標準仕様を押さえておく。それから jQuery Deferred や Q ライブラリといった、「Deferred が Promise を操作する」というパターンの実装方法を学ぶと、概念的に混乱が生じにくいと思う。