vault backup: 2026-05-28 16:00:08

This commit is contained in:
2026-05-28 16:00:08 +08:00
parent d09524a6b2
commit 2a0d4aa33c
@@ -0,0 +1,257 @@
---
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 项目结构与部署]]