ウェブエンジニア問題集
第13章

非同期処理の基本 — コールバック・Promise・async/await

18
この章の目次開く

「APIからデータを取ってきて表示する」——フロントエンドの仕事の大半はこれです。そしてデータの取得には時間がかかります。0.1秒かもしれないし、3秒かもしれない。結果がいつ届くかわからない処理とどう付き合うかが、この章のテーマである非同期処理です。

JavaScriptの非同期処理は、コールバック → Promise → async/await という順に進化してきました。この章ではその進化を順にたどります。歴史の勉強のためではなく、async/awaitはPromiseの糖衣構文であり、PromiseはコールバックのAPI化である——つまり新しい書き方は古い仕組みの上に乗っているので、順に理解するのが結局いちばんの近道だからです。

学習者学習者

async とか await とか、コードで見かけるたびに雰囲気で読み飛ばしてます…。then が続くコードもあって、何が同じで何が違うのかわからない。

先生先生

その3つは全部「時間のかかる処理の結果を、後から受け取る」ための道具なんだ。道具が3世代あるだけで、やりたいことは1つ。まずは「なぜ待てないのか」から始めよう。

同期処理と非同期処理 — JavaScriptは「待たない」

JavaScriptのコードは、基本的に上から順に1行ずつ実行されます。これを同期処理と呼びます。前の行が終わるまで、次の行は始まりません。

console.log('1番目');
console.log('2番目');
console.log('3番目');
// 必ず 1番目 → 2番目 → 3番目 の順に出力される
js

ここで問題になるのが、JavaScriptはシングルスレッド(一度に1つの処理しかできない)だということです。もしサーバーからのデータ取得(数秒かかるかもしれない)を同期処理で「待って」しまうと、その間ボタンクリックもスクロールも一切反応しない、固まった画面になります。

そこでJavaScriptは、時間のかかる処理を「待たずに」先へ進みます。これが非同期処理です。

console.log('1番目');
 
setTimeout(() => {
  console.log('2番目…のつもり');
}, 1000);
 
console.log('3番目');
 
// 出力順:
// 1番目
// 3番目
// 2番目…のつもり ← 1秒後に最後に出力される
js

setTimeout は「1秒後にこの関数を実行して」という予約だけをして、すぐに次の行へ進みます。予約した関数が実行されるのは、1秒後、かつ今実行中のコードが全部終わってからです。

通知を待つイメージ
「終わったら知らせて」と頼んで、自分は別の作業を進めるのが非同期処理

ここで登場した setTimeout は、非同期処理の最も基本的な入り口なので、構文を押さえておきましょう。

構文: setTimeout(callback, delay?, ...args?)

引数説明
callback(第1引数)時間が経ったら実行する関数
delay(第2引数・省略可)待ち時間(ミリ秒)。省略すると 0
...args(第3引数以降・省略可)callback に渡す引数

戻り値: タイマーID(数値)。clearTimeout(id) に渡すと予約をキャンセルできる

コールバック — 「終わったらこの関数を呼んで」

setTimeout に渡した関数のように、「処理が終わったときに呼び出してもらう関数」をコールバック関数と呼びます。これが非同期処理の第1世代の道具です。

「終わったら知らせてほしい」を素直に実現できる仕組みで、配列メソッドクロージャの章でも登場した、JavaScriptの基本パターンです。

コールバック地獄 — ネストが深くなる問題

コールバックには構造的な弱点があります。非同期処理を順番につなげると、ネストがどんどん深くなるのです。

「ユーザー情報を取得 → そのユーザーの投稿を取得 → 投稿へのコメントを取得」という、実務でよくある「前の結果を使って次を取得する」処理をコールバックで書くと、こうなります。

// getUser などは「結果をコールバックで受け取る」古いスタイルのAPIとする
getUser(userId, (user) => {
  getPosts(user.id, (posts) => {
    getComments(posts[0].id, (comments) => {
      console.log(comments);
      // さらに続けるなら、もっと深くなっていく…
    });
  });
});
js

