fetch JSON 解析失敗:Unexpected token、空回應與 HTML 排錯
fetch() 後接 response.json(),卻收到 Unexpected token '<',先到 Network 看實際回應。開頭是 < 只能作為「可能收到 HTML」的線索;登入頁、錯誤頁、SPA fallback 都值得檢查,但錯誤文字本身無法證明是哪一種原因。
把 HTTP 狀態、回應格式、JSON 語法與資料形狀分開檢查。 不要把所有失敗改成 {}:那會讓真正的伺服器故障變成後面的 Cannot read properties of undefined,還可能把失敗畫面當成成功結果快取。
先看回應,再改 JSON.parse
依 MDN 的 Fetch 指南,HTTP 404 或 500 不會單憑狀態讓 fetch() reject;呼叫端要檢查 response.ok。以下順序能縮小問題範圍:
- 在 Network 確認請求 URL、狀態碼和最後到達的 URL。登入重新導向後可能是 200,但內容是 HTML;不要只看綠色狀態。
- 看 Response 的
Content-Type與實際本文。宣告application/json不保證本文一定合法;宣告text/html則先處理路由或 API 契約。 - 如果是 204,確認契約是否允許沒有資料。不要對「無本文」硬做 JSON 解析。
- 如果本文是合法 JSON,再檢查應用需要的欄位和型別。
null、字串和陣列都是合法 JSON,不代表它們符合你的 API。
Unexpected end of JSON input 可能是空字串,也可能是截斷的本文。先保留狀態與回應觀測,不要直接在 catch 裡重送有副作用的請求。
只讀一次本文的分層解析 helper
這個 helper 適用於「成功時必須回傳 JSON,204 可表示沒有結果」的小型 API。嚴格要求 JSON media type 是本文的契約選擇;若既有 API 缺少標頭,應修正伺服器或明確另訂容錯規則。
async function readJsonResponse(response) { if (!response.ok) { throw new Error(`HTTP ${response.status}`); } if (response.status === 204) return null;
const mediaType = (response.headers.get('content-type') ?? '') .split(';')[0].trim().toLowerCase(); const isJson = mediaType === 'application/json' || /^application\/[a-z0-9!#$&^_.+-]+\+json$/.test(mediaType); if (!isJson) throw new TypeError('Expected JSON Content-Type');
const text = await response.text(); if (text.trim() === '') throw new SyntaxError('Empty JSON body');
try { return JSON.parse(text); } catch (cause) { throw new SyntaxError('Invalid JSON body', { cause }); }}
function requireItems(data) { if (!data || typeof data !== 'object' || !Array.isArray(data.items) || !data.items.every((item) => typeof item === 'string')) { throw new TypeError('Expected { items: string[] }'); } return data.items;}本文不把原始本文放入錯誤訊息,避免一般紀錄意外帶出個人資料或登入頁內容。text() 會把本文完整讀進記憶體,這段不是大型串流解析器,也沒有實作本文大小上限。正式服務仍需在 API 或傳輸層限制回應大小。
示意整合如下;/api/items 是範例路徑,請換成自己的 API,並處理 UI 錯誤狀態:
const response = await fetch('/api/items');const data = await readJsonResponse(response);const items = data === null ? [] : requireItems(data);這裡明確把 204 對應成空清單。若你的 API 不允許 204,就應在呼叫端把 null 當契約錯誤,不要沿用這項轉換。
用 Response fixture 驗證不同失敗層
把上面的兩個函式與以下測試放到 check-json.mjs,用 Node.js 執行。這些 assertion 已在 Node.js v24.13.0 執行;它們驗證解析策略,沒有向正式 API 發送請求。
import assert from 'node:assert/strict';
const json = (body, status = 200) => new Response(body, { status, headers: { 'Content-Type': 'application/json' },});assert.deepEqual(requireItems(await readJsonResponse( json('{"items":["one","two"]}'),)), ['one', 'two']);assert.equal(await readJsonResponse(new Response(null, { status: 204 })), null);await assert.rejects(readJsonResponse(json('{"error":"failed"}', 500)), /HTTP 500/);await assert.rejects(readJsonResponse(new Response('<html></html>', { headers: { 'Content-Type': 'text/html' },})), /Content-Type/);await assert.rejects(readJsonResponse(json('')), /Empty JSON body/);await assert.rejects(readJsonResponse(json('{"items":')), /Invalid JSON body/);assert.throws(() => requireItems({ items: [1] }), /string\[\]/);assert.throws(() => requireItems(null), /string\[\]/);const problem = new Response('{"title":"example"}', { headers: { 'Content-Type': 'application/problem+json; charset=utf-8' },});assert.deepEqual(await readJsonResponse(problem), { title: 'example' });不要在讀完後再呼叫 response.json
MDN 的 bodyUsed 用來辨識本文是否已經被讀取。以下程式碼也是已執行的 fixture:
const response = new Response('{"items":[]}');await response.text();assert.equal(response.bodyUsed, true);await assert.rejects(response.json(), TypeError);若需要除錯和解析,本文採用一次 text() 後再 JSON.parse()。不能先 await response.text() 印出內容,再對同一個 response 做 json()。正式 API 的網路取消、CORS、代理重新導向與壓縮解碼仍須在實際環境查核;這些 fixture 沒有覆蓋它們。
如果錯誤發生在本文讀取期間,搭配 fetch 超時與 AbortSignal 的取消原因 檢查;若多個獨立 API 要保留部分成功結果,則看 Promise.allSettled 的部分失敗處理。解析錯誤和 UI 競態是不同問題,應各自保留可辨識的失敗狀態。
參考資料: