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

258 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 | `<BrowserRouter>` (History 模式) | ✅ |
| Vue Router | `mode: 'history'` | ✅ |
| VitePress / Docusaurus | 内置 History 模式 | ✅ |
| Slidev | 内置 History 模式 | ✅ |
| Angular Router | 默认 History 模式 | ✅ |
> [!tip] 一句话总结
>
> **Nginx 不认识的路径,统统交给前端的 `index.html`,让 JavaScript 去接管路由。**
## 关联笔记
- [[Slidev 项目结构与部署]]