エンジアップ エンジアップ

もう迷わない。ITエンジニアのための総合情報サイト

JavaScriptの非同期処理を完全理解する:コールバックからasync/awaitまで
投稿
X LINE B! f

JavaScriptの非同期処理を完全理解する:コールバックからasync/awaitまで

「なんとなく動く」から卒業する

JavaScriptを書き始めてしばらく経つと、asyncawait という書き方を目にする機会が増えてきます。コピペで動かすことはできても、「なぜこう書くのか」「どういう順番で処理されるのか」まで自信を持って説明できる方は、意外と多くありません。

非同期処理はJavaScriptの根幹をなす仕組みです。ここを理解せずに進むと、「なぜか処理の順番がおかしい」「データを取得したはずなのに undefined になっている」「Promise が返ってくる意味がわからない」といった壁に何度もぶつかることになります。この記事では、非同期処理がなぜ必要なのかというところから始めて、コールバック・Promise・async/await という進化の流れを追いながら、現代的な書き方をしっかり身につけることを目標にします。

なぜJavaScriptに非同期処理が必要なのか

シングルスレッドという前提

JavaScriptはシングルスレッドで動作する言語です。これは「一度にひとつのことしかできない」という意味で、もし時間のかかる処理(サーバーへのリクエストやファイルの読み書きなど)を同期的に実行してしまうと、その間ずっと画面が固まってしまいます。

たとえばこんな状況を想像してください。ボタンをクリックしたらAPIからデータを取得してページに表示する、というWebアプリがあるとします。もしAPIの応答に3秒かかるとしたら、その3秒間はユーザーが何もできない、文字通り「止まった画面」になります。これでは実用に耐えません。

イベントループの仕組み

JavaScriptはこの問題を「イベントループ」という仕組みで解決しています。時間のかかる処理を「後でやっておいて」と委託しておき、メインの処理は先に進めます。委託した処理が終わったら「終わったよ」という通知(コールバック)を受け取り、その結果を使った処理を続けます。

この「委託して、終わったら教えてもらう」という仕組みこそが非同期処理の本質です。

コールバック:非同期処理の原点

最も古い非同期処理の書き方がコールバック関数です。「処理が終わったらこの関数を呼んでください」という形で関数を渡します。

// コールバックを使ったデータ取得の例
function fetchUser(userId, callback) {
  setTimeout(() => {
    // サーバーからのレスポンスを模擬
    const user = { id: userId, name: "山田太郎" };
    callback(null, user);
  }, 1000);
}

fetchUser(1, (error, user) => {
  if (error) {
    console.error("エラー:", error);
    return;
  }
  console.log("取得成功:", user.name);
});

コールバックは単純な場合には問題ありませんが、非同期処理が連続する場面で致命的な問題が現れます。「ユーザーを取得して → そのユーザーの投稿を取得して → その投稿へのコメントを取得して」という処理を書くと、次のようになります。

fetchUser(1, (err, user) => {
  fetchPosts(user.id, (err, posts) => {
    fetchComments(posts[0].id, (err, comments) => {
      fetchReplies(comments[0].id, (err, replies) => {
        // どんどん深くなる...
        console.log(replies);
      });
    });
  });
});

これがいわゆる「コールバック地獄」です。ネストが深くなるにつれてコードは読みにくくなり、エラーハンドリングも各段階で個別に書く必要があります。

Promise:コールバック地獄からの解放

この問題を解決するために登場したのが Promise です。Promiseは「非同期処理の結果を将来受け取れることを約束するオブジェクト」です。

function fetchUser(userId) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const user = { id: userId, name: "山田太郎" };
      resolve(user); // 成功時
      // reject(new Error("取得失敗")); // 失敗時
    }, 1000);
  });
}

fetchUser(1)
  .then(user => {
    console.log("取得成功:", user.name);
    return fetchPosts(user.id); // 次の非同期処理をreturnする
  })
  .then(posts => {
    console.log("投稿数:", posts.length);
    return fetchComments(posts[0].id);
  })
  .then(comments => {
    console.log("コメント:", comments);
  })
  .catch(error => {
    // エラーはまとめてここで処理できる
    console.error("エラー:", error);
  });

