--- tags: [nginx, SPA, 部署, Slidev, 前端路由, try_files] create time: 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 中的数字决定显示第几页。 这就引出了两种截然不同的访问方式: ```mermaid 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 ``` ```mermaid 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 的核心指令之一,语法: ```nginx try_files file1 file2 ... fallback; ``` Nginx 按顺序尝试每个文件,**第一个存在的就返回**,全部不存在才执行最后一个 fallback(通常是 `=404`)。 来看我们最终方案中的核心行: ```nginx 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 的执行流程 > > ```mermaid > 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 配置 ```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; } } ``` 三条规则的分工: ```mermaid 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 时遇到问题?以下是实用的排查思路: ```yaml # 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 | `` (History 模式) | ✅ | | Vue Router | `mode: 'history'` | ✅ | | VitePress / Docusaurus | 内置 History 模式 | ✅ | | Slidev | 内置 History 模式 | ✅ | | Angular Router | 默认 History 模式 | ✅ | > [!tip] 一句话总结 > > **Nginx 不认识的路径,统统交给前端的 `index.html`,让 JavaScript 去接管路由。** ## 关联笔记 - [[Slidev 项目结构与部署]]