我先说个场景:线上项目刚部署完,首页打开一切正常,前端路由跳转也都丝滑。然后你随手在地址栏直接输了一个二级路由的URL,回车,浏览器给你甩了个大大的白色 404。再刷新一次当前页,又是404。群里马上炸开锅:"是不是路由配置有问题""是不是构建丢文件了"。排查到最后,问题往往不在 Vue 代码里,而在 Nginx 配置上。这个"Nginx 配置 Vue 路由 404"的经典场景,几乎每个做前端部署的人都会踩一次,我把它完整拆开讲一遍,包含原理、配置、踩坑和子路径部署的进阶内容,看完你不仅能修好 404,还能搞懂它为什么会出现。
1. 一个让我当场愣住的场景:首页正常,刷新却冒出 404
1.1 从一次"看似荒谬"的报障说起
事情是这样的:某项目用 Vue 3 + Vue Router 开发,路由用的 history 模式,线上域名假设叫www.example.com。部署完成之后,测试人员打开www.example.com一切正常,点击页面里的链接跳转到www.example.com/user/123也一切正常。然后他做了一件事:在浏览器地址栏直接按回车重新请求www.example.com/user/123,页面瞬间变成 404。
这还不是最奇怪的,最奇怪的是,在user/123这个页面上随便点几个内部链接,路由切换又恢复了正常。也就是说,问题只在"浏览器向服务器发起全新请求"的时候出现,在应用内部用前端路由跳转的时候永远正常。
很多前端同事第一次遇到这种情况,本能反应是去查 Vue Router 的代码,甚至怀疑是路由懒加载的 chunk 丢失了。实际上代码没有任何问题,问题发生在服务器层面:user/123在服务器里根本没有对应的物理文件,Nginx 找不到,就直接返回了 404。
1.2 先做个标准排查:构建产物到底完不完整
我不建议跳过排查直接改配置,先确认基础项。第一步,把dist目录拉下来,检查index.html是否存在,静态资源 JS/CSS 是否都在。第二步,看一眼构建产物里的 JS 引用路径,是相对路径还是绝对路径,这直接影响后面子路径部署的走向。第三步,用 curl 直接请求服务器,看看返回的 HTTP 状态码和响应体。
curl -I https://www.example.com/user/123当你看到返回404 Not Found,而curl -I https://www.example.com/返回200 OK时,基本可以确定 Nginx 在这个路由上没有做 SPA 回退处理。这时候你要做的不是改 Vue 代码,而是去调整 Nginx 的配置,让服务端把这类路由请求统一回退到index.html。
2. 为什么服务器的"按文件找"和 Vue 的"按路径画组件"会打架
2.1 先理解 Nginx 处理请求的底层逻辑
Nginx 本质上是一个静态文件服务器加反向代理。当一个请求到达时,它会根据location规则做匹配,然后把请求的 URI 映射到服务器磁盘上的一个文件路径。比如你配置了:
location / { root /usr/share/nginx/html; }访问/user/123时,Nginx 会去/usr/share/nginx/html/user/123找这个文件。磁盘上根本没有叫123的文件,也没有user目录,于是 Nginx 返回 404。
关键点在于:Nginx 的思维模型是"文件系统",它判断一个路径是否存在,靠的是磁盘上有没有对应文件,而不是看前端路由表里有没有这条路径。这是架构层面天然存在的认知差异。
2.2 再理解 Vue Router history 模式的操作方式
Vue Router 的 history 模式,底层用的是 HTML5 History API,核心是history.pushState()和history.replaceState()。这套 API 的神奇之处在于:pushState会改变地址栏的 URL,但不会让浏览器向服务器发送网络请求,页面也不会刷新。
所以当你在 SPA 内部点击<router-link>,组件切换是纯前端的操作,浏览器地址栏变了,服务器完全不知道。但当你直接访问一个 URL、刷新页面、用前进后退按钮回到某个深链,浏览器会向服务器发起一个真实的 HTTP 请求。这个请求到了 Nginx,Nginx 照着磁盘找文件,找不到,404。
2.3 冲突的核心:浏览器不同情况下的请求行为差异
把两个逻辑放一起看就清楚了:
| 操作 | 浏览器行为 | 服务器收到的请求 | 结果 |
|---|---|---|---|
| 打开首页 | 请求/ | 有index.html | 正常 |
| 点击路由链接 | 前端pushState,不发请求 | 无请求 | 前端正常渲染 |
直接访问/user/123 | 真实请求 | 磁盘无此文件 | 404 |
在/user/123刷新 | 真实请求 | 磁盘无此文件 | 404 |
| 前进/后退到深链 | 真实请求 | 磁盘无此文件 | 404 |
看到规律了吧。凡是触发真实 HTTP 请求的深链,都会被 Nginx 按文件查找逻辑处理。Vue 觉得/user/123是自己的路由,Nginx 觉得/user/123应该是一个文件,两边对着同一个路径产生了完全不同的理解。
2.4 顺带说说 hash 模式为什么没这个毛病
有人会问:那用 hash 模式不就没这些事了?确实是,因为 hash 模式的路由长这样:www.example.com/#/user/123。浏览器地址栏#之后的片段称为 fragment,fragment 永远不会被发送到服务器。你刷新页面时,请求的 URL 依然是/,Nginx 正常返回index.html,Vue 读取#/user/123渲染对应组件。问题被"藏"在了 fragment 里。
但 history 模式的好处是 URL 干净、美观,也更利于 SEO 和分享。为了这个干净,你就必须在服务器上补上"回退"逻辑。我见过不少项目为了省事直接切 hash 模式的,但对一个长期维护的产品来说,我更推荐把 Nginx 配好,用它换回更标准的资源路径。
3. 根治方案:try_files 回退机制一句话讲透
3.1 最小可用配置与完整配置对照
解决 404 的核心配置就是一行的差别:
server { listen 80; server_name www.example.com; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } }try_files是这段配置的灵魂。它的执行逻辑是:先按顺序检查后面的参数是否存在,如果存在就返回该文件,如果全部不存在,则交给最后一个参数做内部重定向。具体到这一行:$uri表示当前请求的完整 URI 对应的磁盘文件,$uri/表示对应的目录,两个都不存在时,内部重定向到/index.html。
内部重定向之后,Nginx 会重新在location /中匹配到/index.html,然后把index.html作为响应返回给浏览器。浏览器拿到的是标准的 SPA 入口文件,里面的 JS 启动后,Vue Router 读取当前 URL 的location.pathname,匹配路由表并渲染出正确的组件。整个链条至此闭合。
3.2 配好后发生了什么:请求流转全过程
我用实际操作里的一个请求举个例子。假设用户直接访问www.example.com/order/2024/123:
- 浏览器发起 GET 请求到 Nginx,URI 是
/order/2024/123。 - Nginx 匹配到
location /,执行try_files $uri $uri/ /index.html。 - Nginx 查磁盘
root/order/2024/123,不存在;查root/order/2024/123/目录,也不存在。 - Nginx 判定回退,内部重定向到
/index.html。 - 服务器读取
root/index.html,返回 200,Content-Type是text/html。 - 浏览器解析 HTML,加载 JS 文件,Vue 应用启动。
- Vue Router 读取地址栏路径
/order/2024/123,在路由表里找到对应组件并渲染。
整个过程你从浏览器 Network 面板看到的只有一个 200 的 HTML 响应和一些 JS 请求,没有任何 404。用户看到的结果是:地址栏那个"很深"的链接,直接打开居然成功了,刷新也不会再报错。
3.3 修改完记得 nginx -t 和 reload
配置文件改完别急着收工,有两验证步骤一定不能省:
nginx -t nginx -s reloadnginx -t会检查配置语法是否正确,如果有拼写错误、分号遗漏、括号不匹配,它会当场报错并在终端打印具体行号。确认syntax is ok之后再 reload,让新配置平滑生效。reload 不会中断当前正在处理请求的连接,项目在跑的时候这样做是安全的。
然后我会做一轮完整的验证,不只是打开首页,而是手动走这些场景:
- 直接访问一个二级路由 URL,期望 200。
- 在该路由页面按 F5 刷新,期望 200。
- 点击浏览器后退按钮,期望页面能正常渲染。
- 检查 Network 面板,确认 HTML 文档请求返回的是
index.html的内容。
4. 无脑回退是下策:三类请求必须区别对待
4.1 静态资源 404 不能塞回 index.html
try_files $uri $uri/ /index.html虽然能解决路由 404,但如果整个 server 块只有一个大而全的location /,那所有找不到的文件都会回退到index.html。比较典型的一个坑:页面里引用的某张图片因为构建问题丢失了,请求/images/banner.jpg时,本该返回 404,结果因为回退机制,服务器返回了index.html的内容,状态码是 200。
浏览器在 HTML 标签里加载图片,拿到一坨 HTML 且状态码 200,会直接触发图片解析失败并显示裂图,同时控制台报错。最难受的是,因为 HTTP 状态码是 200,监控系统也不会报警,你得靠人肉排查才能发现。
所以我的建议是:如果项目的静态资源是通过版本化文件名部署的,并且没有特殊需求,可以考虑把回退范围限制在"非静态资源"上。常见写法是增加一个专门处理静态资源的 location:
location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ { expires 7d; try_files $uri =404; access_log off; }这里的try_files $uri =404表示:找到资源就返回,找不到就返回 404,不要回退到index.html。正则 location 的优先级高于普通前缀 location,所以这类请求会先进到这个块里处理。这是确保"路由回退"和"静态资源 404"两条逻辑不互相污染的关键一步。
4.2 API 请求该走的还是走代理
如果你的 Nginx 同时承担反向代理职责,前端请求/api/*要转发到后端服务,那这些请求绝对不能回退到index.html。一旦回退,前端会收到一个 HTML 页面而不是 JSON 数据,接口层全部崩掉。
实际项目中,我会在location /前面单独配置 API 代理:
location /api/ { proxy_pass http://backend_server; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }Nginx 的 location 匹配规则是:有多个 location 时,会优先选择前缀匹配最长的那条。/api/比/更长更精确,所以所有/api/开头请求都会走代理,不会进入try_files的回退逻辑。后端如果返回 404,前端 fetch 能拿到真实的状态码,该报错的地方能正常报错,不会像无脑回退那样用 HTML 把错误包起来。
4.3 匹配规则优先级:location 的选择逻辑
为了不踩顺序的坑,掌握 Nginx location 的匹配优先级很有必要。规则大致是这样的:先精确匹配=,再是^~的前缀匹配,然后是正则匹配~/~*,最后是普通前缀匹配。同一个类型里,前缀匹配以最长为准,正则匹配按配置文件中的顺序为准。
location = /favicon.ico { log_not_found off; access_log off; } location ^~ /assets/ { alias /data/web/assets/; } location /api/ { proxy_pass ...; } location / { try_files $uri $uri/ /index.html; }这个布局是我在多个项目里反复打磨过的:特殊文件先拦走,静态资源走 alias,API 走代理,剩下的前端路由全部交给try_files回退。明确的分层让你后续排查问题的时候思路不混乱,不至于为了一个小 404 把整份配置翻来覆去读半天。
5. 部署在子路径时,三个地方必须同时对齐
5.1 先看真实案例:/admin/ 子路径部署
上面讲的都是域名根路径部署,比较简单。但实际项目里,经常是同一个域名下部署多个系统,比如www.example.com/admin/是管理后台,www.example.com/portal/是门户站点。这时候如果直接把try_files照抄上去,很容易发现页面能打开但样式全丢、路由跳转到根路径、刷新深链又 404,整个状态非常割裂。
我接手过一个这种配置的项目,当时的 Nginx 配置长这样:
location /admin/ { alias /usr/share/nginx/admin/; try_files $uri $uri/ /index.html; }你细看就会发现,最后那个/index.html是一个根路径绝对路径,Nginx 内部重定向时会跳转到根路径的index.html,而不是/admin/index.html。所以用户访问/admin/user时,拿到的可能是根路径应用的 HTML,路由根本匹配不上。这是子路径部署最常见的失败原因:Vue 路由、构建路径、Nginx 回退路径三者没有对齐。
5.2 vue.config.js、Vue Router base、Nginx root 的三角关系
子路径部署要打通,需要三个地方同时配置:
第一个地方,构建工具里设置公共资源路径。Vue CLI 版本在vue.config.js里写publicPath: '/admin/',Vite 对应的是base: '/admin/'。这会影响构建产物中 JS、CSS、图片等静态资源的引用前缀。
第二个地方,Vue Router 的 base。创建路由器时:
const router = createRouter({ history: createWebHistory('/admin/'), routes, });这样 Vue Router 在初始化时就知道自己处在/admin/子路径下,内部路由的跳转都会自动带上/admin/前缀,而不是跳去根路径。
第三个地方,Nginx 的 root/alias 和 try_files。这是最容易出错的环节。用alias的情况下,location /admin/对应到的物理路径是alias指向的目录,但try_files的重定向目标必须写成相对位置或者显式带上子路径。稳妥的做法是:
location /admin/ { alias /usr/share/nginx/admin/; try_files $uri $uri/ /admin/index.html; }特别注意最后一项,写成/admin/index.html,这样才能把请求引回/admin下的 SPA 入口,而不是根路径的入口。
5.3 try_files 在子路径下的具体写法
如果项目用的是root而不是alias,配置又不一样。
location /admin/ { root /usr/share/nginx; try_files $uri $uri/ /admin/index.html; }这时的物理路径是/usr/share/nginx/admin/,因为root会把完整 URI 拼在 root 后面。alias则是把 location 前缀替换成 alias 路径,行为上有细微差别,我建议优先用root + 完整路径的方式,逻辑更好理解,也不容易写出"资源路径对了但回退路径错"的配置。
还有个容易疏忽的点:如果前端路由嵌套层级很深,比如/admin/user/detail/1,try_files依然能正常工作。它只看第一个参数$uri是否存在,不存在就回退到/admin/index.html,并不要求路由的层级和目录结构一一对应。所以只要回退路径写对了,深层路由天然可用。
6. 上线之后的更隐蔽问题:缓存、白屏与巡检清单
6.1 页面更新后用户仍看到旧版本:index.html 的缓存策略
try_files解决 404 之后,另一个高频问题浮出水面:每次发布新版本,用户刷新页面看到的还是旧内容。原因通常是浏览器和 Nginx 都缓存了index.html,而这个 SPA 入口文件恰恰不应该做长缓存。
正确的策略是分而治之:带 hash 的静态资源(JS/CSS)文件名每次构建都会变化,可以放心长缓存,设置expires 30d甚至1y都可以;index.html则必须用no-cache,保证每次刷新都能从服务器拿到最新版,同时配合ETag做条件请求,文件没变化时返回 304,节省带宽。
location = /index.html { add_header Cache-Control "no-cache, no-store, must-revalidate"; expires -1; try_files $uri =404; } location / { try_files $uri $uri/ /index.html; }加一个精确匹配的location = /index.html,单独控制它的缓存头。这样index.html不会被缓存,而其他静态资源该缓存缓存,互不干扰。这个配置我几乎每个项目都会带上。
6.2 刷新出现白屏但状态码正常:路由守卫和异步组件的锅
还有一类情况,Nginx 配置完全正确,接口也正常,但深链刷新后页面白屏。这就要回到前端查找了。常见原因是全局路由守卫里做了权限校验,刷新时会重新走一遍登录态判断,如果异步获取用户信息的逻辑处理不当,比如在未获取到状态时直接next(false)或者重定向到登录页,用户看到的就是白屏或者闪一下又弹回首页。
另外一个高发点是路由懒加载。刷新深链时,按需加载的 chunk 会重新发起请求,如果构建产物里某个 chunk 因为 CDN 回源失败或者文件路径拼错而 404,页面会停在空白状态,控制台报"Loading chunk failed"错误。这两类问题与 Nginx 无关,但现象和最初的路由 404 很像,排查思路要分清楚。我的习惯是:先看 Network 面板里 HTML 请求是否是 200,是不是index.html;再看有没有 chunk JS 的 404;最后看控制台有没有路由守卫相关的报错。按这个顺序,能迅速定位问题出在服务端还是前端。
6.3 一盏灯照完:我留下的巡检清单
多次踩坑之后,我给自己总结了一份部署巡检清单,每次上线前过一遍,能省掉大量不必要的应急排查:
- 确认
dist产物中的index.html和静态资源都存在,资源引用路径是否符合子路径部署需求。 - 检查 Nginx 配置中
location /的回退是否只影响文档请求,API 和静态资源是否被独立 location 处理。 - 手动测试根路径、二级路由、深层路由、刷新、前进后退这五类场景,Network 面板里不应出现预期外的 404。
- 检查
index.html的响应头,确认Cache-Control不含长缓存;带 hash 的静态资源可以长缓存。 nginx -t通过后再reload,发布窗口内尽量避开业务高峰。- 如果项目是多子路径部署,逐个验证每个子系统的资源前缀、路由 base 和回退路径是否对齐。
这份清单里的每一条,几乎都能对应到某个真实线上事故。把这些琐碎的检查变成习惯之后,路由 404 这类问题,基本在还没被人感知之前就已经被挡在门外了。
说回到开头那个项目,最后我就是在 Nginx 里补了一行try_files,再让运维 reload 了一下,问题当场消失。但真正让我觉得有价值的,是后面花了一晚上把原理、边界和子路径逻辑全部理清楚。如果你也遇到同样的 404,希望这篇文章能帮你少走一些弯路,而不是像我当年一样,把 Vue 代码翻了整整两小时。