Files
cs-note/hzh/CICD/Nginx SPA 路由配置:解决前端单页应用刷新 404 问题.md
T

10 KiB
Raw Blame History

tags, create time
tags create time
nginx
SPA
部署
Slidev
前端路由
try_files
2026-05-28 15:30

Nginx SPA 路由配置:解决前端单页应用刷新 404 问题

概述

本文记录了一个典型的 SPA 部署问题:将 Slidev 幻灯片站点部署到 nginx 后,直接访问子路径(如 /resume/1)返回 404。从根因分析(SPA 路由的本质)、nginx location 匹配规则、try_files 指令的工作原理,到最终配置方案的完整推导过程——不仅适用于 Slidev,也适用于所有使用 HTML5 History 模式的前端项目。

正文

问题现象

部署了一个 Slidev 幻灯片站点,有多个 deck(demo、resume 等)。访问首页 http://server:8080/ 正常,点击按钮进入 resume 也正常。但把 http://server:8080/resume/1 分享给别人时,对方看到的是 404。

[!question] 为什么从首页点进去一切正常,直连子链接却 404?

关键线索:首页能打开,assets(CSS/JS)也能加载——说明 nginx 基本工作正常,问题出在「某种特定请求路径」上。

根因:SPA 路由 vs 服务器路由

这是理解整个问题的核心。

Slidev 是单页应用(SPA)。构建后,每个 deck 目录下只有一个 index.html:

dist/resume/
├── index.html      ← 唯一的 HTML 文件
├── assets/
│   ├── style.xxx.css
│   └── app.xxx.js

没有 1、2、3 这样的独立文件。幻灯片的翻页完全由浏览器端 JavaScript 完成——它读取 URL 中的数字决定显示第几页。

这就引出了两种截然不同的访问方式:

flowchart LR
    A["用户点击首页按钮"] --> B["JS 执行 pushState"]
    B --> C["URL 变成 /resume/1"]
    C --> D["JS 读取 URL"]
    D --> E["渲染对应页面"]
    style A fill:#1a365d,color:#e2e8f0
    style B fill:#1a365d,color:#e2e8f0
    style C fill:#1a365d,color:#e2e8f0
    style D fill:#1a365d,color:#e2e8f0
    style E fill:#1a365d,color:#e2e8f0
flowchart LR
    F["用户直接访问 /resume/1"] --> G["浏览器发送 HTTP 请求"]
    G --> H["nginx 找文件 resume/1"]
    H --> I["文件不存在 → 404"]
    style F fill:#742a2a,color:#fed7d7
    style G fill:#742a2a,color:#fed7d7
    style H fill:#742a2a,color:#fed7d7
    style I fill:#742a2a,color:#fed7d7
对比维度 首页点击跳转 直接访问子路径
URL 变化谁引起的 JavaScript (pushState) 用户在地址栏输入或粘贴
是否向服务器发请求 否(当前页面的 JS 执行) 是(全新 HTTP 请求)
nginx 是否参与 不参与 必须参与
结果 ✅ 正常 ❌ 404

[!tip] 核心概念

SPA 的"路由"是假的。URL 变化只是浏览器地址栏的视觉效果,真正的页面切换是 JavaScript 完成的。只有用户刷新、直接输入 URL、或分享链接给他人时,才会真正触发服务器请求。

Nginx 的 location 匹配规则

Nginx 用 location 块定义「收到什么样的请求,怎么处理」。当多条规则都能匹配时,按优先级选择:

优先级 语法 示例 说明
1(最高) = /path location = / 精确匹配,路径必须完全一致
2 ~ regex location ~ ^/([^/]+)/ 正则匹配,区分大小写
3 ~* regex location ~* \.(jpg)$ 正则匹配,不区分大小写
4(最低) /path location / 前缀匹配,最长前缀胜出

当请求 /resume/1 时:

  • = / → 不匹配(不是精确的 /)
  • ~ ^/([^/]+)/ → 匹配(符合正则),被选中
  • / → 也匹配(前缀),但优先级低于正则

所以 nginx 会选用正则那条规则来处理。

[!note] 最长前缀优先原则

对于非正则的普通 location(前缀匹配和精确匹配),Nginx 先选精确匹配的(如果有),然后在所有普通前缀匹配中选出最长的。例如请求 /resume/1 同时匹配 / 和 /api/,如果存在这两条规则,就会选 /api/。

正则匹配的优先级高于所有普通 location,且第一个匹配的正则就停,不按长度择优。

try_files 指令

try_files 是 Nginx 的核心指令之一,语法:

try_files file1 file2 ... fallback;

Nginx 按顺序尝试每个文件,第一个存在的就返回,全部不存在才执行最后一个 fallback(通常是 =404)。

来看我们最终方案中的核心行:

try_files $uri $uri/ /$1/index.html =404;

逐层拆解:

尝试项 含义 请求 /resume/1 时的表现
$uri 请求的原始路径 检查文件 /usr/share/nginx/html/resume/1 → 不存在
$uri/ 路径末尾加 / 检查目录 /usr/share/nginx/html/resume/1/ → 不存在
/$1/index.html 用正则捕获组构造回退路径 $1 = resume → 检查 /usr/share/nginx/html/resume/index.html → 存在!返回它
=404 终极兜底 只有以上全失败才走这里

[!example] try_files 的执行流程

