JavaScript中級 #2 非同期入門 — Promise と async/await

読了 6分

#1 クラスに続いて、今回はJavaScriptの最大の特徴の一つ — 非同期を整理します。最初に出会うと難しいですが、一度慣れるとJavaScriptの本当の表現力が広がります。

同期 vs 非同期 — 違いは何か? #

JavaScriptは基本的に一行ずつ実行します。上から下へ。

同期 — 一行ずつ順番に
console.log('1');
console.log('2');
console.log('3');
// 1, 2, 3 (この順序で)

問題は時間がかかる作業(ファイル読み込み、ネットワークリクエスト、タイマーなど)です。JavaScriptがこれを同期で待てば、その間ほかのことが止まります。そのためJavaScriptはこのような作業を非同期で処理します — 「あとで終わったら教えて」という形で。

非同期 — setTimeout 例
console.log('1');
setTimeout(() => console.log('2'), 1000);
console.log('3');
// 1, 3, 2 (この順序で)

setTimeout は1秒後に実行する関数を登録するだけで、後続のコードは実行され続けます。その間に console.log('3') が先に実行されます。

コールバックの時代 — そして限界 #

古いJavaScriptは非同期をコールバック関数で扱っていました。

昔のコールバックスタイル
fetchUser(userId, (user) => {
  fetchPosts(user.id, (posts) => {
    fetchComments(posts[0].id, (comments) => {
      console.log(comments);
    });
  });
});

これが有名なコールバック地獄(callback hell) です。インデントがどんどん深くなり、エラー処理があらゆる箇所に分散されます。

エラー処理まで加えると
fetchUser(userId, (err, user) => {
  if (err) return handleError(err);
  fetchPosts(user.id, (err, posts) => {
    if (err) return handleError(err);
    fetchComments(posts[0].id, (err, comments) => {
      if (err) return handleError(err);
      console.log(comments);
    });
  });
});

ES2015 で Promise が登場することでこの問題が解決されました。

Promise — 非同期の結果を表現するオブジェクト #

Promise は「今は結果がないが後で生まれる値」をオブジェクトで表現したものです。三つの状態のうち一つを持ちます。

  • pending (待機) — まだ終わっていない
  • fulfilled (成功) — 結果値が決まった
  • rejected (失敗) — エラーが発生した

Promise を作る — new Promise #

Promiseを直接作る
const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    if (Math.random() > 0.5) {
      resolve('成功');
    } else {
      reject(new Error('失敗'));
    }
  }, 1000);
});

resolvereject 二つの関数を引数として受け取ります。非同期作業が終わったら二つのうち一つを呼べばよいです。

直接作ることは実はあまり多くありません。ほとんどすべての非同期API(fetch、fs.promises、タイマー)がすでにPromiseを返します。

.then / .catch / .finally #

Promise の結果を扱うメソッド。

基本使用
promise
  .then((value) => console.log('成功:', value))
  .catch((err) => console.error('失敗:', err))
  .finally(() => console.log('終了'));
  • .then(callback) — 成功時
  • .catch(callback) — 失敗時
  • .finally(callback) — 成功/失敗に関係なく最後に

Chain — 結果を次へ渡す #

.then のコールバックが返す値が次の .then の引数になります。コールバックがまたPromiseを返すと、その結果が解決されて次へ渡されます。

chain
fetchUser(userId)
  .then((user) => fetchPosts(user.id))      // user を受け取り posts Promise を返す
  .then((posts) => fetchComments(posts[0].id))   // posts を受け取り comments Promise
  .then((comments) => console.log(comments))
  .catch((err) => handleError(err));

コールバック地獄が平らに広がりました。エラー処理も最後の .catch 一箇所に集まります。

async / await — Promiseを直線化 #

ES2017 で追加された構文。Promiseの上に載せてまるで同期コードのように書けるようにしてくれます。

async / await — 同じことをよりすっきりと
async function loadComments(userId) {
  const user = await fetchUser(userId);
  const posts = await fetchPosts(user.id);
  const comments = await fetchComments(posts[0].id);
  return comments;
}

loadComments(userId)
  .then((comments) => console.log(comments))
  .catch((err) => handleError(err));

ルール:

  1. 関数の前に async を付けるとその関数は常にPromiseを返す
  2. async 関数の中でだけ await を使え、await はPromiseを解いて値を返す

この二つが核心です。await に出会うとJavaScriptはその場で少し止まって結果を待ち、結果が出ると次の行へ進みます(他のコードはその間動作可能)。

エラーは try/catch で #

try/catch と async/await
async function loadComments(userId) {
  try {
    const user = await fetchUser(userId);
    const posts = await fetchPosts(user.id);
    const comments = await fetchComments(posts[0].id);
    return comments;
  } catch (err) {
    handleError(err);
    throw err;
  }
}

