10 KiB
tags, create time
| tags | 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 中的数字决定显示第几页。
这就引出了两种截然不同的访问方式:
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
常见陷阱:
- 没重载配置:改了
.conf文件后,必须nginx -s reload或重启容器。否则旧的配置仍在生效。 - 权限问题:
docker cp后的文件属主可能是 root,而 nginx worker 以nginx用户运行,可能出现 Permission denied。确保root指向的目录对所有用户可读。 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 去接管路由。