侧边栏壁纸
博主头像
一笑痕

仙人之下我无敌,
仙人之上一换一。

  • 累计撰写 52 篇文章
  • 累计收到 7 条评论

SPA 上线后刷新 404,加了兜底接口又坏了

2026-9-15 / 0 评论 / 19 阅读

SPA 上线后刷新 404,加了兜底接口又坏了

页面点进去能看,一刷新就白屏,这个场景在本机搭了个服务复现。npm run dev 完全正常,直接 curl 本地起的那个地址也确实有响应。

问题在 dev server 和生产服务器的差别上。前端用 history 路由,/order/1 这种地址在磁盘上没有对应文件,dev server 会自动把没命中的路径回给 index.html,生产环境的 nginx 不会。

现象:白屏,加上兜底之后变成 JSON 解析错

直接请求 /order/1,服务器去找这个文件或目录,找不到就返回 404,页面从来没机会加载,所以是白屏而不是路由报错。

加兜底能解决,一行 try_files 的事,麻烦的是加完之后。兜底一加上,前端就开始报这个:

SyntaxError
Unexpected token '<', "<!DOCTYPE "... is not valid JSON

这个报错见得很多。它想说的是「拿到的是 HTML」,但错误信息里只露了一个 <!DOCTYPE,很多人以为是自己响应体的 JSON 格式写错了。

更难受的是它有时候不报错。前端写成 if (res.ok) { const d = await res.json(); },200 让 res.ok 变成真,进了分支才炸,栈里指向业务代码,跟兜底规则隔着好几层。

影响面:本来该 404 的请求,全变成 200

一个没命中就一律回 index.html,和分情况处理,差别只落在「本来该 404 的请求」上:

请求 一律回 index.html 分情况处理
GET /api/user/999 200 HTML 404 JSON
GET /assets/missing.js 200 HTML 404 text/plain
POST /api/nope 200 HTML 404 JSON

两边在真实文件和真实接口上完全一致。/api/user/999 是个不存在的用户,一律兜底会给它 200 和 text/html,响应体是首页的 HTML。服务端这边只是 404 变成了 200,调用方那边是完全不同的一件事。

定位第一步:两个等价服务都起在本机

为了把差异摆在明面上,本机搭了两个服务做对比,同一组请求分别打过去,就是上面那张表的来源。对比完第一件事是确认前端到底拿到了什么。

定位第二步:静态资源被兜底成 HTML,浏览器怎么反应

/assets/missing.js 被兜底成 200 的 HTML,比接口那个隐蔽。用 Chrome 实测了两种情形,页面里先挂一个捕获阶段的 error 监听。

脚本返回 text/html,不带额外响应头:

[{"kind":"script-error","message":"Uncaught SyntaxError: Unexpected token '<'","filename":"http://127.0.0.1:39310/assets/plain.js","lineno":1}]

Chrome 没有拦,它嗅探之后照样把这段 HTML 当 JS 解析了,报的语法错误里文件名是那个 js 文件本身,指向 assets/plain.js 第 1 行。所以很多人第一反应是「构建产物坏了」,跑去查打包配置,其实那文件里装的是首页 HTML。

同一份内容加上 X-Content-Type-Options: nosniff 之后:

[{"kind":"resource-error","tag":"SCRIPT","src":"http://127.0.0.1:39310/assets/nosniff.js","message":null,"filename":null,"lineno":null}]

脚本被拒绝执行了,script 元素上触发一个 error 事件,事件里没有 message,只有一个 src,这也是前端监控能拿到的信号。两种情形里其他脚本都照常运行,这个失败不打断任何东西,只是某个功能悄悄不工作,页面上不会有白屏这种提醒。

控制台那行文案我没抓到。想让问题早点暴露,静态资源上加 nosniff 更有用,报错点至少停在元素上,而不是指向 js 文件第 1 行的语法错误。

定位第三步:Accept 头那套写法为什么也不行

另一套写法是看 Accept 头,包含 text/html 就当页面请求。浏览器里能用,口子在别处。实测了几个客户端的默认值:

curl -s http://127.0.0.1:39300/
# {"accept":"*/*","ua":"curl/8.7.1"}

curl -s -H 'Accept: application/json' http://127.0.0.1:39300/
# {"accept":"application/json","ua":"curl/8.7.1"}

curl 8.7.1 默认发的是 */*,按 HTTP 的约定这算「什么都接受」,所以纯看 Accept 的规则会把 curl、健康检查、CDN 回源探测都判成页面请求,一律回一份 HTML,要处理就得专门给 */* 补一条分支。路径前缀加扩展名这套可枚举,出问题时能一眼看出是哪个判断命中的。

改了什么:按路径前缀和扩展名分成三种情况

import http from 'node:http';
import { extname } from 'node:path';

// 真实文件表(真实项目里换成读磁盘,这里为了能直接跑用内存表)
const DIST = {
  '/index.html': ['text/html; charset=utf-8', '<!DOCTYPE html><body>SPA</body>'],
  '/assets/app.js': ['text/javascript; charset=utf-8', 'console.log(1)'],
};
const indexHtml = DIST['/index.html'][1];
const staticLookup = (p) => {
  const hit = DIST[p] ?? (p === '/' ? DIST['/index.html'] : undefined);
  return hit && { type: hit[0], body: hit[1] };
};

// 真实接口表(真实项目里换成你的路由匹配)
const API = { 'GET /api/user/1': { id: 1, name: '金铭' }, 'POST /api/order': { ok: true } };
const apiLookup = (method, p) => API[`${method} ${p}`];

http.createServer((req, res) => {
  const { pathname } = new URL(req.url, 'http://x');

  // 1. 真实文件
  const file = staticLookup(pathname);
  if (file) {
    res.writeHead(200, { 'content-type': file.type });
    return res.end(file.body);
  }

  // 2. 真实接口
  const api = apiLookup(req.method, pathname);
  if (api) {
    res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
    return res.end(JSON.stringify(api));
  }

  // 3. 都没命中,才轮到兜底
  if (pathname.startsWith('/api/')) {
    res.writeHead(404, { 'content-type': 'application/json; charset=utf-8' });
    return res.end(JSON.stringify({ error: 'not_found', path: pathname }));
  }
  if (extname(pathname)) {
    res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
    return res.end('404 not found');
  }
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
  res.end(indexHtml);
}).listen(39122);

/api/ 开头的交给接口自己的 404,带扩展名的交给静态 404,剩下的才当成前端路由。

顺序也要紧。真实文件和真实接口这两个判断必须排在兜底前面,本机搭这个服务时就把顺序写反过:兜底写在接口路由前面,所有接口都被首页 HTML 顶掉了,本地还测不出来,因为全程手点页面,没直接请求过接口。

改了什么:nginx 上的等价配置

本机没装 nginx,下面这段没跑过,只是把上面的规则翻译过去,用之前先自己验一遍:

location /api/ {
    proxy_pass http://127.0.0.1:8000;
}

location /assets/ {
    try_files $uri =404;
}

location / {
    try_files $uri $uri/ /index.html;
}

关键是 /api//assets/ 各自单独一个 location,让它们有自己的 404,别落到最后那个兜底里。/api/ 那个 location 没写 try_files,接口的 404 该由后端应用自己决定。location 的匹配优先级每次都记不住,配完别只看首页能不能打开。

怎么防复发:上线前三条 curl

curl -s -o /dev/null -w '%{http_code} %{content_type}\n' https://你的域名/api/definitely-not-exists
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' https://你的域名/assets/definitely-not-exists.js
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' https://你的域名/some-frontend-route

验收标准就三条:接口不存在是 404 且返回 JSON,静态文件不存在是 404 且不是 HTML,前端路由是 200 且是 HTML。三条同时成立,兜底规则基本不会有漏网的地方。

那段「一律回 index.html」的配置不值得用,省下的是配置行数,代价是接口 404 变成 200,而这类错会骗过前端的 res.ok 判断,最后以 JSON 解析错误的形式出现在业务代码里。

    🤞 分享