右へ右へと深くなるこの形は**コールバック地獄(callback hell)**と呼ばれます。見た目が悪いだけでなく、実務では次の点が深刻です。

  • エラー処理が書きにくい。 各階層でエラー用の分岐を書く必要があり、try/catch ではコールバック内のエラーを捕まえられない
  • 処理の流れが追いにくい。 「AのあとB、そのあとC」という単純な流れが、ネストの奥に埋もれる
学習者学習者

たしかに読みにくい…。でもコールバックを渡す以外に、「終わってから次」ってどう書けばいいんだろう?

この問いへのJavaScriptの答えが、次のPromiseです。

Promise — 「未来の結果」を入れる箱

Promiseは、ES2015で導入された「非同期処理の結果を表すオブジェクト」です。処理が終わったときに関数を呼んでもらう(コールバック)のではなく、「結果が入る予定の箱」を先に受け取る、という発想の転換です。

結果を待って選択するイメージ
Promiseは「あとで成功か失敗のどちらかが入る箱」。届いてから中身に応じた処理を選ぶ

Promiseの3つの状態

Promiseは、次の3つの状態のいずれかを取ります。

状態意味
pendingまだ結果が出ていない(初期状態)
fulfilled成功した。結果の値を持っている
rejected失敗した。失敗の理由(エラー)を持っている

Promiseの状態は pending から一度だけ遷移し、fulfilled または rejected になったら二度と変わりません。「成功したあとにやっぱり失敗」はあり得ない、という保証がPromiseの信頼性の土台です。

Promiseを作る — new Promise

Promiseは fetch() など多くのAPIが返してくれるので、自分で作る機会は多くありませんが、仕組みの理解のために作り方も見ておきます。

構文: new Promise(executor)

引数説明
executor(第1引数)(resolve, reject) => { ... } の形の関数。Promise作成と同時に実行される。成功したら resolve(値)、失敗したら reject(エラー) を呼ぶ

戻り値: 新しい Promise オブジェクト

function wait(ms) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(`${ms}ミリ秒待ちました`);
    }, ms);
  });
}
js

コールバックスタイルの setTimeout が、「結果の箱を返す関数」に変わりました。この wait の使い方が次のテーマです。

then・catch・finally — 結果を受け取る

Promiseの結果は、次の3つのメソッドで受け取ります。

メソッド引数戻り値
then(onFulfilled, onRejected?)成功時に呼ぶ関数(と、省略可で失敗時の関数)新しい Promise
catch(onRejected)失敗時に呼ぶ関数新しい Promise
finally(onFinally)成否に関わらず最後に呼ぶ関数(引数なし)新しい Promise
wait(1000)
  .then((message) => {
    console.log(message); // "1000ミリ秒待ちました"
  })
  .catch((error) => {
    console.error(error); // 失敗したときだけ実行される
  })
  .finally(() => {
    console.log('成功でも失敗でもここは実行される'); // ローディング表示の解除などに
  });
js

注目すべきは戻り値の列です。then は新しいPromiseを返すため、.then().then()... と数珠つなぎにできます。これをPromiseチェーンと呼びます。

Promiseチェーン — コールバック地獄の解消

then のコールバックが値を返すと、その値が次の then に渡ります。さらに、Promiseを返すと、そのPromiseの結果が出るまで待ってから次の then に進みます。この性質を使うと、先ほどのコールバック地獄はこう書き直せます。

// getUser などは「Promiseを返す」スタイルのAPIとする
getUser(userId)
  .then((user) => getPosts(user.id))
  .then((posts) => getComments(posts[0].id))
  .then((comments) => {
    console.log(comments);
  })
  .catch((error) => {
    // チェーンのどこで失敗しても、ここで1か所で捕まえられる
    console.error('取得に失敗:', error);
  });
js

ネストは消え、処理は上から下へ一直線に読めます。エラー処理も、各階層に書く代わりに末尾の catch 1つに集約できました。

学習者学習者

