【JavaScript】async/awaitで非同期処理をスッキリ書く

javascript-icon JavaScript

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

前回はPromiseの基本と、.then()をつなげて非同期処理を書く方法を解説しました。

今回はそのPromiseをさらに読みやすく書けるようにする「async/await」を扱います。

async/awaitPromiseを置き換える別の仕組みではなく、Promiseを土台にした「書き方」です。

Promiseの理解があってこそ活きてくる内容なので、前回の記事とセットで理解していきましょう。

async/awaitとは?

非同期処理を同期処理のように書ける仕組み

async/awaitとは、Promiseを使った非同期処理を、まるで同期処理のように上から順番に書ける構文です。

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

async function main() {
  const user = await fetchUser(); // Promiseが解決するまで待つ
  console.log(user.name); // かつコーチ
}

main();

awaitをつけると、そのPromiseresolveされるまで処理がそこで一時停止し、結果が返ってきたら次の行に進みます。

.then()のようにコールバックをネストさせる必要がなく、コードの見た目が普通の同期処理とほとんど変わらなくなるのが最大の特徴です。

なぜasync/awaitが生まれたのか

Promise.then()チェーンは、コールバック地獄よりはずっと読みやすいものの、処理が増えると.then()が横に長く連なってしまいます。

fetchUser()
  .then((user) => fetchPosts(user))
  .then((posts) => fetchComments(posts[0]))
  .then((comments) => console.log(comments))
  .catch((error) => console.error(error));

async/awaitを使うと、この一連の流れを次のように書き直せます。

async function main() {
  try {
    const user = await fetchUser();
    const posts = await fetchPosts(user);
    const comments = await fetchComments(posts[0]);
    console.log(comments);
  } catch (error) {
    console.error(error);
  }
}

「非同期処理なのに、まるで同期処理のように上から下に読める」——この可読性の高さが、async/awaitが広く使われている理由です。

async/awaitの基本ルール

asyncをつけた関数は必ずPromiseを返す

awaitasyncをつけた関数の中でしか使えません。

async function getUser() {
  return { id: 1, name: "かつコーチ" };
}

getUser().then((user) => {
  console.log(user); // { id: 1, name: "かつコーチ" }
});

async functionは、たとえreturnで普通の値を返しているように見えても、実際には自動的にその値をPromiseでラップして返します

asyncがついた関数の戻り値は常にPromise」というのは、async/awaitを理解する上での大前提です。

awaitはasync関数の中でしか使えない

awaitをトップレベル(関数の外)でそのまま使おうとすると、環境によってはエラーになります。

// ❌ 通常の関数の中ではawaitを使えない
function main() {
  const user = await fetchUser(); // SyntaxError
}

awaitを使いたい処理は、必ずasync function(またはアロー関数のasync () => {})の中に書く必要があります。

try/catchでエラーハンドリングする

Promiseのcatchの代わりにtry/catchを使う

Promiseでは.catch()でエラーを受け取りましたが、async/awaitでは通常のtry/catch構文を使います。

function fetchUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id <= 0) {
        reject(new Error("不正なIDです"));
        return;
      }
      resolve({ id, name: "かつコーチ" });
    }, 1000);
  });
}

async function main() {
  try {
    const user = await fetchUser(-1);
    console.log(user.name);
  } catch (error) {
    console.error("エラーが発生しました:", error.message);
  }
}

main();
// エラーが発生しました: 不正なIDです

awaitしたPromiserejectされると、その場で例外(エラー)が投げられたのと同じ扱いになり、catchブロックに処理が移ります。

「非同期のエラーハンドリングも、同期処理と同じtry/catchの書き方で統一できる」という点は、async/awaitの大きなメリットです。

つまずきやすいポイント:forEachの中でawaitを使ってしまう

実際につまずいた「順番に処理されない」問題

私が実務で最もハマったのが、配列のforEachの中でawaitを使ってしまい、意図した順番で処理が終わらなかったケースです。

❌ Before:forEachの中でawaitしても待ってくれない

function fetchUserName(id) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(`ユーザー${id}`), 1000);
  });
}

async function main() {
  const ids = [1, 2, 3];

  console.log("処理開始");

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

  console.log("処理終了"); // なぜかこれが一番先に表示される
}

main();
// 処理開始
// 処理終了 ← forEach内の非同期処理を待たずに先に実行されてしまう
// ユーザー1
// ユーザー2
// ユーザー3

forEachのコールバックにasyncをつけても、forEach自体はコールバックのPromiseが終わるのを待たずに次のループへ進んでしまう仕様のため、「処理終了」が一番早く表示されてしまいます。

私はこのとき、「awaitをつけたのになぜ順番が保証されないんだ」と混乱し、forEachの仕様書を読み返してようやく理由に気づきました。

forEachは非同期処理の完了を待つようには設計されていない、という前提を知らなかったのが原因でした。

✅ After:for…ofとawaitを組み合わせて順番に処理する

async function main() {
  const ids = [1, 2, 3];

  console.log("処理開始");

  for (const id of ids) {
    const name = await fetchUserName(id); // 1件ずつ確実に待ってから次へ進む
    console.log(name);
  }

  console.log("処理終了"); // 全件の処理が終わってから表示される
}

main();
// 処理開始
// ユーザー1
// ユーザー2
// ユーザー3
// 処理終了

for...ofループはawaitと組み合わせても正しく「1件ずつ待ってから次へ進む」動きをしてくれます。

配列を順番に非同期処理したいときはforEachではなくfor...ofを使う、というのを覚えておくと、この種のバグを避けられます。

複数の非同期処理を並行実行したいとき

順番を待つ必要がなく、むしろ同時に実行したい場合は、前回学んだPromise.all()awaitを組み合わせます。

async function main() {
  const ids = [1, 2, 3];

  const names = await Promise.all(ids.map((id) => fetchUserName(id)));

  console.log(names); // ["ユーザー1", "ユーザー2", "ユーザー3"](約1秒で全件完了)
}

main();

ids.map()Promiseの配列を作り、それをPromise.all()にまとめてawaitすることで、3件を同時に実行しつつ、全件終わるまでmain関数側で待つことができます。

「順番が重要か、同時に実行してよいか」を意識して、for...ofPromise.all()を使い分けましょう。

応用:非同期関数の戻り値をチェーンする

async関数同士を組み合わせる

async関数はPromiseを返すため、他のasync関数の中でawaitすることができます。

async function fetchUser() {
  return { id: 1, name: "かつコーチ" };
}

async function fetchPosts(user) {
  return [`${user.name}の記事1`, `${user.name}の記事2`];
}

async function main() {
  const user = await fetchUser();
  const posts = await fetchPosts(user);
  console.log(posts);
}

main();

処理の流れを追うときも、awaitが並んでいるだけなので、上から下に読むだけで全体の流れがつかめます。

Promiseのときのように「今どのコールバックの中にいるのか」を意識する必要がない点が、async/awaitの読みやすさの本質です。

まとめ

この記事のポイント

  • async/awaitPromiseを土台にした、非同期処理を同期処理のように書ける構文
  • asyncをつけた関数は常にPromiseを返し、awaitasync関数の中でしか使えない
  • エラーハンドリングは.catch()ではなくtry/catchで行う
  • forEachは非同期処理の完了を待たないため、順番に処理したいときはfor...ofを使う
  • 同時実行したい非同期処理はPromise.all()awaitを組み合わせる

次に読むべき記事

非同期処理の書き方が身についたところで、次はコードを機能ごとに分割して管理する「モジュール(import/export)」を学んでいきましょう。

→ 次の記事:モジュール(import/export)の基本

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