async/await は Promise の糖衣構文
async/await は新しい非同期処理の仕組みではありません。Promise の上に乗った糖衣構文で、非同期のコードを あたかも 同期処理のように書けるようにするものです。中身はまったく同じで、見た目だけがぐっと扱いやすくなっている、ということですね。
同じ処理を両方の書き方で並べてみましょう。
どちらの関数も Promise を返し、やっていることはまったく同じです。ただし async 版は .then チェーンなしで上から下へ素直に読めます。これこそが async/await の魅力です。
async は「Promise を返す関数」を示す目印
function やアロー関数、メソッドの頭に async を付けると、次の2つの効果があります。
- その関数は 必ず Promise を返すようになります。
returnした値がそのまま解決値になります。 - 関数の中で
awaitが使えるようになります。
result は文字列そのものではなく、文字列へと解決される Promise になっている点に注目してください。greet の中には await も非同期処理も一切ないのに、async キーワードが付いているだけで戻り値は自動的に Promise に包まれます。関数が例外を投げれば、その Promise は reject されます。
await は Promise が解決するまで処理を一時停止する
async 関数の内部で await somePromise と書くと、その Promise が解決するまで関数の実行が一時停止し、解決された値が返ってきます。Promise が reject された場合は、await がそのまま例外を投げます。
出力順に注目してみてください。"カウントダウンを開始しました" は "2" より先に表示されます。これは await が一時停止させるのは async 関数の中だけで、プログラム全体を止めているわけではないからです。イベントループはそのまま回り続け、countdown の方は各 wait の Promise が解決したタイミングで再開されるわけです。
await は Promise っぽいものなら何でも受け取れます。await 42 みたいな書き方も実はOKで、Promise でない値は Promise.resolve(42) でラップされて、即座に解決されます。
try/catch でエラーハンドリング
素の Promise なら .catch() をチェーンしていくところですが、async/await を使うと拒否された Promise はそのまま例外として投げられるので、いつもの try/catch でキャッチできます。
同じ try/catch の中に書いた await は、すべてひとつのブロックでまとめて拾えます。ネットワークエラー、JSON のパースエラー、自分で throw した例外も、全部同じ catch に飛んでくる。ネストした .then/.catch を書き連ねていた頃と比べると、これはかなり大きな進化です。
ただし注意点がひとつ。fetch が reject するのはネットワークエラーのときだけで、HTTP の 4xx/5xx ではエラーになりません。自分で res.ok をチェックして throw する —— 実務のコードで嫌になるほど見かけるパターンです。
ループ内で安易に await しない
これは async/await で一番ハマりやすい落とし穴です。ループの中でそのまま await を書くと処理が直列になり、毎回前のイテレーションが終わるまで待つことになります。
sequential は900ms前後、parallel は300ms前後で終わります。基本ルールとしては、タスク同士に依存関係がなければ全部一気にキックしてから await Promise.all でまとめて待つこと。1つずつ await するのは、次の呼び出しに前の結果が本当に必要なときだけで十分です。
配列を処理するときの定番は Promise.all(items.map(async (x) => ...)) のパターンです。for...of の中で await する書き方だと直列実行になります。レート制限や順序制御のためにわざとそうしたい場面もありますが、たいていは避けたほうが無難です。
async/await と通常の Promise を組み合わせる
どちらか一方に統一する必要はありません。async 関数は Promise を返しますし、await はどんな Promise にも使えるので、自由に混ぜて書けます。
どちらのスタイルを使っても構いません。上から下へ素直に読ませたいときは await、サクッと単発で済ませたいときや async の外で書きたいときは .then が便利です。
トップレベル await(ES モジュール内)
以前は await をスクリプトのトップレベルで書くことができず、必ず async 関数で包む必要がありました。しかし今は状況が変わり、ES モジュール(.mjs ファイルや <script type="module">)の中であれば、トップレベルで直接 await を書けるようになっています。
// ES モジュール内
const res = await fetch("https://jsonplaceholder.typicode.com/users/1");
const user = await res.json();
console.log(user.name);
トップレベルの await を使うと、awaitしている Promise が解決するまでモジュール自体の読み込みが完了しません。そのモジュールを import する側もその分待たされることになります。設定ファイルの読み込みや動的 import には便利ですが、使いどころは選びましょう。遅いトップレベル await は、そのモジュールを import するすべての箇所をブロックしてしまいます。
CommonJS のファイルや通常のインラインスクリプトでは、これまで通り SyntaxError になります。昔からある回避策は、即時実行の async 関数でくくってしまう方法です。
ハマりやすいポイント
よくある落とし穴をざっとまとめておきます。
asyncの付け忘れ。 普通の関数の中でawaitを使うと構文エラーになります。関数にasyncを付けるか、async な関数を呼び出して.thenで受けるようにしましょう。- 戻り値に
awaitを付け忘れる。const data = getJSON(url);と書くと、受け取れるのは Promise であってデータそのものではありません。値として扱うと、出力に[object Promise]が出てきて驚くことになります。 - キャッチされない reject。
doWork();のように投げっぱなしで呼び出した async 関数は、.catchを付けるかtry/catchの中でawaitしない限り、エラーを黙って握りつぶします。 forEachに async コールバックを渡すケース。array.forEach(async (x) => await something(x))は実は何も待ってくれません。forEachは返ってきた Promise を無視してしまうからです。for...ofとawaitを組み合わせるか、Promise.all(array.map(...))を使いましょう。
実行してみてください。broken は待たずに return してしまうので、"完了" が出る前に "終わった?" が表示されます。一方の fixed はすべての処理を待ってから、最後に "終わった!" を表示します。
async/await はいつ使うべきか
非同期処理を複数ステップ順番に実行する場合や、try/catch でエラーハンドリングしたい場合は、基本的に async/await を選んでおけば間違いありません。一方で、ワンライナーで済むような単純なケース、ライブラリ内で Promise をそのまま返すだけで await する必要がないケース、あるいは Promise.race や .finally() のようなコンビネータをチェーンで使いたいケースでは、素の Promise のままでも十分です。
うまく使えば、async/await で書かれた非同期コードはレシピのように読めます。これをやって、次にこれ、最後にこれ、という具合です。裏でイベントループが動いているのは変わりませんが、コールバックの入れ子から解放されるのが嬉しいところです。
次は fetch API
ここまでの例では、「何らかの非同期処理」の代役としてずっと fetch を使ってきました。せっかくなのでちゃんと取り上げておきたいところです。リクエストとレスポンスの仕組み、JSON の扱い方、ヘッダーの設定、そして fetch が HTTP エラーで reject されない理由など。次のページで解説します。
よくある質問
async/awaitって結局なにをしてくれるの?
ひとことで言うと、Promiseを同期コードっぽい見た目で書けるようにする構文です。関数にasyncを付けるとその関数は必ずPromiseを返すようになり、中でawaitを書くと指定したPromiseが決着(resolve / reject)するまでその場で一時停止して、解決された値を返してくれます。裏側で動いているのはあくまでPromiseなので、仕組みが変わるわけではなく「読みやすくなるだけ」というイメージでOKです。
async関数の外でawaitは使える?
ESモジュールのトップレベルでなら使えます。いわゆる「トップレベルawait」です。一方、普通の関数の中やCommonJSのスクリプトではawaitは構文エラーになります。対処法としては、コードをasync関数でラップして呼び出すか、ファイル自体をESモジュール(.mjsや"type": "module")に切り替えるのが定番です。
async/awaitでのエラー処理はどう書く?
基本はawaitしている箇所をtry/catchで囲むだけです。awaitしたPromiseがrejectされると、それは例外としてthrowされるのでcatchブロックで拾えます。一方、awaitせずに投げっぱなしにしているバックグラウンド処理には、必ず.catch()を付けておきましょう。そうしないとunhandled rejectionになって後で痛い目を見ます。
awaitするとプログラム全体が止まる?
止まりません。awaitで一時停止するのはあくまで「そのasync関数の中」だけです。イベントループ自体は動き続けるので、タイマーは発火するし、ほかの非同期処理も進むし、UIもフリーズしません。呼び出し元にはその場でpending状態のPromiseが返り、処理はそのまま先に進みます。