ジャンルから探す

Web Design English

JavaScriptのasync/awaitの改善

非同期処理まわりで近年本当に追加されたのは、トップレベルawait(ES2022)、Promise.withResolvers()(ES2024)、Array.fromAsync()(ES2024)の3つです。

この記事は2024年の公開後、2026年9月に全面的に見直しました。 公開当時に「改善点」として挙げていた4項目のうち、3項目は実在しない機能でした。訂正したうえで、実際に使える構文と、async/awaitで本当に間違いやすい点に書き直しています。

  • awaitの前にasyncをつけずに戻り値を取得できるショートカット機能」は存在しません。 近いのはトップレベルawait(ES2022)で、こちらはESモジュール内でのみ使えます
  • 「複数のawaitのエラー処理が簡素化され、個別のtry/catchが不要になった」という言語仕様の変更もありません。 元から1つのtry/catchで囲めます
  • Promise.allとの併用がしやすくなる文法変更」もありません

スポンサーリンク

async/await の基本

async 関数は必ずPromiseを返しますawait はPromiseが解決するまでその関数の実行を中断します。

async function fetchData(url) {
  try {
    const response = await fetch(url);

    // fetch は 404 や 500 では reject しない
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    return await response.json();
  } catch (error) {
    console.error('取得に失敗しました:', error);
    throw error; // 握りつぶさず、呼び出し元に伝える
  }
}

response.ok の確認は今も必須です。 fetch() がrejectするのは通信そのものが失敗したときだけで、サーバーが500を返しても成功扱いになります。詳しくはfetch() APIの使い方にまとめました。

トップレベル await:モジュール内なら関数で囲まなくてよい

ES2022で追加された機能です。ESモジュールの最上位で await が書けます。

<script type="module" src="main.js"></script>
// main.js(ESモジュール)
const res = await fetch('/api/config');
const config = await res.json();

// 即時実行関数で囲む必要がない
export { config };

従来必要だった「async即時実行関数」の定型文が不要になります。

// 以前の書き方
(async () => {
  const res = await fetch('/api/config');
  const config = await res.json();
})();

使える場所には制限があります。

  • ESモジュール内のみ<script type="module">.mjs、あるいは "type": "module" のパッケージ内
  • 通常の <script> やCommonJSでは構文エラーになります
  • そのモジュールを読み込む側の実行が待たされます。起動時に時間のかかる処理を置くと、ページ全体の初期化が遅れます

スポンサーリンク

Promise.withResolvers():resolve と reject を外に取り出す

ES2024で追加された、地味ですが実用的なメソッドです。 Promiseと、その resolve / reject を同時に受け取れます。

const { promise, resolve, reject } = Promise.withResolvers();

従来は、コンストラクタの外へ関数を持ち出すために変数を用意する必要がありました。

// 従来の書き方
let resolve, reject;
const promise = new Promise((res, rej) => {
  resolve = res;
  reject = rej;
});

使いどころは、「Promiseを作る場所」と「解決する場所」が離れているときです。ダイアログの結果待ちが典型例です。

function confirmDialog(message) {
  const { promise, resolve } = Promise.withResolvers();

  const dialog = document.getElementById('confirm-dialog');
  dialog.querySelector('.message').textContent = message;

  dialog.addEventListener('close', () => {
    resolve(dialog.returnValue === 'ok');
  }, { once: true });

  dialog.showModal();
  return promise;
}

// 呼び出し側は普通に await できる
if (await confirmDialog('この投稿を削除しますか?')) {
  await deletePost();
}

イベント駆動の処理を await できる形に変換するときに効きます。対応は Chrome 119 / Firefox 121 / Safari 17.4 / Node.js 22 以降です。

直列と並列を取り違えない

ここが async/await で最も多い実装ミスです。 ループの中で await すると、リクエストが1本ずつ順番に実行されます。

// 直列:3件で3倍の時間がかかる
const results = [];
for (const url of urls) {
  results.push(await fetchData(url));
}

互いに依存しない処理なら、先にPromiseを作ってからまとめて待ちます

// 並列:最も遅い1件の時間で済む
const results = await Promise.all(urls.map((url) => fetchData(url)));

逆に、意図的に直列にすべき場面もあります。前の結果が次の入力になる場合や、APIのレート制限がある場合です。「ループ内の await が常に悪い」わけではなく、意図してそうしているかどうかが問題です。

Promise.all の落とし穴

Promise.all()1つでも失敗すると全体が失敗します。一部が落ちても表示を続けたい場合は Promise.allSettled() を使ってください。

// 全部そろわないと画面が作れない
const [user, posts] = await Promise.all([
  fetchData('/api/user'),
  fetchData('/api/posts'),
]);

// 一部失敗しても表示したい
const results = await Promise.allSettled([
  fetchData('/api/user'),
  fetchData('/api/recommend'), // 落ちても致命的でない
  fetchData('/api/notice'),
]);