コールバックと比べると、処理の流れが上から下へ一直線になり、エラーハンドリングも .catch() ひとつにまとめられています。コードの見通しが格段に改善されました。

Promiseの3つの状態

Promiseは必ず以下の3つの状態のいずれかにあります。

状態意味説明
pending待機中非同期処理が完了していない初期状態
fulfilled成功resolve()が呼ばれ、結果の値を持つ
rejected失敗reject()が呼ばれ、エラー情報を持つ

一度 fulfilled か rejected になったPromiseの状態は変化しません。この不変性がPromiseを扱いやすくしています。

async/await:最もシンプルな非同期の書き方

ES2017から導入された async/await は、Promiseをベースにしながら、まるで同期処理のように非同期コードを書ける構文です。現代のJavaScript開発ではこれが標準的な書き方になっています。

async function loadUserData(userId) {
  try {
    const user = await fetchUser(userId);
    console.log("ユーザー:", user.name);

    const posts = await fetchPosts(user.id);
    console.log("投稿数:", posts.length);

    const comments = await fetchComments(posts[0].id);
    console.log("コメント数:", comments.length);

    return comments;
  } catch (error) {
    console.error("データ取得に失敗しました:", error);
    throw error;
  }
}

await はPromiseが解決されるまで処理を「待つ」ように見えますが、実際にはその関数の実行を一時停止してメインスレッドを解放し、他の処理を進めさせます。だからといって画面が固まることはありません。

並行処理でパフォーマンスを上げる

await を単純に並べると、処理が直列実行されます。互いに依存関係のない非同期処理を順番に待つのは時間の無駄です。そこで Promise.all() を使って並行実行します。

async function loadDashboard(userId) {
  // ❌ 直列:合計3秒かかる(各1秒 × 3)
  const user     = await fetchUser(userId);      // 1秒待つ
  const settings = await fetchSettings(userId);  // さらに1秒待つ
  const notices  = await fetchNotices(userId);   // さらに1秒待つ

  // ✅ 並行:約1秒で完了
  const [user2, settings2, notices2] = await Promise.all([
    fetchUser(userId),
    fetchSettings(userId),
    fetchNotices(userId),
  ]);
}

依存関係がない処理は Promise.all() でまとめて実行するだけで、体感速度が大きく変わります。実務でも覚えておきたいテクニックです。

よくあるミスと注意点

await の書き忘れ

非同期関数を呼び出す際に await を書き忘れると、Promise オブジェクトそのものが変数に入ってしまいます。

async function bad() {
  const user = fetchUser(1); // ← awaitがない!
  console.log(user.name);   // undefined になる(userはPromiseオブジェクト)
}

ループの中でのawait

forEach の中で await を使っても、期待通りには動きません。非同期処理を順番に実行したい場合は for...of ループを使います。

const userIds = [1, 2, 3];

// ❌ これは正しく動かない
userIds.forEach(async (id) => {
  const user = await fetchUser(id);
  console.log(user.name);
});

// ✅ for...of を使う
for (const id of userIds) {
  const user = await fetchUser(id);
  console.log(user.name);
}

エラーハンドリングを忘れない

async/await では try/catch でエラーをキャッチします。catch を書き忘れると、Promiseが reject されたときにエラーが握りつぶされてしまい、デバッグが非常に困難になります。

まとめ

コールバックから Promise、そして async/await へという流れは、JavaScriptが「非同期処理をいかに読みやすく書くか」を追求してきた歴史です。それぞれの仕組みは土台の上に成り立っているので、コールバックや Promise の概念を理解したうえで async/await を使うと、何か問題が起きたときの原因追跡がずっとスムーズになります。

現在のモダンなフロントエンド開発(React や Vue.js など)でも、APIコールやデータフェッチには async/await が至るところで使われています。今日から意識的に Promise.all() での並行化やエラーハンドリングを取り入れてみてください。「なんとなく動く」から「なぜ動くかわかって書ける」への変化を、きっと実感できるはずです。