fetch

ブラウザからの HTTP 通信のデファクト API。Promise ベースで、Node.js / Bun / Deno でも標準で使える。 XHR の後継。

基本

const res = await fetch("/api/users")
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const users = await res.json()

Response の主なメソッド

ボディは1度しか読めない。複数回必要なら res.clone()

POST / PUT

// JSON
const res = await fetch("/api/users", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name: "Alice", email: "a@b.c" }),
})

// FormData(ファイルアップロード等)
const fd = new FormData()
fd.append("file", file)
fd.append("title", "My Photo")
await fetch("/upload", { method: "POST", body: fd })
// Content-Type は自動で設定される(multipart/form-data + boundary)

// URLSearchParams(フォーム標準)
const usp = new URLSearchParams()
usp.append("q", "hello")
await fetch("/search", { method: "POST", body: usp })

クエリパラメータ

const url = new URL("/api/search", location.origin)
url.searchParams.set("q", "hello")
url.searchParams.set("page", "1")

const res = await fetch(url)

Headers

// オブジェクトリテラルで
fetch(url, { headers: { "X-Custom": "value", "Accept": "application/json" } })

// Headers クラス
const h = new Headers()
h.set("Authorization", `Bearer ${token}`)
h.append("X-Custom", "a")
h.append("X-Custom", "b")        // 複数値
fetch(url, { headers: h })

// レスポンス側
res.headers.get("content-type")
res.headers.has("x-foo")
for (const [k, v] of res.headers) console.log(k, v)

credentials(Cookie の送受信)

await fetch("https://api.example.com/me", {
  credentials: "include",       // クロスオリジンに Cookie を送る
})

cache モード

mode

redirect

AbortController(タイムアウト・キャンセル)

const ac = new AbortController()
setTimeout(() => ac.abort(), 5000)         // 5 秒タイムアウト

try {
  const res = await fetch(url, { signal: ac.signal })
  // ...
} catch (e) {
  if (e.name === "AbortError") console.log("timeout")
  else throw e
}

// 最近は AbortSignal.timeout が使える
const res = await fetch(url, { signal: AbortSignal.timeout(5000) })

複数の signal を組み合わせ

const signal = AbortSignal.any([userAbort.signal, AbortSignal.timeout(10000)])
fetch(url, { signal })

ストリーミング

res.bodyReadableStream。チャンクごとに処理:

const res = await fetch("/big.ndjson")
const reader = res.body.getReader()
const decoder = new TextDecoder()

while (true) {
  const { done, value } = await reader.read()
  if (done) break
  console.log(decoder.decode(value, { stream: true }))
}

SSE 風に行ごと処理

async function* lines(stream) {
  const reader = stream.getReader()
  const decoder = new TextDecoder()
  let buffer = ""
  while (true) {
    const { done, value } = await reader.read()
    if (done) break
    buffer += decoder.decode(value, { stream: true })
    const parts = buffer.split("\n")
    buffer = parts.pop()        // 最後の不完全行
    for (const p of parts) yield p
  }
  if (buffer) yield buffer
}

for await (const line of lines(res.body)) {
  console.log(line)
}

進捗(プログレス)

fetch 自体に進捗 API は無い。ReadableStream で自前計算:

const res = await fetch(url)
const total = Number(res.headers.get("content-length"))
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
  console.log(`${(received / total * 100).toFixed(1)}%`)
}
const blob = new Blob(chunks)

Request オブジェクト

URL + 設定をまとめたRequestを作って渡せる。SW では特に重要。

const req = new Request("/api/data", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ a: 1 }),
})

await fetch(req)

エラーの扱い

fetch は HTTP エラー(4xx/5xx)で reject しないres.ok を必ずチェック。 ネットワーク失敗・CORS エラー・abort でのみ reject。

try {
  const res = await fetch(url)
  if (!res.ok) {
    throw new Error(`HTTP ${res.status} ${res.statusText}`)
  }
  return await res.json()
} catch (e) {
  // ネットワーク or HTTP エラー
}

共通ラッパを作る

async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
  const res = await fetch(`/api${path}`, {
    ...init,
    headers: {
      "Content-Type": "application/json",
      ...init.headers,
      Authorization: `Bearer ${getToken()}`,
    },
  })
  if (!res.ok) {
    const text = await res.text().catch(() => "")
    throw new HttpError(res.status, text)
  }
  if (res.status === 204) return undefined as T
  return res.json()
}

class HttpError extends Error {
  constructor(public status: number, public body: string) {
    super(`HTTP ${status}: ${body}`)
  }
}

リトライ

async function fetchWithRetry(url, init = {}, retries = 3, delay = 500) {
  for (let i = 0; i <= retries; i++) {
    try {
      const res = await fetch(url, init)
      if (res.ok) return res
      if (res.status < 500) return res     // 4xx はリトライしない
    } catch (e) {
      if (i === retries) throw e
    }
    await new Promise((r) => setTimeout(r, delay * Math.pow(2, i)))
  }
}

サーバ側からの fetch

fetch 代替・拡張

よくあるバグ

fetch が成功 = OK と思ってしまう(4xx/5xx はreject されない
✗ Body を 2 回読もうとする(res.clone()
✗ クロスオリジンで Cookie を送ろうとしてcredentials: "include" 忘れ
FormData + Content-Type を手で指定(boundary が壊れる)
✗ AbortController を作ったが signal を渡し忘れ