then の中で return したものが次の then に渡るんだ…。じゃあ return を書き忘れたらどうなるの?

先生先生

いいところに気づいたね。return がないと次の then には undefined が渡る。「チェーンの途中で値が undefined になった」ときは、まず return の書き忘れを疑うのが定石だよ。

async/await — 非同期処理を「同期処理のように」書く

Promiseチェーンでコールバック地獄は解消されましたが、then の連続はまだ「普通のコード」とは見た目が違います。ES2017で導入された async/await を使うと、Promiseベースの非同期処理を同期処理とほぼ同じ見た目で書けます。

構文: async function 関数名() { ... } / await 式

キーワード書ける場所説明
async関数宣言・関数式・アロー関数の頭その関数を「async関数」にする。中で await が使えるようになる
awaitasync関数の中右側のPromiseが決着するまで待ち、成功ならその値を取り出す。失敗ならエラーを投げる

戻り値: async関数は常にPromiseを返すreturn した値 がそのPromiseの成功値になる

先ほどのPromiseチェーンを書き直してみましょう。

async function showComments(userId) {
  const user = await getUser(userId);
  const posts = await getPosts(user.id);
  const comments = await getComments(posts[0].id);
  console.log(comments);
}
js

then が消え、「取得して、変数に入れて、次に使う」という普通の手続き型のコードになりました。1行ごとに await がPromiseの決着を待ってくれるので、上から順に読めば処理の順序どおりです。

ただし誤解しないでほしいのは、async/awaitは新しい仕組みではなく、Promiseチェーンを読みやすく書くための糖衣構文(シンタックスシュガー)だという点です。await の右側にあるのはPromiseですし、async関数の戻り値もPromiseです。だからこそ、この章で先にPromiseを学びました。

// async関数の戻り値はPromiseなので、外からはthenでもawaitでも受け取れる
async function getMessage() {
  return 'こんにちは'; // ← Promiseで包まれて返る
}
 
getMessage().then((msg) => console.log(msg)); // "こんにちは"
js

エラー処理は try/catch

await しているPromiseが失敗(rejected)すると、その場でエラーが投げられます。つまり、同期処理と同じ try/catch で捕まえられます。

async function showComments(userId) {
  try {
    const user = await getUser(userId);
    const posts = await getPosts(user.id);
    const comments = await getComments(posts[0].id);
    console.log(comments);
  } catch (error) {
    // どのawaitで失敗しても、ここで捕まえられる
    console.error('取得に失敗:', error);
  } finally {
    console.log('ローディング表示を解除'); // 成否に関わらず実行
  }
}
js

Promiseチェーンの catch / finally が、そのまま try/catch/finally に対応しています。try/catch 自体の詳しい挙動やカスタムエラーの作り方は、次のエラーハンドリングの章で掘り下げます。

複数のPromiseをまとめて扱う — Promise.all と仲間たち

「ユーザー情報と通知一覧を同時に取得したい」のように、互いに依存しない複数の非同期処理を扱う場面では、Promise の静的メソッドを使います。

複数の処理を並行して走らせるイメージ
依存関係のない処理は、1つずつ待たずに同時にスタートさせる
メソッド成功する条件成功時の値
Promise.all(promises)すべて成功したとき。1つでも失敗すると即座に失敗結果の配列(渡した順)
Promise.allSettled(promises)常に成功(全員の決着を待つ){ status, value または reason } の配列
Promise.race(promises)最初に決着した1つが成功なら成功、失敗なら失敗最初に決着したPromiseの値
Promise.any(promises)どれか1つでも成功したとき。全滅で失敗最初に成功したPromiseの値

戻り値: いずれも新しい Promise(引数にはPromiseの配列などの反復可能オブジェクトを渡す)

最もよく使うのは Promise.all です。

async function loadDashboard() {
  // 2つの取得を同時にスタートし、両方の完了を待つ
  const [user, notifications] = await Promise.all([
    getUser(userId),
    getNotifications(userId),
  ]);
 
  console.log(user, notifications);
}
js

