1070 字
5 分鐘

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。以下順序能縮小問題範圍:

  1. 在 Network 確認請求 URL、狀態碼和最後到達的 URL。登入重新導向後可能是 200,但內容是 HTML;不要只看綠色狀態。
  2. 看 Response 的 Content-Type 與實際本文。宣告 application/json 不保證本文一定合法;宣告 text/html 則先處理路由或 API 契約。
  3. 如果是 204,確認契約是否允許沒有資料。不要對「無本文」硬做 JSON 解析。
  4. 如果本文是合法 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 競態是不同問題,應各自保留可辨識的失敗狀態。

參考資料:

MDN:Using the Fetch API

MDN:Response.json()

MDN:Response.bodyUsed

MDN:JSON.parse()

fetch JSON 解析失敗:Unexpected token、空回應與 HTML 排錯
https://laplusda.com/posts/javascript-fetch-json-unexpected-token/
作者
Zero
發佈於
2026-10-05
許可協議
CC BY-NC-SA 4.0