ジャンルから探す

Web Design English

JavaScriptのアップデートでfetch() APIの改良

fetch()まわりでこの数年に本当に増えた便利機能は、AbortSignal.timeout()(タイムアウトを1行で書ける)とAbortSignal.any()(複数の中断条件をまとめる)の2つです。どちらも2024年に Baseline 入りしました。

最初に誤解を1つ正しておきます。fetch()は今も、404や500といったHTTPエラーで Promise を reject しません。 「アップデートでHTTPエラーが自動で例外になった」という説明を見かけますが、そのような変更はありません。response.okのチェックは今も必須です。

この記事では、その前提を押さえたうえで、中断・タイムアウト・ストリーミング・並列取得の実装を、そのまま使えるコードで整理します。

スポンサーリンク

fetch() が reject するのはどんなときか

fetch()が reject するのは、通信そのものが失敗したときだけです。サーバーが応答を返した時点で、たとえそれが500でも Promise は fulfilled になります。

状況 Promiseの結果
200 OK fulfilled(response.ok === true
404 / 500 fulfilledresponse.ok === false
ネットワーク断・DNS失敗 rejected(TypeError
CORSでブロック rejected(TypeError
abort()で中断 rejected(AbortError
タイムアウト rejected(TimeoutError

したがって、実務で使える最小形はこうなります。

async function getJson(url, options = {}) {
  const res = await fetch(url, options);

  if (!res.ok) {
    // ここを書かないと 404 でも処理が進んでしまう
    throw new Error(`HTTP ${res.status} ${res.statusText}`);
  }

  return res.json();
}

res.statusを例外に含めておくと、あとからログを見たときに原因の切り分けが早くなります。

タイムアウトは AbortSignal.timeout() で1行

fetch()には既定のタイムアウトがありません。応答が返ってこないままリクエストが残り続けます。以前はAbortControllersetTimeoutを組み合わせて自作していましたが、今は1行で済みます。

// 5秒で自動的に中断する
const res = await fetch('/api/data', {
  signal: AbortSignal.timeout(5000),
});

タイムアウトしたときの例外はTimeoutErrorです。ユーザー操作による中断(AbortError)と区別できるので、メッセージを出し分けられます。

try {
  const res = await fetch('/api/data', { signal: AbortSignal.timeout(5000) });
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  const data = await res.json();
  render(data);
} catch (err) {
  if (err.name === 'TimeoutError') {
    showMessage('応答がありません。通信環境を確認してください');
  } else if (err.name === 'AbortError') {
    // ユーザーが中断した。何も表示しない
  } else {
    showMessage('読み込みに失敗しました');
    console.error(err);
  }
}

比較のため、自作していた頃の書き方を載せておきます。clearTimeoutの書き忘れでタイマーが残る、という不具合が起きやすい実装でした。

// 以前の書き方(今は不要)
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);

try {
  const res = await fetch('/api/data', { signal: controller.signal });
  // ...
} finally {
  clearTimeout(timer); // これを忘れがち
}

スポンサーリンク

ユーザー操作による中断とタイムアウトを両立させる

「検索欄への入力ごとに前のリクエストを捨てる」ような実装では、ユーザー起因の中断タイムアウトの両方が必要になります。これを1つのシグナルにまとめるのがAbortSignal.any()です。

let currentController = null;

async function search(keyword) {
  // 前のリクエストを捨てる
  currentController?.abort();
  currentController = new AbortController();

  // ユーザー中断 と 8秒タイムアウト のどちらでも止まる
  const signal = AbortSignal.any([
    currentController.signal,
    AbortSignal.timeout(8000),
  ]);

  try {
    const res = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`, { signal });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return await res.json();
  } catch (err) {
    if (err.name === 'AbortError') return null; // 上書きされただけ
    throw err;
  }
}

AbortSignal.any()は Safari 17.4 以降で使えます。いずれか1つが中断されれば全体が中断されるという、Promise.race()のシグナル版だと考えると分かりやすいです。

ストリーミングで受信しながら処理する

response.bodyReadableStreamなので、全部届くのを待たずに先頭から処理できます。生成AIの応答表示のような用途で必須になります。

async function streamText(url, onChunk) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  if (!res.body) throw new Error('ストリームに対応していません');

  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      onChunk(value);
    }
  } finally {
    reader.releaseLock();
  }
}

await streamText('/api/stream', (text) => {
  document.getElementById('output').append(text);
});

ポイントはTextDecoderStreampipeThroughで挟むことです。生のバイト列を自分でTextDecoderにかけると、マルチバイト文字がチャンクの境界で分断されて文字化けします。TextDecoderStreamはその境界を正しく処理してくれます。

ダウンロードの進捗を出したい場合は、Content-Lengthと受信バイト数を突き合わせます。

const res = await fetch(url);
const total = Number(res.headers.get('Content-Length')) || 0;
const reader = res.body.getReader();

let received = 0;
const chunks = [];

while (true) {
  const { done, value } = await reader.read();
  if (done) break;

  chunks.push(value);
  received += value.length;

  if (total) {
    updateProgress(received / total); // 0〜1
  }
}

const blob = new Blob(chunks);

Content-Length圧縮転送や chunked transfer では存在しないことがあります。上のようにif (total)で守っておかないと、進捗バーがInfinityNaNになります。

スポンサーリンク

並列で取得する:all / allSettled の使い分け

複数のAPIを同時に叩くとき、1つでも失敗したら全体を失敗にするのかで選ぶメソッドが変わります。

// ① 全部そろわないと意味がない → Promise.all
const [user, posts] = await Promise.all([
  getJson('/api/user'),
  getJson('/api/posts'),
]);

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

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

「どれか1つ返ってくればよい」場合はPromise.any()を使います。詳しくはPromise.any() の使い方にまとめています。

知っておくと役立つオプション

オプション 用途
keepalive: true ページ離脱時でも送信を完了させる(計測ログなど。64KBまで)
cache: 'no-store' キャッシュを一切使わない
credentials: 'include' クロスオリジンでもCookieを送る
priority: 'low' 取得の優先度を下げる(Chromium系)
redirect: 'manual' リダイレクトを自動で追わない

ページ離脱時のログ送信は、以前はnavigator.sendBeacon()が定番でしたが、keepaliveならヘッダーやメソッドを自由に指定できます

addEventListener('visibilitychange', () => {
  if (document.visibilityState !== 'hidden') return;

  fetch('/api/log', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ event: 'leave' }),
    keepalive: true,
  });
});

離脱の検知にunloadbeforeunloadを使うと、モバイルでは発火しないことがあります。visibilitychangehiddenになったタイミングを使うのが確実です。

まとめ

  • fetch()は404や500で reject しない。 response.okの確認は今も必須
  • タイムアウトはAbortSignal.timeout(ms)で1行。例外名はTimeoutError
  • ユーザー中断とタイムアウトの併用はAbortSignal.any([...])(Safari 17.4以降)
  • ストリーミングはpipeThrough(new TextDecoderStream())で文字化けを防ぐ
  • 進捗表示のContent-Lengthは存在しないことがある。必ず有無を確認する
  • 全部必要ならPromise.all、一部失敗を許すならPromise.allSettled
  • 離脱時のログ送信はkeepalive: truevisibilitychange

AbortController自体は2017年からある機能ですが、AbortSignal.timeout()any()が揃ったことで、ようやく定型のボイラープレートを書かずに済むようになりました。