flowchart TD
    S["收到请求 /resume/1"] --> T1{"$uri 是文件?"}
    T1 -->|存在| R1["返回该文件 ✅"]
    T1 -->|不存在| T2{"$uri/ 是目录?"}
    T2 -->|存在| R2["返回目录下的 index ✅"]
    T2 -->|不存在| T3{"/$1/index.html 存在?"}
    T3 -->|存在| R3["返回 SPA 入口 index.html ✅"]
    T3 -->|不存在| R4["=404 ❌"]
    style S fill:#1a365d,color:#e2e8f0
    style T1 fill:#2a4365,color:#e2e8f0
    style T2 fill:#2a4365,color:#e2e8f0
    style T3 fill:#2a4365,color:#e2e8f0
    style R1 fill:#276749,color:#fff
    style R2 fill:#276749,color:#fff
    style R3 fill:#276749,color:#fff
    style R4 fill:#742a2a,color:#fff

最终的 Nginx 配置

server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # 规则 1:首页,精确匹配
    location = / {
        try_files /index.html =404;
    }

    # 规则 2:带尾斜杠的路径,如 /resume/、/resume/1、/demo/assets/style.css
    location ~ ^/([^/]+)/ {
        try_files $uri $uri/ /$1/index.html =404;
    }

    # 规则 3:不带尾斜杠的路径,如 /resume
    location ~ ^/([^/]+)$ {
        try_files $uri $uri/ =404;
    }
}

三条规则的分工:

flowchart TD
    R["收到请求"] --> Q1{"路径是 / ?"}
    Q1 -->|是| A1["规则 1\n返回首页 index.html"]
    Q1 -->|否| Q2{"有尾部斜杠?"}
    Q2 -->|是| A2["规则 2\n找真实文件 → 找不到返回 SPA 入口"]
    Q2 -->|否| A3["规则 3\n找文件 → 找不到则 301 重定向"]
    style R fill:#1a365d,color:#e2e8f0
    style Q1 fill:#2a4365,color:#e2e8f0
    style Q2 fill:#2a4365,color:#e2e8f0
    style A1 fill:#276749,color:#fff
    style A2 fill:#276749,color:#fff
    style A3 fill:#276749,color:#fff

逐个请求验证:

请求 匹配规则 try_files 执行过程 结果
/ 规则 1 = / 直接返回 /index.html ✅ 首页
/resume/ 规则 2 $uri/ 是目录 → 返回目录内 index.html ✅ 幻灯片
/resume/1 规则 2 $uri 不存在 → $uri/ 不存在 → /$1/index.html = /resume/index.html ✅ 幻灯片
/demo/assets/style.css 规则 2 $uri 是真实文件 → 直接返回 ✅ CSS 资源
/resume 规则 3 $uri 是目录 → $uri/ 存在 → nginx 自动 301 到 /resume/ 🔄 重定向

[!important] 正则捕获组的妙用

^/([^/]+)/ 中 () 内的 [^/]+ 匹配"一个或多个非斜杠字符"。对于 /resume/1,捕获组 $1 的值就是 resume。这样 /$1/index.html 就能动态拼出 /resume/index.html,把所有同目录的子路径都指向同一个 SPA 入口——这就是所谓的"catch-all"模式。

为什么需要三条规则?

你可能会问:一条 location / { try_files $uri $uri/ /index.html =404; } 不就完了吗?

确实可以,但有隐患:

方案 优点 缺点
一条通用规则 简洁 会把 /api/xxx、/admin/ 等后端路径也错误地返回 index.html
分规则处理 精准控制,不影响其他服务 略复杂,需要理清每条的职责

对于只跑前端的静态站点,一条规则就够了。但如果 nginx 还要代理后端 API,就需要用更细粒度的规则来隔离前后端路径。这也解释了为什么我们要设计三条独立的规则而非一条通用的。

调试与常见问题

在配置 nginx 时遇到问题?以下是实用的排查思路:

# CI 流水线中的配置验证(临时加入,确认部署成功后可移除)
- name: Verify nginx config
  run: |
    docker run -d --name slides-nginx-test -p 9080:80 nginx:alpine
    docker cp nginx.conf slides-nginx-test:/etc/nginx/conf.d/default.conf
    docker cp dist/. slides-nginx-test:/usr/share/nginx/html/
    docker exec slides-nginx-test nginx -s reload
    curl -s -o /dev/null -w "%{http_code}" http://localhost:9080/resume/1 && echo "OK" || echo "FAIL"
    docker rm -f slides-nginx-test

常见陷阱:

  1. 没重载配置:改了 .conf 文件后,必须 nginx -s reload 或重启容器。否则旧的配置仍在生效。
  2. 权限问题:docker cp 后的文件属主可能是 root,而 nginx worker 以 nginx 用户运行,可能出现 Permission denied。确保 root 指向的目录对所有用户可读。
  3. try_files 最后必须是文件或 =code:不能以目录名结尾(如 try_files $uri $uri/ /index.html/ 是错的,应写 try_files $uri $uri/ /index.html =404)。

举一反三

这个配置模式适用于所有使用 HTML5 History 路由的前端项目:

框架/工具 路由方式 适用此方案
React Router <BrowserRouter> (History 模式) ✅
Vue Router mode: 'history' ✅
VitePress / Docusaurus 内置 History 模式 ✅
Slidev 内置 History 模式 ✅
Angular Router 默认 History 模式 ✅

[!tip] 一句话总结

Nginx 不认识的路径,统统交给前端的 index.html,让 JavaScript 去接管路由。

关联笔记