はじめに

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 は「未来の値」を扱う仕組み
  • async / awaitPromise をより読みやすくした構文
  • try / catch でエラー処理が統一される
  • コールバック地獄を解消できる
  • fetch などの非同期処理は、現在では async / await が標準