依存関係のない処理を1つずつ await すると、かかる時間は足し算になります。Promise.all なら並行して走るので、いちばん遅い1つ分の時間で済みます。

よくあるハマりどころ

await のつけ忘れ — 中身ではなくPromiseが返ってくる

async function getUserName(userId) {
  const user = getUser(userId); // ← awaitを忘れた!
  return user.name; // undefined(userはPromiseオブジェクトなので.nameは無い)
}
js

console.log したときに Promise { <pending> } と表示されたら、それは値ではなく箱のほうを掴んでいるサインです。「取得したはずの値が undefined」「Promise { <pending> } が表示される」ときは、まず await のつけ忘れを疑いましょう。

forEach の中の await は待ってくれない

学習者学習者

配列の各要素に対して順番にAPIを呼びたくて、forEach の中に await を書いたんですけど…全然待ってくれないんです。

これは非常によく踏む罠です。await が効くのはそれを直接囲むasync関数の中だけ。forEach に渡したコールバックをasyncにしても、forEach 自身はPromiseを一切待たずに次々とコールバックを呼び出します。

// ❌ 順番に実行されない(forEachは待たない)
ids.forEach(async (id) => {
  await sendRequest(id);
});
console.log('完了'); // ← 全リクエストが終わる前に出力される
 
// ⭕ 順番に実行したいなら for...of
for (const id of ids) {
  await sendRequest(id);
}
console.log('完了'); // ← 全部終わってから出力される
js
// ⭕ 並行でよい場合の定番: map + Promise.all
const results = await Promise.all(ids.map((id) => sendRequest(id)));
js

直列 await で無駄に遅くなる

逆のパターンもあります。依存関係がないのに1つずつ await してしまい、無駄に遅くなるケースです。

// ❌ 2つは無関係なのに、直列で待っている(合計時間 = 足し算)
const user = await getUser(userId);
const news = await getNews();
 
// ⭕ 同時にスタートして両方待つ(合計時間 = 遅いほう)
const [user, news] = await Promise.all([getUser(userId), getNews()]);
js

「後の処理が前の結果を使っているか」が判断基準です。使っていないなら Promise.all にまとめられます。

エラーの取りこぼし — catch のないPromise

catchtry/catch も書かずにPromiseが失敗すると、エラーはどこにも捕まえられず、コンソールに Unhandled promise rejection という警告が出ます。処理は静かに中断し、ユーザーには何も表示されない——発見が遅れやすい不具合の典型です。

Promiseを使う処理には、チェーンの末尾の catch、またはasync関数内の try/catch を必ず用意しましょう。具体的なエラー処理の設計はエラーハンドリングの章で扱います。

ちゃんと使うためのポイント

  • 非同期処理とは、時間のかかる処理を待たずに予約し、終わったら結果を受け取る仕組み。シングルスレッドのJavaScriptが画面を固まらせないための必須装置
  • コールバック → Promise → async/await は別物ではなく同じ仕組みの書き方の進化。async/awaitはPromiseの糖衣構文
  • Promiseは pendingfulfilled / rejected一度だけ遷移する「未来の結果の箱」
  • then は新しいPromiseを返すのでチェーンできる。return を忘れると次に undefined が渡る
  • async関数は常にPromiseを返しawait はPromiseの決着を待って値を取り出す。エラーは try/catch で捕まえる
  • 依存関係のない複数の処理は Promise.all で並行に。順番に処理したいループは forEach ではなく for...of

非同期処理では「失敗」が日常茶飯事です——ネットワークは切れるし、サーバーはエラーを返します。次の章では、その失敗と正面から向き合う**エラーハンドリング**を扱います。try/catch/finally の正確な挙動から、カスタムエラーの設計、この章で学んだ非同期エラーの実践パターンまでを整理します。

参考リンク

JavaScriptクイズに挑戦するこの章で学んだJavaScriptの知識を、4択クイズでアウトプットして定着させよう