一覧WB-11-1 · ブラウザからの非同期通信

Fetch APIによるデータ取得

在庫APIから応答ヘッダーとJSONを取得し、本文の消費と複製を確認します。その後、404・JSONの構文不正・応答前の切断を順に試します。最後にJSONのPOSTとCookieの送信条件を調べます。ここでは同じオリジンにあるAPIを使います。

説明

Fetch APIはHTTP要求を開始し、応答をResponseとして返します。fetchが成功してもHTTPの処理が成功したとは限らず、さらに本文をJSONへ変換する段階で失敗することもあります。どの段階の結果なのかを分けて扱う必要があります。

在庫APIから応答ヘッダーとJSONを取得し、本文の消費と複製を確認します。その後、404・JSONの構文不正・応答前の切断を順に試します。最後にJSONのPOSTとCookieの送信条件を調べます。ここでは同じオリジンにあるAPIを使います。

1応答と本文は別に受け取る

在庫APIのResponseを取得し、状態とヘッダーを表示します。

(globalThis.response=await fetch("/api/item")応答を待つ,{status:response.statusHTTP状態コード,ok:response.ok,type:response.headers.get応答ヘッダーを取得("Content-Type"),revision:response.headers.get("X-Revision"),bodyUsed:response.bodyUsed本文を消費したか})

200の応答が返り、okはtrueです。fetchのPromiseは応答が利用可能になると確定しますが、この時点ではjson()を呼んでいないためbodyUsedはfalseです。Content-Typeは本文の形式を示すヘッダーです。

2JSONへ変換する

応答本文をJSONとして読みます。

(globalThis.item=await response.json()本文を読みJSONとして解析,{item,bodyUsed:response.bodyUsed消費後の状態})

itemはid・name・stockを持つオブジェクトになります。json()もPromiseを返し、本文を読み終えてから解析します。Content-Typeだけを見て自動的にJavaScriptオブジェクトになるわけではありません。

3同じ本文をもう一度読む

同じResponseへtext()を呼びます。

await response.text()消費済み本文を再読.then(value=>({value}),error=>({error:error.name失敗した例外の型}))

TypeErrorになります。Responseの本文は消費済みで、そのまま二回読むことはできません。取得したオブジェクトや文字列を保持して使うか、消費前にclone()で別の読み口を作ります。

4消費前に複製する

新しく取得した応答を複製し、一方をJSON、他方を文字列で読みます。

(globalThis.fresh=await fetch("/api/item"),globalThis.copy=fresh.clone()消費前に別の読み口を作る,{json:await fresh.json()一方をJSONへ,text:await copy.text()もう一方を文字列へ,used:[fresh.bodyUsed,copy.bodyUsed]})

両方を読み取れます。cloneは本文ストリームを分岐させるため、必要な場合に消費前に呼びます。大きい応答で片側だけを遅く読むとバッファが増える可能性があり、無条件に複製する必要はありません。

5404でもResponseが返る

存在しない資源の応答を取得します。

(globalThis.missing=await fetch("/api/missing")404を返す資源,{status:missing.status,ok:missing.ok成功範囲かを確認,body:await missing.json()エラー応答の本文})

404のResponseが返り、okはfalseです。HTTPエラーの応答を受け取ること自体はfetchの拒否理由になりません。okまたはstatusを確認して、アプリ側の失敗処理へ進めます。

6HTTP失敗を呼出し元へ伝える

statusを確認し、404なら明示的に例外を投げます。

await (async()=>{const r=await fetch("/api/missing");if(!r.ok)HTTP状態を検査throw new Errorアプリ側で失敗にする("HTTP "+r.status);return r.json()})().catch呼出し元で受け取る(e=>({error:e.message}))

呼出し元はHTTP 404という失敗を受け取ります。fetch自身が拒否する失敗と、アプリがstatusを見て投げる失敗を分けて実装すると、原因に応じて表示や再試行を決められます。

7200でもJSON解析に失敗する

途中で切れたJSON文字列を返すAPIを読みます。

(globalThis.broken=await fetch("/api/broken-json"),{status:broken.statusHTTP状態,result:await broken.json()JSON構文を解析.then(value=>({value}),e=>({error:e.name解析段階の例外}))})

HTTP状態は200ですが、json()はSyntaxErrorで失敗します。要求の成功・HTTP状態・本文の構文は別々の確認点です。さらに、JSONとして正しくても必須項目や値の範囲が正しいとは限りません。

8応答を得られない失敗

サーバーが応答を返す前に接続を閉じるURLへ要求します。

