同一个接口地址,浏览器打开返回 HTML,fetch请求却得到 JSON?
你有没有遇到过这样的联调场景:
把接口 URL 复制到浏览器地址栏,回车后展示的是一整页 html(有时是一个“看起来很正式”的登录页或错误页);
在浏览器控制台里用 fetch() 请求同一个地址,返回的却是正常的 JSON;
用 Postman 或 curl 再试一次,也没问题。
接下来,群里往往就开始对线了:
前端:“你这个接口是不是路由转错了?我打开就是 HTML。”
后端:“不可能,我本地测的就是 JSON,你用 curl 跑一下。”
最后通常不是谁在撒谎,也不是网关抽风,而是一个很基础但经常被忽略的机制在起作用:内容协商(Content Negotiation)。
简单来说就是:同一个 URL,你“怎么开口说话”,服务端就“按你的语气回你”。
先分清两个容易混淆的概念:Accept 与 Content-Type
很多人一看到“返回格式不对”,第一反应是检查 Content-Type,但这往往会带偏排查方向。
Content-Type 更像是在说:“我这次发出去 / 我回给你的内容,是什么格式。”
Accept 更像是在说:“我希望你回给我的内容,是什么格式。”
你以为你在请求 api,服务端却以为你在访问网页,很多时候分歧就是从 Accept 开始的。
为什么浏览器地址栏更像“想要 HTML”?
因为浏览器的天职是渲染页面。
你在地址栏敲 URL,本质上是一次“导航到一个可展示的资源”,它发出去的请求头通常会明显偏向可展示的内容类型,例如:
text/html, application/xhtml+xml, ...而用 fetch()、Postman 或 curl 调用接口时,常见的 Accept 反而是:
application/json, */*如果服务端(或网关 / 中间件 / 框架默认行为)支持内容协商,它就完全可能做出这种分支判断:
你看起来像“页面访问者” → 返回 HTML(文档页、登录页、错误页、兜底页)
你看起来像“程序调用者” → 返回 JSON
于是你就遇到了那个令人困惑的现象:同一个 URL,在不同环境下表现得像“两套人格”。
经典报错:Unexpected token < in JSON at position 0
这个报错你应该见过。
它的潜台词通常是:你以为拿到的是 JSON,于是代码里写了 res.json();但服务端(在鉴权失败、异常兜底或重定向时)返回的其实是 HTML 页面,开头就是 <,解析自然就炸了。
更让人抓狂的是:你这边看到的是“解析 JSON 报错”,后端那边看到的是“我确实返回了一个页面 / 跳转”,两边都觉得对方在胡扯。
(我第一次遇到时还很认真地查业务逻辑,后来发现只是登录态过期,框架顺手返回了一个 HTML 登录页,顺便烧掉了我半小时。)
别靠猜,按这个顺序排查:三步就够了
第一步:Network 面板里先看 Request Headers 的 Accept
不要先看 Response,更不要急着改代理。
直接点开那次“返回 HTML”的请求,看 Accept 到底是什么。你会很快确认:你发出去的到底是“我想要 JSON”,还是“我什么都行 / 我更想要 HTML”。
第二步:把三种请求横向对比
同一个 URL,至少对比这三种场景:
浏览器地址栏直接打开(navigation)
你代码里的 fetch()(programmatic)
Postman 或 curl(工具请求)
重点看这几个字段,基本就能摸清差异:
Accept
Content-Type
Cookie
Authorization
X-Requested-With(有些老系统会依赖这个头)
很多“接口时好时坏”的诡异问题,本质不是业务逻辑不稳定,而是上下文不一致:浏览器带 cookie、会跟重定向走;工具请求可能不带登录态;再叠加一个 Accept 的偏好,走的链路就完全不是同一条。
第三步:问后端两个具体问题
比起“你接口是不是挂了”,我更愿意直接问清楚这两句(能省掉很多扯皮):
你们是不是根据 Accept 在切换返回格式(或者框架默认就这么干)?
在 401 / 404 / 500 这些情况下,是不是优先返回 HTML 兜底页 / 跳转页,而不是 JSON 错误体?
这两句问清楚,很多问题就不再是“联调玄学”,而是“约定没对齐”。
我现在的推荐做法:想要 JSON,就在请求里明说
别指望环境默认值“刚好懂你”。不同浏览器、代理层、网关中间件,默认行为都可能不一样。
想拿 JSON,就把 Accept 写死:
const res = await fetch("/api/user/info", {
headers: {
Accept: "application/json",
},
});如果团队有统一的 HTTP Client(比如封装了 fetch 或 axios),更建议在底层统一补上,别让每个业务请求都去“凭运气”:
axios.defaults.headers.common["Accept"] = "application/json";更进一步,如果后端也愿意配合,我会建议把约定写得更硬一点:
/api/* 这类明确的 API 路由,错误兜底尽量也返回 JSON(哪怕是最简的 { message, code })
页面级的跳转 / 渲染,留给前端自己决定(至少别把 HTML 静默塞进“API 返回”里)
这不是什么高深技巧,但非常省命:少一堆“为什么又是 HTML”的半夜排查,少一堆“这接口昨天还好好的”的群聊复读。
你下次再看到“同一个 URL 两种返回”,别急着怀疑后端。先看看请求头在说什么——很多时候,罪魁祸首就是那个不起眼的 Accept。
本文内容仅供个人学习、研究或参考使用,不构成任何形式的决策建议、专业指导或法律依据。未经授权,禁止任何单位或个人以商业售卖、虚假宣传、侵权传播等非学习研究目的使用本文内容。如需分享或转载,请保留原文来源信息,不得篡改、删减内容或侵犯相关权益。感谢您的理解与支持!