同期コードのように try/catch ですべての非同期エラーを一箇所で捕まえることができます。これが async/await が持ってきた最大の利点の一つです。

並列実行 — Promise.all #

上のコードの一つの落とし穴 — 三つの fetch が順番に実行されます(最初が終わってから二番目が始まる)。実は同時に始めてもよい場面では時間を無駄にしているのです。

互いに依存しない非同期を並列に実行したいなら Promise.all を使います。

Promise.all — 並列実行
async function loadDashboard(userId) {
  const [user, notifications, settings] = await Promise.all([
    fetchUser(userId),
    fetchNotifications(userId),
    fetchSettings(userId),
  ]);
  return { user, notifications, settings };
}

三つのリクエストが同時に始まり、すべて終わると結果が配列で入ってきます。デストラクチャリング(#5 オブジェクトと配列)で受け取ります。

Promise.all の落とし穴 — 一つでも失敗すると全体失敗 #

all は first error で抜ける
const results = await Promise.all([
  fetchA(),    // 成功
  fetchB(),    // 失敗
  fetchC(),    // 成功
]);
// 全体が reject — fetchA, fetchC の結果は受け取れない

これが負担なら Promise.allSettled を使ってください。

allSettled — すべて最後まで
const results = await Promise.allSettled([
  fetchA(),
  fetchB(),
  fetchC(),
]);
// results = [
//   { status: 'fulfilled', value: ... },
//   { status: 'rejected', reason: ... },
//   { status: 'fulfilled', value: ... },
// ]

各結果がオブジェクトで来て、成功/失敗を個別に処理できます。

その他のよく使う Promise 静的メソッド #

Promise 静的メソッド
// 最速の結果 (成功/失敗を問わず)
const fastest = await Promise.race([fetchA(), fetchB()]);

// 最速の成功のみ
const firstSuccess = await Promise.any([fetchA(), fetchB(), fetchC()]);

// 即座に成功/失敗する Promise
Promise.resolve(42);              // 即座に fulfilled
Promise.reject(new Error('!'));    // 即座に rejected

Promise.race は timeout パターンでよく使います。

timeout パターン
function timeout(ms) {
  return new Promise((_, reject) =>
    setTimeout(() => reject(new Error('timeout')), ms)
  );
}

const result = await Promise.race([
  fetch('/api/slow'),
  timeout(5000),
]);

5秒以内に fetch が終わらないと timeout が先に reject して race が抜けます。モダンJavaScriptは AbortController も一緒に使えますが、それは次の#6 fetch APIで扱います。

await の使いどころ — 落とし穴とガイド #

1) await は一行ずつ少し止まります #

順次 vs 並列
// 順次 — 合算時間になる
const a = await fetchA();
const b = await fetchB();

// 並列 — より速い方
const [a, b] = await Promise.all([fetchA(), fetchB()]);

上のコードの loadComments が順次だった理由は — postsuser が必要で、commentsposts が必要で、依存があったからです。依存がない場面では常に Promise.all が速いです。

2) ループの中の await 注意 #

for...of + await — 一度に一つずつ
for (const id of userIds) {
  const user = await fetchUser(id);
  console.log(user);
}

これはユーザー100人なら fetch が順番に100回 — 非常に遅いです。並列にしてもよければ:

並列 fetch
const users = await Promise.all(
  userIds.map((id) => fetchUser(id))
);

100倍近く速くなります。

3) 忘れてはいけない await #

awaitを抜かすと
async function process() {
  const result = fetchData();        // ✗ Promise オブジェクト自体が入る
  console.log(result);                // [object Promise]
  console.log(result.data);           // undefined
}

await を抜かすと resultPromiseオブジェクト自体になります。コンパイラが捕まえてくれないので自分で意識しなければなりません(TypeScript + ESLint が助けてくれます)。

Top-level await — ES2022 #

ES Modules では関数の外でも await を使えます。

top-level await
// app.js (ES module)
const data = await fetch('/api/init').then((r) => r.json());

console.log(data);

CommonJS モジュールはダメです。モダンESMでだけ動作します。Vite、Next.js、Bun などモダンツールで使用可能です。

まとめ #

この記事で整理した内容:

  • JavaScript は一行ずつ実行するが時間のかかる作業は非同期で処理
  • コールバック → Promise → async/await へ表現方式が進化
  • Promise の三つの状態 — pending / fulfilled / rejected
  • .then / .catch / .finally と chain
  • async 関数は Promise を返し、await が結果を解いてくれる
  • 依存のない非同期は Promise.all で並列実行
  • Promise.allSettled / race / any
  • ループの中の await 注意、await 抜かし注意
  • ESM で top-level await

次の記事(#3 イテレータとジェネレータ)では、for...of がどんな約束に従うのかから、自分のイテラブルを作りジェネレータで遅延シーケンスを表現するところまで扱います。

X