ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Nginx下SPA刷新404问题排查与解决

Nginx下SPA刷新404问题排查与解决 上周帮同事排查一个问题本地用 Nginx 托管一个 Vue 项目配好 HTTPS 之后在 https://localhost 下面点击导航一切正常但只要按下 F5 手动刷新页面立刻变成 404 Not Found。同事第一反应是证书没配好后来又怀疑是 Nginx 路由写错了折腾了大半天都没找到原因。最后我打开配置文件扫了一眼问题其实很小但如果不理解背后的原理确实容易在“HTTPS”“localhost”“index”这几个词里面来回打转。今天这篇就把“刷新即 404”这个问题彻底拆开讲清楚现象背后的原理是什么、Nginx 里正确姿势怎么写、边缘情况有哪些坑。同时会覆盖本地 HTTPS 证书生成、静态资源处理、多项目部署这些实际开发里一定会碰到的配套内容。无论你是刚接触 Nginx 的新手还是准备把前端项目迁到 HTTPS 环境的老人这篇文章都能直接照着抄。1. 现象与根因一刷新就404问题出在哪1.1 复现路径我是在什么场景下遇到的先说一个最典型的复现路径。你有一个前端项目用的是 Vue 或 React 这种单页应用框架为了后端路由好看开了 HTML5 History 模式比如 Vue Router 里设置createWebHistory()。项目构建完dist目录里只有下面这些文件dist/ ├── index.html ├── favicon.ico ├── assets/ │ ├── index-xxx.js │ └── index-xxx.css你把dist目录放到 Nginx 的某个路径下用root指过去再配好 HTTPS浏览器打开 https://localhost 之后首页正常显示。你点一下导航栏URL 从/变成了/about页面也切换正常。然后你想着验证一下刷新场景按了 F5结果 Nginx 直接给你回了一个大大的 404。这时候你会觉得莫名其妙“刚才还好好的怎么一刷新就崩了” 如果此时看 Nginx 的 error log会发现类似这样的记录[error] 12345#0: *123 open() /usr/share/nginx/html/about failed (2: No such file or directory)注意它找的是/about这个文件而不是你的index.html。这句话基本已经说明了问题所在。1.2 根因拆解SPA路由与服务端文件系统的“错位”要理解 404 为什么会发生得先明白单页应用的路由机制。Vue Router、React Router 在 History 模式下改变 URL 的时候并不会真正向服务器请求一个新的 HTML 页面而是由 JS 拿到浏览器的地址变化动态地渲染出对应组件。所以你在页面内部点击导航走的是前端路由服务器完全无感知。但刷新就不一样了。浏览器把 F5 当成一次全新的页面访问它会按照当前地址栏的 URL老老实实向服务器发一个 HTTP 请求。比如当前地址是https://localhost/about浏览器就会请求 Nginx 返回/about这个路径的内容。Nginx 的处理逻辑很简单你配置了root /path/to/dist那我就去这个目录下找about这个文件有就返回没有就 404。可你的项目压根没有about这个物理文件只有index.html于是 Nginx 只能干瞪眼。这个问题的本质是SPA 把“路由”放在前端管理是对浏览器地址栏的一种“欺骗”而 Nginx 是按“文件系统”来管理路由的两者对“路径”的理解完全不一致。刷新动作把这两种路由拉到了一起矛盾就爆发了。很多人会想到用 Hash 模式就是 URL 里带#/about的那种因为#后面的内容不会发送到服务器所以刷新不会触发请求。但我们需要的是干净、美观的路径所以不能让 Nginx 在这件事上掉链子。1.3 为什么 HTTPS 和 localhost 会让问题更隐蔽这个场景里还有两个干扰项一个是 HTTPS一个是 localhost它们本身不直接导致 404但是会把你的排查思路带偏。加了 HTTPS 之后你很容易怀疑是不是证书有问题。浏览器打开页面时如果证书不可信会直接拦截显示NET::ERR_CERT_AUTHORITY_INVALID之类的错误。但当前场景里页面明明能正常打开只是刷新才 404所以证书基本可以排除。可人的心理就是这样一看到“https”就想去检查证书白白浪费时间。localhost 带来的干扰更多。很多人平时测试会用127.0.0.1和localhost混着访问如果你的 Nginxserver_name只写了其中一个而浏览器恰好访问的是另一个虽然页面可能因为 fallback 规则加载了但某些请求会走到默认 server 或者其它配置块里出现一些“时好时坏”的诡异现象。另外Chrome 在 localhost 下有一些特殊处理比如强制认为 localhost 是安全上下文Service Worker、Cookie 的反常表现会更明显这也可能是刷新后出问题的帮凶之一。说白了HTTPS 和 localhost 不是根因但它们让你以为根因在别处。真正的修复点还是 Nginx 的路径匹配规则。2. 前置准备本地HTTPS证书与Nginx基础配置2.1 用OpenSSL生成一张localhost能用的自签名证书既然涉及 HTTPS就得有证书。本地开发环境一般不会去申请公共 CA 证书用自签名证书就够了。但这里有个容易踩的坑现在浏览器对证书的要求很严格如果你生成的证书里没有包含localhost这个域名即使你手动信任它Chrome 也可能继续报错。推荐用 OpenSSL 直接生成带 SANSubject Alternative Name的证书SAN 里同时写上localhost和127.0.0.1。命令如下mkdir -p ~/nginx-certs cd ~/nginx-certs openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ -keyout localhost.key -out localhost.crt \ -subj /CNlocalhost \ -addext subjectAltNameDNS:localhost,IP:127.0.0.1参数不复杂解释一下关键点-x509表示直接生成自签名证书而不是证书签名请求。-nodes表示私钥不加密。本地测试图省事可以这样生产环境千万别这么干。-days 365证书有效期一年过期之后浏览器会重新报警。-addext这个很关键没有它Chrome 会说证书缺少 SAN即使你点了“高级”也找不到继续访问的入口。生成之后你会在~/nginx-certs下看到两个文件localhost.crt和localhost.key。下一步把它们放到 Nginx 配置能读到的地方。2.2 最小可用的HTTPS server配置先把一个最基础的 Nginx server 块写出来。假设你的前端构建产物在/home/user/projects/demo/dist证书放在/etc/nginx/certs/那么配置长这样server { listen 443 ssl; server_name localhost; ssl_certificate /etc/nginx/certs/localhost.crt; ssl_certificate_key /etc/nginx/certs/localhost.key; ssl_protocols TLSv1.2 TLSv1.3; root /home/user/projects/demo/dist; index index.html; }这段配置本身没毛病root指定了项目根目录index index.html告诉你当请求路径是/或/xxx/这种目录路径时Nginx 会尝试返回目录下的index.html。它也确实能解决首页访问的问题但仅此而已。关键缺陷就在index指令身上。很多人以为加了index index.html就能让所有页面都找到入口实际上 Nginx 的index逻辑非常简单只有在 URL 以/结尾或者请求的是一个目录时会去查index文件。如果你请求的是/about这种“像文件又像路径”的地址它根本不会启动index机制只会去磁盘上找名为about的文件找不到就 404。所以真正要做的不是加强index而是配置一个 fallback让那些磁盘上不存在的路径统一交给index.html去处理。这就是try_files的活。3. 核心修复try_files 让前端路由不再迷路3.1 你真正需要的是 try_files在location /里加上try_files这几乎是所有 SPA 项目部署的标准答案。最简单的写法是server { listen 443 ssl; server_name localhost; ssl_certificate /etc/nginx/certs/localhost.crt; ssl_certificate_key /etc/nginx/certs/localhost.key; root /home/user/projects/demo/dist; index index.html; location / { try_files $uri $uri/ /index.html; } }配好之后跑一下nginx -t检查语法然后nginx -s reload热加载配置再去刷新那个之前 404 的页面应该就能正常打开了。try_files的每个参数都要理解清楚不然出问题你还是不会改$uriNginx 会先去root指定的目录下查找当前请求路径对应的文件。如果请求的是/assets/index-xxx.js且文件真实存在直接返回。$uri/如果找不到文件Nginx 会尝试把当前路径当作目录处理。比如请求/about它去看看有没有about/这个目录如果有再尝试读取目录下的index文件。/index.html前面两个都失败时就把请求内部重写为/index.html让前端应用去接管。这是整个配置的灵魂。这里涉及一个 Nginx 内部细节try_files最后一个参数如果是以/开头的路径会被当成本地文件的 URL 去内部跳转如果最后一个参数是404这样的形式就直接返回状态码。我们写/index.html等价于告诉 Nginx兜底把请求交给 index.html而不是让它返回 404。3.2 处理“index”路径的细节题目里那个httpslocalhostindex对应到真实场景就是浏览器地址栏输入https://localhost/index然后刷新。这个路径比/about更有迷惑性因为你的项目里确实有一个index.html文件你可能会想Nginx 怎么能找不到index呢实际执行过程是这样的请求$uri等于/indexNginx 先去磁盘找index文件不存在然后找index/目录也不存在最后 fallback 到/index.html正常返回。也就是说添加了try_files之后/index这个路径会“歪打正着”地命中index.html你再刷新它也不会 404。但如果没加try_files光靠index index.html是救不了/index这个路径的。因为index指令只会在请求以/结尾时生效/index不带斜杠不满足触发条件。你可以试一下访问https://localhost/index/带斜杠的版本这时 Nginx 会把它当成目录请求然后去目录下找 index.html从而成功返回。这也是很多人误以为“index 配置生效了”的原因其实只是带不带斜杠的巧合。所以我的建议是不要纠结 URL 末尾加不加斜杠直接在location /里放好try_files让所有路径都走统一逻辑。如果你发现自己真的需要频繁处理/index这种特殊路径还可以在try_files里多写一个显式的/index.html兜底但没必要过度设计。3.3 静态资源与接口请求不能一把梭try_files $uri $uri/ /index.html;解决了前端路由刷新 404但它有个副作用如果 JS、CSS、图片这类静态资源在磁盘上不存在Nginx 也会返回index.html的内容而不是 404。浏览器拿到text/html类型的“JS 文件”会直接报 MIME 类型错误页面照样跑不起来。更合理的做法是对不同类型的请求做区分。前端静态资源一般都在/assets目录下可以单开一个 location让它们只走文件系统没有就真返回 404location / { try_files $uri $uri/ /index.html; } location /assets/ { expires 7d; add_header Cache-Control public; try_files $uri 404; }加上expires可以给打包后的资源设置缓存因为打包产物通常都带 hash 文件名缓存策略可以放得很开。而接口请求一般走/api通常不会直接落在静态文件目录而是要转发给后端服务这个我放到第 5 节具体说。整体配置看起来可能有点“多”但思路很清晰静态文件按文件处理普通路径交给前端接口请求转发后端各司其职不要用一个try_files大包大揽。4. 踩坑实录404之外的那些连带问题4.1 刷新之后样式和脚本全丢了配置好try_files后你满心欢喜地刷新结果页面不 404 了但控制台里报了一大堆错样式没了、脚本没执行、页面白屏。最常见的错误长这样Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of text/html原因很直白浏览器请求/assets/js/index.js但这个文件在磁盘上不存在Nginx 被try_files兜底逻辑返回了index.html响应头里的Content-Type是text/html浏览器拒绝执行。这种情况通常发生在你改了静态资源路径、配置了错误的root或alias或者前端构建产物没真正放到 Nginx 指定目录下。排查思路很简单打开浏览器开发者工具看 Network 面板里对应资源的状态码和响应体。如果响应体是 HTML 内容说明走了 fallback如果状态码 404说明 Nginx 确实找不到文件。前者检查静态资源的 location 配置后者检查根目录是否正确。这里建议养成一个习惯任何前端项目部署到 Nginx都要先确认dist目录里的assets路径能直接用 URL 访问。比如https://localhost/assets/index-xxx.js如果这个地址都能返回正确的 JS 文件再去谈前端路由刷新的事。4.2 证书不受信任HTTPS页面本身都进不去本地自签名证书还会带来一个前置问题浏览器不认它你连 https://localhost 都访问不了更别说刷新测试了。Chrome 的拦截页上会有“您的连接不是私密连接”之类的提示Chrome 里还隐藏了一个“高级”按钮点开之后可以选择“继续前往 localhost不安全”。Firefox 和 Edge 也都有类似的“接受风险并继续”入口。这个方法适合日常快速调试但每次打开浏览器都要点一次“信任”很烦。一劳永逸的办法是把自签名证书导入系统信任区。以 Windows 为例双击localhost.crt选择“安装证书”存储位置选“本地计算机”然后把证书放入“受信任的根证书颁发机构”。macOS 上则是用“钥匙串访问”导入证书并把信任级别改为“始终信任”。导入后重启浏览器该做的还是得做。要提醒一句这个方法只适用于本地开发测试。证书一旦被系统信任任何持有该私钥的人都能对你的机器发起中间人攻击。不要把这套操作搬到生产环境也不要贪图方便去下载所谓的“一键信任证书”工具安全底线不能碰。4.3 浏览器缓存、Service Worker一直在拖后腿还有一种情况是配置看起来都对但刷新后页面仍然表现不正常甚至旧版本一直不更新。这多半不是 Nginx 的锅而是浏览器缓存和 Service Worker 在“捣乱”。前端项目如果注册了 Service Worker它会把页面和静态资源缓存在浏览器内部。你改了 Nginx 配置甚至重新构建了项目但 Service Worker 还在按旧的缓存策略工作导致刷新后依然加载老版本的资源。判断方法很简单打开开发者工具的 Application 面板找到 Service Workers勾选 Offline 旁边的 Update on reload或者直接点 Unregister 注销。配合快捷键强制刷新Windows/Linux 是 CtrlShiftRmacOS 是 CmdShiftR一般就能看到真实效果。普通浏览器缓存也一样。Nginx 默认可能没有发Cache-Control头浏览器某些情况下会缓存 HTML 页面。你更新了 index.html 内容刷新时它读的还是旧缓存。处理方式有两种一是在开发环境给 HTML 明确设置不缓存location / { try_files $uri $uri/ /index.html; add_header Cache-Control no-store; }二是测试的时候直接用无痕窗口所有请求都不会复用旧缓存。我个人的经验是先开无痕窗口复现能复现才是配置问题无痕窗口下一切正常那就去查缓存和 Service Worker。5. 扩展多项目、多路由和接口转发怎么一并搞定5.1 一个域名下跑多个前端项目实际开发中一个 Nginx 端口很可能要支撑多个项目比如同一个 localhost 上挂一个后台管理、一个官网、一个文档站。最常见的做法是用子路径区分比如https://localhost/admin 对应后台项目https://localhost/docs 对应文档站点https://localhost/ 对应当前的落地页项目配置上要用好alias或者root的搭配。建议用alias把请求路径映射到不同的磁盘目录server { listen 443 ssl; server_name localhost; ssl_certificate /etc/nginx/certs/localhost.crt; ssl_certificate_key /etc/nginx/certs/localhost.key; location /admin/ { alias /data/sites/admin/dist/; try_files $uri $uri/ /admin/index.html; } location /docs/ { alias /data/sites/docs/dist/; try_files $uri $uri/ /docs/index.html; } location / { root /data/sites/main/dist; try_files $uri $uri/ /index.html; } }这里有几个细节必须说清楚。alias和root的区别在于root会把完整的 URL 路径拼接到 root 目录后面而alias是把 location 匹配到的部分替换成 alias 指定的路径。比如请求/admin/index.htmlalias /data/sites/admin/dist/会直接去找/data/sites/admin/dist/index.html如果用root写root /data/sites/admin/distNginx 会去找/data/sites/admin/dist/admin/index.html这通常不是你想要的结果。try_files里的兜底路径也要跟着子路径走写成/admin/index.html而不是/index.html否则内部跳转会跳到主项目的地盘上。5.2 把80端口自动跳到HTTPS本地测试时你可能会先用http://localhost访问但项目里某些接口、跨域逻辑只允许 HTTPS 环境甚至你把某些接口写成了绝对路径https://...HTTP 页面下会直接报错。最省心的方式是在 Nginx 里加一个 80 端口的 server 块把所有 HTTP 请求都跳到 HTTPSserver { listen 80; server_name localhost; return 301 https://$host$request_uri; }这个配置简单到不需要root也不需要location只要访问http://localhost浏览器就会收到 301 响应自动跳转到https://localhost。注意$host会保留用户访问的域名$request_uri会保留原始路径和查询参数跳转后不会丢失路由信息。同样要提醒一句生产环境下这种硬跳转有时候会影响 SEO 和某些 API 回调需要斟酌但在本地开发、内部部署场景里它是提升效率的好东西。5.3 后端接口请求转发前端页面放到 Nginx 后接口请求通常也需要经过 Nginx 转发到 Java、Go、Node 等后端服务。如果还是用之前那套try_files同时没有给接口路径单独处理会出现一个很尴尬的局面/api/user这个路径在磁盘上找不到被try_files兜底返回了index.html前端发了 “请求” 拿回来的却是一个 HTML 文档解析 JSON 时直接报错。正确的姿势是对/api开头的请求做个转发把它交给后端服务。比如后端起在127.0.0.1:8080location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }这段配置的作用是当你的页面请求/api/user时Nginx 把请求原样转发给127.0.0.1:8080/api/user然后把后端返回的结果带回来。同时带上Host、X-Real-IP之类的请求头后端才能正确拿到客户端 IP 和域名。要特别注意 location 的优先级。Nginx 的 location 匹配规则里精确匹配和正则匹配的优先级各有不同但通常情况下只要把/api/这个 location 写在 server 块里它就会优先于location /的try_files不会走到前端 fallback。如果你发现接口请求还是被打回了index.html大概率是 location 写错了位置或者正则 location 拦截了/api。到这里一套相对完整的本地 HTTPS 环境就齐了静态资源按文件返回前端路由统一交给index.html接口请求转发到后端80 端口自动跳 443。此后再遇到“刷新就 404”的问题你至少有三个方向可以快速排查先看try_files有没有写再看静态资源路径是不是走了兜底最后确认接口路径有没有被前端拿到。我在实际配置中还发现一个小技巧每次改完 Nginx 配置先执行nginx -t测试语法再nginx -s reload平滑加载。这两步之间最好隔几秒因为 reload 是异步的如果旧 worker 还在处理请求强行测试会有误判。这个习惯帮我避免了好几次“为什么我改了没生效”的困惑。
返回列表