await fetch("/api/disconnect")応答前に切断される要求.then(r=>({status:r.status}),e=>({error:e.namefetchの拒否を取得}))

この場合はResponseを得られず、fetch自体がTypeErrorで拒否されます。404を受け取った場合とは異なります。TypeErrorという型だけから切断・名前解決・CORSなどの具体的原因を一つに断定できるわけではありません。

9JSONをPOSTする

商品名「あ」と個数2をJSONで送り、サーバーが受け取った本文を確認します。

await (await fetch("/api/echo",{method:"POST"要求メソッド,headers:{"Content-Type":"application/json"送る本文の形式,"Accept":"application/json"希望する応答形式},body:JSON.stringify値をJSON文字列へ({name:"あ",qty:2})})).json()

method・headers・bodyが要求を構成します。JSON.stringifyはJavaScript値を文字列へ変換し、Fetchは文字列本文をUTF-8で符号化します。「あ」は3バイトなので文字数と本文バイト数は一致しません。Content-TypeとAcceptはそれぞれ送る形式と希望する応答形式です。

10同一オリジンのCookie

保存済みlab=blueを、通常のFetchとcredentials:omitで比較します。

({normal:await(await fetch("/api/cookie")既定の資格情報設定).json(),omit:await(await fetch("/api/cookie",{credentials:"omit"Cookieなどを含めない})).json()})

既定のcredentialsはsame-originで、同じオリジンの要求には対象Cookieを含めます。omitではCookieを含めません。CookieのDomain・Path・Secureなどの条件も送信可否に関わるため、credentialsだけで全条件が決まるわけではありません。

まとめ

Responseの取得、HTTP状態の判定、本文の消費・解析は別々の段階です。

本文は原則一度消費し、複数の読み方が必要なら消費前に複製します。

要求のmethod・headers・body・credentialsを、受信した値と対応させて確認します。

操作履歴と解説を振り返れます。

ブラウザ / Fetch・通信記録

1
(globalThis.response=await fetch("/api/item"),{status:response.status,ok:response.ok,type:response.headers.get("Content-Type"),revision:response.headers.get("X-Revision"),bodyUsed:response.bodyUsed})
{
  "status": 200,
  "ok": true,
  "type": "application/json",
  "revision": "3",
  "bodyUsed": false
}
2
(globalThis.item=await response.json(),{item,bodyUsed:response.bodyUsed})
{
  "item": {
    "id": 7,
    "name": "ノート",
    "stock": 10
  },
  "bodyUsed": true
}
3
await response.text().then(value=>({value}),error=>({error:error.name}))
{
  "error": "TypeError"
}
4
(globalThis.fresh=await fetch("/api/item"),globalThis.copy=fresh.clone(),{json:await fresh.json(),text:await copy.text(),used:[fresh.bodyUsed,copy.bodyUsed]})
{
  "json": {
    "id": 7,
    "name": "ノート",
    "stock": 10
  },
  "text": "{\"id\":7,\"name\":\"ノート\",\"stock\":10}",
  "used": [
    true,
    true
  ]
}
5
(globalThis.missing=await fetch("/api/missing"),{status:missing.status,ok:missing.ok,body:await missing.json()})
{
  "status": 404,
  "ok": false,
  "body": {
    "error": "not_found"
  }
}
6
await (async()=>{const r=await fetch("/api/missing");if(!r.ok)throw new Error("HTTP "+r.status);return r.json()})().catch(e=>({error:e.message}))
{
  "error": "HTTP 404"
}
7
(globalThis.broken=await fetch("/api/broken-json"),{status:broken.status,result:await broken.json().then(value=>({value}),e=>({error:e.name}))})
{
  "status": 200,
  "result": {
    "error": "SyntaxError"
  }
}
8
await fetch("/api/disconnect").then(r=>({status:r.status}),e=>({error:e.name}))
{
  "error": "TypeError"
}
9
await (await fetch("/api/echo",{method:"POST",headers:{"Content-Type":"application/json","Accept":"application/json"},body:JSON.stringify({name:"あ",qty:2})})).json()
{
  "method": "POST",
  "contentType": "application/json",
  "bytes": 22,
  "text": "{\"name\":\"あ\",\"qty\":2}",
  "accept": "application/json"
}
10
({normal:await(await fetch("/api/cookie")).json(),omit:await(await fetch("/api/cookie",{credentials:"omit"})).json()})
{
  "normal": {
    "cookie": "lab=blue"
  },
  "omit": {
    "cookie": ""
  }
}

演習完了