【JavaScript】非同期処理でハマりやすい罠と対処法

javascript-icon JavaScript

こんにちは、かつコーチです。

前回はundefinednullの違いを解説しました。

今回のテーマは、多くの学習者が一度は必ずつまずく「非同期処理」です。

async/awaitPromiseの書き方自体は覚えても、いざ実務で使うと「あれ、なんで順番通りに動かないんだろう」と混乱することがよくあります。

今回は非同期処理特有の罠と、その対処法を実例を交えて解説していきます。

非同期処理の基本をおさらい

同期処理と非同期処理の違い

同期処理は、コードを書いた順番に上から下へ実行される処理です。

一方非同期処理は、「時間のかかる処理」を裏で走らせつつ、その完了を待たずに次のコードを実行してしまう処理のことです。

console.log('1番目');

setTimeout(() => {
  console.log('2番目(1秒後)');
}, 1000);

console.log('3番目');

// 出力順:1番目 → 3番目 → 2番目(1秒後)

コードの見た目上は「1番目→2番目→3番目」の順に見えますが、実際の出力は「1番目→3番目→2番目」になります。

setTimeoutの中身は「1秒後に実行してね」と予約されるだけで、その間もJavaScriptは次の行を先に実行してしまうためです。

Promiseとasync/awaitの関係

Promiseは「非同期処理の結果を後から受け取る箱」のようなものです。

function fetchUser() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ name: 'かつコーチ' });
    }, 1000);
  });
}

fetchUser().then((user) => {
  console.log(user.name); // 1秒後に「かつコーチ」
});

async/awaitは、このPromiseをより同期処理っぽい見た目で書けるようにする糖衣構文です。

async function showUser() {
  const user = await fetchUser();
  console.log(user.name); // 1秒後に「かつコーチ」
}

showUser();

awaitは「このPromiseが完了するまで、この関数の中だけ処理を一時停止する」という意味になります。

罠1:forEachの中でawaitが効かない

かつコーチが実際にハマった話

私が実務で一番時間を溶かしたのが、forEachの中でawaitを使ったときの挙動です。

複数のユーザーIDを順番にAPIへ問い合わせて、結果を1つずつ処理したいというよくある場面でした。

❌ Before:forEachの中でawaitを使う

async function fetchUserById(id) {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}

async function showAllUsers(ids) {
  console.log('開始');

  ids.forEach(async (id) => {
    const user = await fetchUserById(id);
    console.log(user.name);
  });

  console.log('終了');
}

showAllUsers([1, 2, 3]);
// 出力順:開始 → 終了 → (バラバラのタイミングで)各ユーザー名

「開始→ユーザー名を順番に表示→終了」という順番になることを期待していたのに、実際には「開始→終了」が先に表示され、ユーザー名は後からバラバラの順番で出てきました。

原因は、forEachが渡されたコールバック関数の完了を待たない仕様になっているためです。

forEachは各要素に対してコールバックを呼び出すだけで、その中でawaitがあってもforEach自体はすぐに次のループへ進んでしまいます。

結果としてshowAllUsers関数は、中の非同期処理が終わるのを待たずに「終了」まで実行し切ってしまうのです。

✅ After:for…ofループを使ってawaitを正しく効かせる

async function fetchUserById(id) {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}

async function showAllUsers(ids) {
  console.log('開始');

  for (const id of ids) {
    const user = await fetchUserById(id);
    console.log(user.name);
  }

  console.log('終了');
}

showAllUsers([1, 2, 3]);
// 出力順:開始 → ユーザー1の名前 → ユーザー2の名前 → ユーザー3の名前 → 終了

for...ofループなら、ループ自体がasync関数の中にあるためawaitが正しく効き、1件ずつ処理が完了してから次のループに進みます。

「順番に1件ずつ処理したい」場面ではforEachではなくfor...ofを使う、と覚えておくとこの罠にはハマらなくなります。

並列で処理したい場合はPromise.allを使う

順番を気にせず、全部同時に処理を走らせて構わない場合はPromise.allを使うと高速です。

async function showAllUsersParallel(ids) {
  console.log('開始');

  const users = await Promise.all(ids.map((id) => fetchUserById(id)));
  users.forEach((user) => console.log(user.name));

  console.log('終了');
}

ids.mapで複数のPromiseをまとめて作り、Promise.allで全部の完了を待ってから次に進む、という書き方です。

順番を保証したいならfor...of、速度を優先するならPromise.all、と目的によって使い分けましょう。

罠2:try…catchでawaitのエラーを捕まえ忘れる

エラーがcatchされずに落ちてしまう例

awaitで発生したエラーは、try...catchで囲まないと関数の外まで伝播してしまいます。

❌ Before:try…catchで囲んでいない

async function fetchUserById(id) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) {
    throw new Error('ユーザー取得に失敗しました');
  }
  return res.json();
}

async function showUser(id) {
  const user = await fetchUserById(id);
  console.log(user.name);
}

showUser(999);
// Uncaught (in promise) Error: ユーザー取得に失敗しました

APIが404などのエラーを返した場合、throwされたエラーがどこにもキャッチされず、コンソールに「Uncaught (in promise)」という形で表示されてしまいます。

ユーザーには何のフィードバックも出せないまま、処理だけが止まってしまう状態です。

✅ After:try…catchでエラーハンドリングする

async function fetchUserById(id) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) {
    throw new Error('ユーザー取得に失敗しました');
  }
  return res.json();
}

async function showUser(id) {
  try {
    const user = await fetchUserById(id);
    console.log(user.name);
  } catch (error) {
    console.error('エラーが発生しました:', error.message);
    // 画面にエラーメッセージを表示するなどのフォローができる
  }
}

showUser(999);
// エラーが発生しました: ユーザー取得に失敗しました

try...catchで囲むことで、エラー発生時に画面へメッセージを表示したり、再試行を促したりといった適切なフォローができるようになります。

async関数を書くときは、「エラーが起きる可能性のあるawaitには必ずtry...catchをセットで考える」という習慣をつけておくと安全です。

罠3:Promiseを返し忘れて呼び出し元でawaitできない

関数の中でawaitを忘れるパターン

自作の関数がPromiseを返しているつもりで、実はreturnし忘れているケースもよくあります。

async function fetchAndLog(id) {
  fetchUserById(id).then((user) => {
    console.log(user.name);
  });
  // returnしていないので、この関数はundefinedを返すのと同じ扱いになる
}

async function main() {
  await fetchAndLog(1);
  console.log('完了');
  // fetchAndLogの中身が終わる前に「完了」が出てしまう
}

fetchAndLogの中で.thenを使って非同期処理を行っていますが、そのPromisereturnしていません。

そのため呼び出し元でawait fetchAndLog(1)としても、実際にはfetchAndLog関数自体の処理(何も返さない)が終わった時点ですぐに次へ進んでしまいます。

非同期処理を行う関数を自作するときは、その中のPromiseをきちんとreturnしているか、常に確認する癖をつけましょう。

まとめ

この記事のポイント

  • forEachの中でawaitを使っても待ってくれないため、順番に処理したいときはfor...ofを使う
  • 並列で処理して構わない場合はPromise.allを使うと速度面で有利
  • awaitのエラーはtry...catchで囲まないと捕捉されずに落ちてしまう
  • 非同期処理を行う自作関数は、内部のPromisereturnし忘れていないか確認する
  • 「順番を保証したいか」「速度を優先したいか」で書き方を使い分ける

次に読むべき記事

非同期処理の罠を理解したら、次は環境によって挙動が変わる「ブラウザ互換性」の考え方を見ていきます。

→ 次の記事:ブラウザ互換性対応の考え方

タイトルとURLをコピーしました