
刷新 /login 直接 404这个问题在 Vue 和 React 项目里反复出现。我见过不少团队本地开发环境跑得好好的路由跳转、登录流程全都正常结果部署到服务器之后页面一刷新就崩了浏览器地址栏里的 /login 直接变成一个 404 页面控制台里还写着 Cannot GET /login。这不是某个框架的 bug。只要是单页应用SPA只要前端用了 history 模式的路由就会遇到这个问题。Vue Router 的 createWebHistory、React Router 的 BrowserRouter都是一个道理。这篇文章会把这个坑从原理讲到配置再把 Nginx、Apache、Node.js 等常见部署场景的解法一一列出来最后聊聊我踩过的一些边界问题。无论你现在用的是 Vue 还是 React这篇都适用建议先收藏。1. 先看清真相刷新时服务器拿到了什么请求要解决这个问题先得搞清楚单页应用的路由到底是怎么回事。我们平时说的前端路由实际上有两种形态哈希路由hash和 history 路由。两者的差异直接决定了会不会踩刷新 404这个坑。1.1 两种路由模式的本质差别哈希路由的 URL 长这样https://example.com/#/loginhistory 路由的 URL 长这样https://example.com/login。注意哈希路由里真正发给服务器的请求永远是 https://example.com/因为 # 后面的内容属于浏览器端锚点根本不会发送给服务器。也就是说不管用户在哈希模式下访问 #/login 还是 #/dashboard服务器收到的始终是根路径 /只要根路径下有 index.html页面就能正常加载怎么刷新都不会 404。history 路由就不一样了。它借助 HTML5 的 History API把路由状态直接写到 URL 路径上/login 就是 /login这串完整路径会原封不动地发给服务器。服务器一看网站目录里没有 login 这个文件也不存在 login 这个目录只能返回 404。这就是刷新 /login 无法访问的根源。两种模式的对比整理如下表对比项哈希路由 HashHistory 路由URL 示例/#/login/login路由部分是否发给服务器否只发根路径是完整路径刷新页面是否需要服务端配置不需要天然兼容需要必须配置 fallbackURL 美观度一般带 #好看标准 URLSEO 友好度较差较好仍需配合预渲染/SSR实际项目中我绝大多数情况会选择 history 路由因为 URL 干净也方便后续做 SEO。但选 history 的前提就是你必须解决好服务端回退的问题。1.2 “点击能跳刷新就崩”的完整链路很多人会有个疑问为什么我在页面里点链接、调 router.push一切都正常唯独手动刷新或者直接在地址栏输入 /login 会 404因为这两种操作走的根本不是同一条路径。点击导航时前端路由会拦截这次跳转JS 直接修改浏览器的历史记录并重新渲染组件整个过程压根没发起新的页面请求服务器自然不参与。而手动刷新、直接输入 URL、或者从别的地方比如邮件、书签跳进来时浏览器会老老实实发一个 GET 请求到服务器请求的路径就是你地址栏里的 /login。所以问题的本质就是单页应用只有一个真正的入口文件 index.html但 history 路由在 URL 上造出了一堆虚拟路径/login、/dashboard、/user/123服务器并不知道这些虚拟路径也没法用它们去找真实文件。要解决只有一个思路让服务器把这类查无此文件的请求全部回退到 index.html再由前端路由接管渲染出对应的页面。做个类比index.html 是商店唯一的大门history 路由相当于在大门口挂了一堆门牌路径刷新相当于有人直接举着门牌号来问路。服务器如果不认识这些门牌就会把客人撵走404。我们要做的就是让服务器统一回复不管门牌号是啥先带我去唯一的大门。2. 标准解法把未知路径统一回退到入口 HTML先说结论服务端只需配置一个兜底规则凡是找不到的真实文件、真实目录一律返回 index.html。下面按常见部署环境挨个说。2.1 Nginxtry_files 是最常用的解法大部分前端项目部署都用 Nginx配置也最简单。核心就是 location / 里的 try_files 指令server { listen 80; server_name example.com; root /var/www/dist; index index.html; location / { try_files $uri $uri/ /index.html; } }这段配置的含义是当请求进来时先按当前路径 $uri 找真实文件找不到就找同名目录$uri/再找不到就回退到 /index.html。回退之后的 /index.html 其实是一次内部重新定向浏览器地址栏里的 URL 不会变但服务器返回的内容变成 index.html。浏览器拿到 HTML 后会加载里面的 JS然后前端路由根据 URL 路径自动展示 /login 页面。对于后端接口记得单独配置代理并且别让 try_files 把接口请求也吞掉这一点第 3 节专门讲location /api/ { proxy_pass http://127.0.0.1:8080; }只要 /api/ 这个 location 匹配优先级高于 location //api/login 这类请求就会走反向代理不会落到 index.html。Nginx 会优先匹配带 ^~ 或最长前缀等规则的 location所以把 location /api/ 放在前面通常就够了。2.2 Apache 和 Caddy写法不同思路一致Apache 环境也常见核心是用 mod_rewrite 模块做一个不是真实文件、不是真实目录就重写到 index.html的规则IfModule mod_rewrite.c RewriteEngine On RewriteBase / RewriteRule ^index\.html$ - [L] RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule . /index.html [L] /IfModule这里的关键是 RewriteCond 两个条件!-f 表示不是真实文件!-d 表示不是真实目录只有同时满足才重写。这样 /assets/app.js 这种真实文件仍然能正常返回而 /login 这种虚拟路径会被重写到 index.html。效果和 Nginx 的 try_files 一模一样只是写法不同。如果你用的是 Caddy那更简单example.com { root * /var/www/dist try_files {path} /index.html file_server }Caddy 的 try_files 语法更直白能匹配到真实文件就返回文件否则回退到 /index.html。2.3 Node.js 服务Express 等框架怎么做有些项目的前端页面直接由 Node.js 服务托管比如用 Express 同时提供 API 和静态页面。这时候同样要做回退否则一样 404。Express 里最简单的写法是const express require(express); const path require(path); const app express(); // 静态资源 app.use(express.static(path.join(__dirname, dist))); // 除静态资源外的所有 GET 请求都返回前端入口 app.get(/.*/, (req, res) { res.sendFile(path.join(__dirname, dist, index.html)); });注意这个通配路由必须放在静态资源中间件之后并且写在 API 路由之后否则会把 API 请求也一并返回 HTML。如果你用的 Koa思路一致用 koa-static 托管 dist再写一个兜底中间件返回 index.html 就好。顺便提一句本地开发时为什么很少遇到这个坑因为 Webpack Dev Server、Vite 这类开发服务器内置了 historyApiFallback当你访问 /login 时开发服务器自动把请求回退到 index.html。这也是很多人本地正常、上线就 404的原因——差别就在服务器配置上。2.4 静态托管和容器部署也要注意现在不少项目用对象存储加 CDN或者各种静态托管平台。这类平台一般会提供索引文档和错误文档两个配置项索引文档通常填 index.html错误文档也填 index.html这样刷新生效。如果平台只支持配置一个 404 页面就把 404 页指到 index.html大多数情况也能绕过去。如果完全无法配置自定义规则那只能退回 hash 路由模式或者换一个支持自定义跳转规则的托管方式。Docker 部署的话常见做法是把构建产物打进 Nginx 镜像然后在镜像内置一份带 try_files 的 nginx.conf。这里顺便提醒一个坑很多基础镜像里没有 /etc/nginx/conf.d/default.conf你需要自己把配置文件 COPY 进去否则容器里的 Nginx 用的是镜像自带配置你的 try_files 根本不会生效。3. 配置完不等于结束这些边界情况必须处理很多人配完 try_files刷新 /login 确实不 404 了然后就开始遇到一堆新问题。我把实战中常见的边界情况列一下这些才是真正区分会部署和部署好了的分水岭。3.1 静态资源路径必须用绝对路径回退配置生效后服务器返回的是 index.html但 index.html 里引用的 JS、CSS 路径同样决定页面能不能正常渲染。如果你的资源是相对路径比如 ./assets/app.js那在 /login 这种深层路径下可能还能解析对但如果路由嵌套得深比如 /user/123/profile相对路径就可能解析出 /user/123/assets/app.js然后资源 404页面白屏。最稳妥的方式是让构建产物使用绝对路径。Vite 里设置 baseVue CLI 里设置 publicPathReact 里设置 homepage 或 PUBLIC_URL最终让 index.html 里的资源引用以 / 开头// vite.config.js export default defineConfig({ base: /, })// Vue Router 里 history 的 base 也要对应 const router createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes, })// React Router 的 basename 同理 BrowserRouter basename/一句话部署到根路径时资源和路由的 base 都设为 /部署到子路径时统一改成子路径前缀。前后端对不上刷新就会出现千奇百怪的 404。3.2 不要让 try_files 把 API 请求吞掉这是我最常看到的问题之一。有人配了 location / { try_files $uri $uri/ /index.html; } 后发现不仅页面刷新好了连接口请求也正常了——返回的是 HTML前端代码里报错说 JSON parse error但后端日志里却看不到这个请求。原因就是 try_files 的匹配范围太宽把 /api/login 这种请求也回退到了 index.html。解决办法有两个一是给接口单独配置 location让接口请求优先走代理二是尽量不要用 location / 通配所有路径而是按实际需要配置location /api/ { proxy_pass http://backend; } location / { root /var/www/dist; try_files $uri $uri/ /index.html; }Nginx 匹配规则里带前缀的 location 优先级高于不带前缀的 location所以 /api/ 的 location 会被优先命中。这样接口请求不会落到前端兜底逻辑里。3.3 子路径部署根源上是“前端 base 服务端 location”没对齐如果项目部署在 https://example.com/admin/ 这种子目录下事情会复杂一截。前端路由、静态资源、服务端路径必须三处保持同步。以 Vite React 为例// vite.config.js子路径部署时 export default defineConfig({ base: /admin/, })// React Router 加 basename BrowserRouter basename/adminNginx 这边也要让 /admin/ 开头的请求落到正确的目录并回退到子路径下的 index.htmllocation ^~ /admin/ { alias /var/www/myapp/dist/; try_files $uri $uri/ /admin/index.html; }这里的关键是 alias 后面要写清楚 dist 的真实位置同时 try_files 的回退目标要写成子路径下的 index.html而不是根 index.html。很多子路径部署问题表面看是刷新 404本质是这三处路径不一致导致的。3.4 页面能打开、刷新却卡在登录死循环有一种隐蔽情况Nginx 配置好了直接访问 /login 能打开但刷新之后页面虽然加载了却因为 token 过期或者 cookie 没带马上被路由守卫重定向到 /login然后又因为某种原因一直留在 /login看起来像死循环。这种情况往往不是路由配置问题而是登录态的问题。检查点有三个cookie 的 Path 是否覆盖了你的子路径接口请求带不带 cookie前端路由守卫判断登录状态的逻辑是否把 /login 当成需要登录的页面。调试时打开 DevTools 的 Application 面板看 cookie再打开 Network 面板看请求头基本就能定位。4. 常见问题速查与排查套路遇到问题别慌按照下面这个速查表逐项排除会比漫无目的地改配置高效得多。现象可能原因排查手段处理办法刷新 /login 直接显示 404服务端缺少 fallback 规则curl -I 看响应状态码配置 try_files / RewriteRule刷新后返回 index.html 但白屏静态资源 base 配置错误DevTools Network 看 JS/CSS 是否 404统一设置绝对 base接口请求返回 HTML前端报 JSON 解析错误location 覆盖顺序问题看 Response 内容是不是 HTML单独配置 /api 代理 location子路径部署大量 404base 与 Nginx alias 不一致对比 index.html 中资源 URL 与请求路径统一子路径前缀/login 能打开但刷新就重定向回登录页cookie 未携带或路径不对看请求头 Cookie 字段调整 cookie Path / 域名发布新版本后刷新出现旧页面index.html 被浏览器或 CDN 缓存看响应头 Cache-Controlindex.html 设置 no-cache另外给一个我自己常用的快速定位套路先用 curl 直接看服务器到底返回了什么。# 看响应头确认状态码和 Content-Type curl -I https://example.com/login # 看响应内容判断返回的是 HTML 还是 404 文本 curl -s https://example.com/login | head -20如果返回的内容是 index.html 的完整 HTML说明 fallback 已经生效问题大概率在前端资源加载如果返回的是 404 页或者 Cannot GET /login说明服务端配置还没到位。走到这一步问题基本能缩小到明确的方向。再提醒一个容易被忽略的点刷新 /login 和直接访问 /login其实是同一件事。如果你在本地用 npx serve 这类静态工具测试很多静态工具默认自带 fallback 行为所以本地测不出来一定要在线上环境验证或者换一个不带 fallback 的静态服务试一次。5. 部署 SPA 时的一些长期建议最后分享几个我自己的习惯算不上什么高深技术但确实能在以后的项目里少踩很多坑。第一如果你选 history 路由就把直接访问任意前端路由必须可用写进发布检查清单。每次上线前除了验证首页还要专门直接访问 /login、/dashboard、/user/123 这类深层路由刷新一次。很多问题恰恰是发布后才暴露的因为本地的开发服务器默认为你做了一切。第二index.html 不要缓存带内容 hash 的静态资源可以长缓存。配置上可以这么区分/index.html 返回 Cache-Control: no-cache/assets/ 下的文件返回 Cache-Control: max-age31536000。这样既保证用户能拿到最新的 HTML又能让静态资源充分利用缓存不会出现刷新后 HTML 是最新的但引用的还是旧版 JS这种尴尬场景。第三如果项目实在没有条件配置服务端 fallback比如纯静态托管且不支持自定义规则hash 路由是最后的退路。代价是 URL 里多个 #看着丑一点但对服务器零要求。决策时想清楚优先级功能可用大于 URL 好看。第四尽量把前端路由和后端 API 的路径前缀划清界限。比如前端负责所有不带 /api 的路径后端只认 /api 开头的请求。这也是为什么很多项目设计 /api 前缀的原因——不仅是规范更是为了让 Nginx 的 location 规则可以简单又可靠。这个问题我在项目里反复遇到每次解决方案大同小异但细节处各有各的坑。希望这篇能把原理和配置一次讲全以后再遇到刷新 /login 404你就能直接定位到服务端 fallback 这一层而不是在路由代码里折腾半天。