JavaScript の async/await の教え方
JavaScript の async/await は、理解不足による誤用が絶えない。
- そもそも非同期という考え方ができない人がいる。
- 一時的に中断されて、別の処理が走り出すことがイメージできない?
- Promise オブジェクトの取り扱いが分からない人がいる。
- 「止まる」のではなく「すぐさま Promise オブジェクトが返ってくる」ということがイメージできない?
- 「async と書けばすぐさま非同期用の関数になる」と思い込んでいる人がいる。
- 「await と書けば何でも待ってくれる」と思い込んでいる人がいる。
- 「async と書いてある関数だけ await で待つべきで、そうでない関数は await を使わない」と思い込んでいる人がいる。
- 型チェックがないために、Promise オブジェクトを受け取ってしまってもなかなか気が付かない。
- Promise の then, catch と async/await をごちゃ混ぜにしてスパゲッティにしてしまう人がいる。
- Promise を受け取るのを解決するために場当たり的に await を付けてしまう人がいる。
まず、そもそも JavaScript がどう動くのかの理解が必要。
JavaScript はシングルスレッドで動き、基本的に、途中で止まることがありません。
(例外は alert 関数などの同期関数)
HTML の script で呼ばれた場合は、ソースを解析後、グローバルスコープの処理(定義文ではなく実行文または式に相当するもの)を上から順に実行してファイル終端まで突き進みます。
(複数の script の場合にはまた色々あるけど一旦無視)
on* (onloadなど)、addEventListener, setTimeout, setInterval で登録された関数は、イベント発生時に呼ばれて、その関数が完了するまで、他の処理は一切動きません。シングルスレッドなので。
関数の中から emit や dispatchEvent でイベントを発行しても、そのイベントはキューに貯まるだけで、実行中の関数が終わるまでは、そのイベントは処理されません。
このため、長時間かかる処理を実装するには、時々中断しながら実行し、
完了またはエラー時にコールバックをする必要があります。
// メモ(未完成)
function tooLongProcImpl(ctx) {
while (ctx.curCount < ctx.count) {
// 長い処理
ctx.curCount++;
if (ctx.curCount % 1000 == 0) {
// 処理を時々打ち切って別の処理に回す。
setTimeout(function() { tooLongProcImpl(ctx); }, 0);
}
}
// return の代わりに callback を呼ぶ。これで非同期になる。
ctx.callback(result);
}
function tooLongProc(count, callback) {
ctx = { curCount: 0, count: count, callback: callback };
tooLongProcImpl(ctx);
}
このため、非同期処理を連鎖的に行おうとするとコールバックの連鎖が必要となり、すぐに「コールバック地獄」に陥ることになりました。
コールバック地獄をちょっとだけ楽にするために、Promise という共通インターフェースが考え出されました。
Promise は「約束」の名の通り、いつか処理結果が得られることを約束します。
Promise を渡す側は、処理が完了したときには resolve を呼び、失敗したときには reject を呼びます。
// 書きかけ
function fooAsyncMethod(param,...) {
return new Promise((resolve, reject) => {
try {
// 時間の掛かる処理
resolve(result); // 成功時
} catch (e) {
reject(e); // 失敗時
}
});
}
Promise を受け取った側は、then と catch をメソッドチェーンで書くことで、非同期に処理を続けることができるようになりました。
(メソッドチェーンで書ける、ということは then も catch も Promise を返してくるということです。)
fooAsyncMethod(...)
.then((result) => {
// 成功時の処理
})
.catch((e) => {
// 失敗時の処理
})
しかし、これでも結局、連鎖的に、条件判断やループを入れて非同期処理をしようと考えると複雑なネストが必要になり、Promise 地獄になるだけでした。
(一応、catch は最後にまとめられる。)
詳細は以下
コールバック地獄を逃れたプログラマを待っていたのは、また Promise 地獄だった。
というわけで、Promise の待ち合わせを直感的にする async/await という文法が用意されました。
元々は C# での発明品を JavaScript に導入したものです。
async の呼び方は「エイシンク」
await の呼び方は「アウェイト」
が正しいとのこと。
→ https://ufcpp.net/blog/2018/1/await/
async として宣言された関数の中では、await が使えるようになり、Promise を返してきた関数が結果を返すまで待つようになります。エラーの場合は catch 文で受け取れるようになります。
これで人間が直感的に理解しやすい構文になりました。
async function fooCaller() { // 返り値は Promise(!)
try {
// 連続処理が直感的になった
const fooResult = await fooAsyncMethod();
for (let i = 0; i < fooResult; i++) { // こういうのを書くのがとても難しかった
const barResult = await barAsyncMethod(i);
console.log(barResult);
}
} catch (e) {
// 失敗ケースは例外1つでまとめられる。
throw e; // 例外は throw で投げてOK
}
return result; // 結果は returnで返してOK
}
async 関数で返ってくるのは、ここの result ではなく、result が返ってくる Promise オブジェクトです。(要注意)
詳細は以下
さて、ここで、あまりに普通の構文そっくりになってしまったために、逆に混乱する人達が出て来ました。
ここまでの経緯が理解できていれば、以下が間違いであることが分かると思います。
- × async を付けたら何でも非同期関数になる。
- ○ await がないなら「すぐに結果を返す Promise オブジェクト」が返ってきます。
- × await を付けたら何でも待ち合わせてくれる。
- ○ Promise を返さない関数では待ちません。
- × async が付いていない関数では await で待つ必要がない。
- ○ async が付いていない関数でも Promise が返ってくることがあります。
- ○ Promise が返ってくるなら await で待つか、Promise.prototype.then, Promise.prototype.catch で待ち受ける必要があります。
制約として以下があることが分かると思います。
- async/await は Promise インターフェースを簡便に取り扱うための文法で、Promise インターフェースがないところでは使えません。
- async 関数はすぐさま Promise を返します。(関数自体は待たない)
- async を付けないと await は使えません。
- つまり、Promise.prototype.then, Promise.prototype.catch を使う必要があります。
- async を付けるなら、await がなければ意味がありません。(インターフェースの共通化としては役に立つこともある。)
- then, catch で Promise を処理してしまうと、await をしないため、async はうまく機能しません。then, catch と async/await は基本的に混ぜて使うべきではありません。