JavaScriptの非同期処理を整理する——コールバック・Promise・async/awaitの使い分け

コールバック地獄からPromise、async/awaitへの変遷を実務経験ベースで整理。JavaScript本格入門で改めて体系を学んだエンジニアが書く非同期処理入門。

非同期処理は、JavaScriptを「使える」人間と「わかっている」人間を分ける最初の壁だと思っている。

私自身、独学のころに非同期処理で何度も詰まった。コードが思い通りの順番で動かない、undefinedが返ってくる、エラーがどこで起きたかわからない——そういう経験を積み重ねながら「なんとなく動かせる」状態になっていた。

テックリードになって後輩のコードを見るようになってから、「なぜPromiseを使うのか」「async/awaitはどういう仕組みか」を説明しなければならない場面が増えた。「JavaScript本格入門(山田祥寛 著 技術評論社)」を読んで、その説明の精度を上げたいと思ったのが動機のひとつだ。

コールバック:最初の非同期の書き方

JavaScriptの非同期処理の出発点はコールバック関数だ。

// Node.jsのファイル読み込みをイメージした例
const fetchUser = (id, callback) => {
  setTimeout(() => {
    if (id <= 0) {
      callback(new Error("Invalid ID"), null);
    } else {
      callback(null, { id, name: "Taka" });
    }
  }, 100);
};

fetchUser(1, (err, user) => {
  if (err) {
    console.error(err);
    return;
  }
  console.log(user.name);
});

問題は、非同期処理が連なると起きる「コールバック地獄」だ。ユーザー情報を取得して、そのIDでプロフィールを取得して、さらに投稿一覧を取得して……というネストが深くなるほど読みにくく、エラーハンドリングが各層に散らばる。

独学時代、このネストの深さにうんざりしてコードを書くのをやめたことが実際にある。

Promiseで「何が起きるか」を表現する

Promiseはコールバック地獄を解消するために生まれた仕組みだ。「今は値がないが、いずれ解決(または失敗)する」という状態を表現するオブジェクトといえる。

type User = { id: number; name: string };

const fetchUser = (id: number): Promise<User> => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id <= 0) {
        reject(new Error("Invalid ID"));
      } else {
        resolve({ id, name: "Taka" });
      }
    }, 100);
  });
};

const fetchProfile = (userId: number): Promise<string> => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(`Profile of user ${userId}`);
    }, 100);
  });
};

// then/catchでチェーン
fetchUser(1)
  .then((user) => fetchProfile(user.id))
  .then((profile) => console.log(profile))
  .catch((err) => console.error(err));

.then()でチェーンできるので、ネストが横に伸びる。エラーハンドリングも.catch()1箇所にまとめられる。コールバックのころと比べると見通しがかなりよくなった。

しかしPromiseが増えたとき、複数の非同期処理をどう組み合わせるかでまた詰まった。Promise.all()Promise.race()の存在を知ったのも実務に入ってからで、最初はそこで混乱した。

async/awaitで同期的に書ける見た目を得る

ES2017で導入されたasync/awaitは、Promiseをベースに「同期処理のような見た目」で非同期コードを書けるようにしたものだ。

const getProfileInfo = async (userId: number): Promise<void> => {
  try {
    const user = await fetchUser(userId);
    const profile = await fetchProfile(user.id);
    console.log(profile);
  } catch (err) {
    console.error(err);
  }
};

// 並列実行したいときはPromise.allを使う
const getMultipleUsers = async (ids: number[]): Promise<User[]> => {
  const users = await Promise.all(ids.map((id) => fetchUser(id)));
  return users;
};

getProfileInfo(1);

awaitで一時停止して次の行に進む書き方は、頭の中で「上から下へ順番に実行される」イメージで読める。エラーハンドリングもtry/catchで統一できる。

実務ではほぼasync/awaitで書いているが、本書を読んで改めて確認したのは「async関数は必ずPromiseを返す」という点だ。これを意識していないと、async関数の戻り値を扱うときに型エラーで詰まることがある。

実務で詰まるポイント:awaitの付け忘れと並列実行

async/awaitを使うようになって最初に詰まったのは、awaitの付け忘れによる不具合だ。

// NG:awaitがないのでPromiseオブジェクトが返る
const badExample = async () => {
  const user = fetchUser(1); // Promiseオブジェクトが入る
  console.log(user.name);   // undefinedになる
};

// NG:逐次処理を書いているつもりで、無駄に遅い
const slowExample = async (ids: number[]) => {
  const user1 = await fetchUser(ids[0]); // 100ms待つ
  const user2 = await fetchUser(ids[1]); // さらに100ms待つ(合計200ms)
  return [user1, user2];
};

// OK:並列実行で効率化
const fastExample = async (ids: number[]) => {
  const [user1, user2] = await Promise.all([
    fetchUser(ids[0]),
    fetchUser(ids[1]),
  ]); // 100msで両方取得できる
  return [user1, user2];
};

後輩のコードレビューで「これ逐次実行になってますよ」とコメントする機会が増えた。async/awaitの見た目のわかりやすさが、逆に「Promiseが並列実行できる」という意識を薄めてしまうのだと思う。

エラーハンドリングの設計も変わる

非同期処理の変遷で、エラーハンドリングの書き方も変わった。

コールバック時代はNode.jsの慣習として第一引数にエラーを受け取るパターンが多かった。Promiseでは.catch()。async/awaitではtry/catchだ。

チームではasync/awaitのtry/catchを基本にしているが、本書を読んで意識するようになったのは「どこでcatchするか」の設計だ。関数ごとにtry/catchを書くと、エラーが握りつぶされやすい。どこで回復するかを意識してcatchの位置を決める習慣が大切だと、本書の説明を通じて改めて整理できた。

まとめ

コールバック→Promise→async/awaitという変遷は、「非同期処理をどう表現するか」の試行錯誤の歴史だ。それぞれに出番があり、Promiseを理解せずにasync/awaitだけ覚えても詰まる場面が必ず来る。

テックリードとして「なぜこう書くのか」を説明できるための地図を持っておきたい人には、本書の非同期処理の章は読む価値がある。書き方を覚える本ではなく、仕組みを理解するための本として使うのが正しいと感じた。