はじめに

JavaScript では、ネットワーク通信やファイル読み込みなど、時間のかかる処理を「非同期」で扱う必要があります。

もしこれらを同期的に実行してしまうと、UI が固まったり、アプリ全体が停止したように見えてしまいます。

この章では、非同期処理の基本である Promise と、現代的な書き方である async / await を、実例を交えながら丁寧に解説します。


非同期処理が必要な理由

JavaScript は「単一スレッド」で動きます。

つまり、1 度に 1 つの処理しか実行できません。

例えば、下記に示すように、外部からなにかしらの値を取り込み、画面に表示する JavaScript があるとします。

この fetch 操作が同期的に実行されると仮定すると…

const res = fetch("/api/user"); // ← ここで数秒止まる
console.log("次の処理");

ネットワーク通信が終わるまで、UI が完全に停止してしまいます。この際、マウスやキーボードへの応答もブロックされてしまう為、ユーザーには、まるでフリーズもしくは、ハングアップしたかのように見えてしまいます。

👉 これを避けるために、JavaScript では 非同期処理 が必須です。

時間のかかる処理をイベントドリブンで処理する事により、「単一スレッド」にもかかわらず、複数の処理を同時に行っているようにみせかけることで、この無反応にみえる状態を防ぎます。

JavaScript は「イベントループ」という仕組みにより、重い処理を待っている間も UI を止めずに他の処理を進められます。

その為の技術が、非同期処理です。

そして、それを実現する為に、当初用いられた方法が、コールバックによる記法です。


コールバックによる非同期処理

非同期で処理を行いたい関数にCallback関数を引数として、渡す記法です。

function getUser(id, callback) {
  try {
    const user = { id, name: "Kon" };
    callback(null, user);
  } catch (err) {
    callback(err, null);
  }
}

function getPosts(userId, callback) {
  try {
    const posts = [{ id: 1, title: "Post" }];
    callback(null, posts);
  } catch (err) {
    callback(err, null);
  }
}

function getComments(postId, callback) {
  try {
    const comments = [{ id: 1, text: "Nice!" }];
    callback(null, comments);
  } catch (err) {
    callback(err, null);
  }
}

getUser(id, (err, user) => {
  if (err) {
    console.error("getUser failed:", err);
    return;
  }

  getPosts(user.id, (err, posts) => {
    if (err) {
      console.error("getPosts failed:", err);
      return;
    }

    getComments(posts[0].id, (err, comments) => {
      if (err) {
        console.error("getComments failed:", err);
        return;
      }

      console.log(comments);
    });
  });
});

※ これは Node.js で一般的な「エラーファーストコールバック」という書き方で、(err, result) の順で値を返します。

ところが、この記法には、処理が複雑化すると

  • ネストが深い
  • エラー処理が複雑
  • 可読性が低い

といった、俗に「コールバック地獄」と呼ばれる問題が常につきまとっていました。

👉 そこで、この問題を解決する方法として、ES6(ECMAScript 2015)で新たに導入されたのが、Promise です。


Promise による非同期処理

Promise とは、「未来の値」 を表すオブジェクトです。

  • まだ値が決まっていない(pending)
  • 成功した(fulfilled)
  • 失敗した(rejected)

という 3 つの状態を持ちます。

Promise を使った記法では、下記のように、Promise の状態に応じた処理を then / catch を使って書く事が可能です。

下記では、先ほどの CallBack を使った非同期処理を Promise を使った記法で書き直しています。

function getUser(id) {
  return new Promise((resolve, reject) => {
    try {
      const user = { id, name: "Kon" };
      resolve(user);
    } catch (err) {
      reject(err);
    }
  });
}

function getPosts(userId) {
  return new Promise((resolve, reject) => {
    try {
      const posts = [{ id: 1, title: "Post" }];
      resolve(posts);
    } catch (err) {
      reject(err);
    }
  });
}

function getComments(postId) {
  return new Promise((resolve, reject) => {
    try {
      const comments = [{ id: 1, text: "Nice!" }];
      resolve(comments);
    } catch (err) {
      reject(err);
    }
  });
}

getUser(id)
  .then(user => getPosts(user.id))
  .then(posts => getComments(posts[0].id))
  .then(comments => {
    console.log(comments);
  })
  .catch(err => {
    console.error(err);
  });

👉 ずいぶんとコードがすっきりし、見やすくなった事がわかると思います。

Promise のメリット

  • エラー処理は .catch() 1 回で OK
  • ネストが消えて読みやすい
  • コールバック地獄から脱却できる

Promise の限界

  • then が増えるとネストが深くなる
  • エラーがどこで起きたか分かりにくい
  • 非同期処理が多くなると .then() が横に長くなり、可読性が落ちることがある。

async / await を使用した非同期処理

Promise を利用した非同期処理でも、コードの見通しが非常によくなったのですが、ES2017 で導入された async / await の仕組みにより、もっと分かりやすいコードが書けるようになりました。

async / await を使用した記法は、従来の Promise による非同期処理を補強し、その限界を突破する記法として、モダンな非同期処理の記法の中心になっています。

async 関数とは?

async を付けた関数は、必ず Promise を返す関数になります。

async function load() {
  return 1;
}

load().then(console.log); // 1

await の本質

awaitPromise の完了を待つ構文 です。実際には then の糖衣構文(syntactic sugar)です。

async function load() {
  const res = await fetch("/api/user");
  const data = await res.json();
  console.log(data);
}

では、これらの記法で、どのような変化があるのでしょうか。

下記では、先ほどの Promise を使った非同期処理を async / await を使った記法で書き直しています。

async function getUser(id) {
  const user = { id, name: "Kon" };
  return user;
}

async function getPosts(userId) {
  const posts = [{ id: 1, title: "Post" }];
  return posts;
}

async function getComments(postId) {
  const comments = [{ id: 1, text: "Nice!" }];
  return comments;
}

async function load() {
  try {
    const user = await getUser(id);
    const posts = await getPosts(user.id);
    const comments = await getComments(posts[0].id);
    console.log(comments);
  } catch (err) {
    console.error("Error:", err);
  }
}

load();

👉 よりコードがシンプルになり、直観的で理解しやすくなった事がわかると思います。

async / await のメリット

  • 同期処理のように書ける
  • ネストが浅くなる
  • エラー処理が try / catch に統一される

👉 現在の実務では「非同期処理の標準の書き方」となっています。


非同期処理における try / catch の取り扱いについて

下記のような async / awaittry / catch を使ったコードにおいて、どこまで try / catch で囲むべきかは、悩むところです。

なお、async 関数内で try / catch を書く必要があるのは「エラーを補足したい場合だけ」で、必須ではありません。

async function load() {
  try {
    const res = await fetch("/api/user");
    const data = await res.json();
    console.log(data);
  } catch (err) {
    console.error("Error:", err);
  }
}

👉 このような場合、 try / catch は「エラーが起きる可能性がある最小単位」で囲むことが基本です。

fetchjson は、実行時にエラーを throw する可能性があるからです。例えば、ネットワークエラー、CORS エラー、JSON が壊れている場合などは例外が発生します。

ただし、実務では、UI 更新は別 try / catch に分けることも多いです。API の取得と UI 更新のエラーでは性質が違うからです。

  • API 取得のエラー

→ ネットワークやデータの問題 → ユーザーに「読み込み失敗」と伝えるべき

  • UI 更新のエラー

→ DOM 操作のバグ → プログラミングミス → API とは関係ない

👉 実行時になんらかの理由で発生するエラーか、バグかの違いです。


並列処理

Promise による非同期処理は、Promise.all()Promise.allSettled()を利用することで、並列処理が可能になります。

Promise.all とは?

Promise.all() は「複数の Promise を同時に実行し、全部の処理が終わるまで待つ」関数です。

例:

async function load() {
  const [user, posts, comments] = await Promise.all([
    fetch("/api/user").then(r => r.json()),
    fetch("/api/posts").then(r => r.json()),
    fetch("/api/comments").then(r => r.json()),
  ]);

  console.log(user, posts, comments);
}

3 つの処理が同時に並列で動作し、全部終わったら結果が配列で返ります。配列の順番は Promise の完了順ではなく、Promise.all() に引き渡した処理の順番です。

1つでも失敗すると全体が reject されます。

Promise.all() では、最初に失敗した Promise のエラーが reject の理由になり、他の Promise は裏で動き続けますが、結果は返りません。

メリット

  • 複数処理が同時に走るので高速
  • コードが短くて読みやすい

注)1つでも失敗すると全部失敗になります。もし「失敗しても続けたい」なら Promise.allSettled() を使う必要があります。

Promise.allSettled() とは?

Promise.allSettled() も「複数の Promise を同時に実行し、全部の処理が終わるまで待つ」関数です。

例:

const results = await Promise.allSettled([
  fetch("/api/user").then(r => r.json()),
  fetch("/api/posts").then(r => r.json()),
  fetch("/api/comments").then(r => r.json()),
]);

for (const r of results) {
  if (r.status === "fulfilled") {
    console.log("成功:", r.value);
  } else {
    console.warn("失敗:", r.reason);
  }
}

👉 Promise.all() との最大の違いは、渡した処理の1つが失敗したとしても、全体が reject されないという点です。

返り値は、次のようなオブジェクトの配列です。

[
  { status: "fulfilled", value: ... },
  { status: "rejected", reason: ... },
  ...
]

成功した場合

{ status: "fulfilled", value: 結果 }

失敗した場合

{ status: "rejected", reason: エラー内容 }

配列の順番は Promise の完了順ではなく、Promise.allSettled() に引き渡した処理の順番です。

メリット

  • 1つ失敗しても 他の結果は受け取れる
  • UI 表示やログ収集に向いている -「全部やってみて、成功したものだけ使う」という用途に最適

Promise.all()Promise.allSettled() の違い

項目 Promise.all() Promise.allSettled()
目的 全部成功したら結果を返す 成功・失敗を含めて全部の結果を返す
成功時の返り値 成功した値の配列 {status: 'fulfilled', value: ...}の配列
失敗時の返り値 1つでも失敗すると全体が reject {status: 'rejected', reason: ...}を含む配列
エラー発生時の挙動 例外が飛ぶ(try/catch 必須) 例外は飛ばない(reject しない)
並列実行 すべて同時に実行 すべて同時に実行
用途 全部成功しないと意味がない処理 失敗しても続けたい処理
典型例 複数 API のデータをまとめて取得 成功したものだけ UI に表示したい
結果の形式 [value1, value2, ...] [{status, value/reason}, ...]
エラーの扱い 1つの失敗で全体が止まる 失敗も「結果」として扱う
使いやすさ 成功前提の処理に最適 失敗や異常が起きても、全体が壊れずに動く処理に最適


まとめ

  • Promise は「未来の値」を扱う仕組み
  • async / awaitPromise をより読みやすくした構文
  • try / catch でエラー処理が統一される
  • コールバック地獄を解消できる
  • fetch などの非同期処理は、現在では async / await が標準
  • PromisePromise.all()Promise.allSettled() で、並列処理が可能