for (const r of results) {
  if (r.status === 'fulfilled') render(r.value);
  else console.warn('取得失敗:', r.reason);
}

さらに、Promise.all() に渡す前に catch を付けておかないと未処理の拒否になるケースがあります。すでに走り出しているPromiseの1つが失敗し、Promise.all が別のエラーで先に reject すると、残りの拒否が拾われません。用途に応じて allSettled を選ぶのが安全です。

「どれか1つ成功すればよい」場合はPromise.any()を使います。

スポンサーリンク

forEach の中で await は効かない

これも定番の罠です。 forEach はコールバックの戻り値(Promise)を無視するため、待ってくれません。

// 「完了」が先に出力される
urls.forEach(async (url) => {
  await fetchData(url);
});
console.log('完了');

for...ofPromise.all を使ってください。

// 直列で待つ
for (const url of urls) {
  await fetchData(url);
}

// 並列で待つ
await Promise.all(urls.map((url) => fetchData(url)));

console.log('完了');

mapfilter も同様で、async コールバックを渡すとPromiseの配列が返ります。filter に至っては、Promiseオブジェクトは常に真値なので絞り込みがまったく効きません

// 何も絞り込まれない(Promise は常に truthy)
const valid = items.filter(async (item) => await isValid(item));

// 判定結果を先に集めてから絞る
const flags = await Promise.all(items.map((item) => isValid(item)));
const valid = items.filter((_, i) => flags[i]);

非同期イテレーターと Array.fromAsync()

ページングAPIのように少しずつ届くデータは、非同期ジェネレーターで扱うと素直に書けます。

async function* fetchAllPages(baseUrl) {
  let page = 1;

  while (true) {
    const res = await fetch(`${baseUrl}?page=${page}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);

    const items = await res.json();
    if (items.length === 0) return;

    yield* items;
    page += 1;
  }
}

// 届いた順に処理する
for await (const item of fetchAllPages('/api/items')) {
  render(item);
}

すべて集めてから処理したい場合は、ES2024の Array.fromAsync() が使えます。

const all = await Array.fromAsync(fetchAllPages('/api/items'));
console.log(all.length);

件数が多い場合は for await のまま処理してください。 Array.fromAsync() は全件をメモリに載せます。

エラーの扱いで気をつけること

await を付け忘れると catch できない

// await が無いので try/catch を素通りする
try {
  fetchData(url); // ← await 忘れ
} catch (e) {
  // ここには来ない。未処理の拒否になる
}

return awaitreturn の違いもここに関わります。try の中で return promise と書くと、拒否が起きるのは関数を抜けたあとなので、その try/catch では捕まりません。

async function a() {
  try {
    return fetchData(url);        // catch されない
  } catch (e) { /* 来ない */ }
}

async function b() {
  try {
    return await fetchData(url);  // catch される
  } catch (e) { /* 来る */ }
}

エラーの原因を保持する

再スローするときは、cause で元のエラーを持たせておくと調査が早くなります。

try {
  return await fetchData(url);
} catch (error) {
  throw new Error(`ユーザー情報の取得に失敗しました (${url})`, { cause: error });
}

受け取り側では error.cause で元の例外を辿れます。メッセージを差し替えるだけで元の情報を捨てるのは避けてください。

タイムアウトと中断

await しているだけではいつまでも待ち続けます。タイムアウトは AbortSignal.timeout() で1行です。

try {
  const res = await fetch('/api/data', { signal: AbortSignal.timeout(5000) });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return await res.json();
} catch (err) {
  if (err.name === 'TimeoutError') {
    showMessage('応答がありません');
  } else if (err.name === 'AbortError') {
    // ユーザーが中断した
  } else {
    throw err;
  }
}

ユーザー操作による中断と併用する場合は AbortSignal.any() でまとめられます。詳細はfetch() APIの使い方にあります。

対応状況

機能 追加 Chrome Firefox Safari
トップレベルawait ES2022 89 89 15
Promise.withResolvers() ES2024 119 121 17.4
Array.fromAsync() ES2024 121 115 17
AbortSignal.timeout() 2024年4月 Baseline

まとめ

  • 「asyncなしで戻り値を取得できるショートカット」は存在しません。 近いのはトップレベルawaitで、ESモジュール限定
  • Promise.withResolvers()(ES2024)で resolve を外に取り出せる。イベントを await 化するのに便利
  • ループ内の await は直列。 依存が無いなら Promise.all で並列にする
  • Promise.all は1つ落ちると全滅。一部失敗を許すなら allSettled
  • forEach / map / filterasync を渡しても待たない。 filter は絞り込みが効かなくなる
  • try 内では return awaitreturn だけだと catch できない
  • 再スローには { cause: error } を付けて原因を残す
  • タイムアウトは AbortSignal.timeout()

async/await は構文としては安定していて、この数年で変わったのは周辺のPromise APIの方です。「新しい文法が増えた」という説明を見かけたら、まず対応表で裏を取ってください。