外卖平台部署: 前端 dist 部署与 Nginx 反向代理 rewrite 配置
纲要
「部署前端项目」,两步完成前端上线:
- 上传
dist目录 :前端Webpack打包产物,整体放到Nginx的html目录下 - 修改
nginx.conf:root指向html/dist,再加一段^~ /api/的反向代理 rewrite的必要性 :前端请求带/api前缀,后端接口没有,rewrite负责剥掉它rewrite正则解析 :^/api/(.*)$与$1的含义,以及break标志- 完整请求链路 :点登录 →
/api/employee/login→ 重写为/employee/login→ 转发到8080 ^~前缀匹配 :为什么用它而不是普通location
Spring Boot :8080 Nginx :80 浏览器 Spring Boot :8080 Nginx :80 浏览器 #mermaid-svg-zufiyFkru5qrFnBo{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zufiyFkru5qrFnBo .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zufiyFkru5qrFnBo .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zufiyFkru5qrFnBo .error-icon{fill:#552222;}#mermaid-svg-zufiyFkru5qrFnBo .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zufiyFkru5qrFnBo .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zufiyFkru5qrFnBo .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zufiyFkru5qrFnBo .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zufiyFkru5qrFnBo .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zufiyFkru5qrFnBo .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zufiyFkru5qrFnBo .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zufiyFkru5qrFnBo .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zufiyFkru5qrFnBo .marker.cross{stroke:#333333;}#mermaid-svg-zufiyFkru5qrFnBo svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zufiyFkru5qrFnBo p{margin:0;}#mermaid-svg-zufiyFkru5qrFnBo .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-zufiyFkru5qrFnBo text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-zufiyFkru5qrFnBo .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-zufiyFkru5qrFnBo .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-zufiyFkru5qrFnBo .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-zufiyFkru5qrFnBo .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-zufiyFkru5qrFnBo #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-zufiyFkru5qrFnBo .sequenceNumber{fill:white;}#mermaid-svg-zufiyFkru5qrFnBo #sequencenumber{fill:#333;}#mermaid-svg-zufiyFkru5qrFnBo #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-zufiyFkru5qrFnBo .messageText{fill:#333;stroke:none;}#mermaid-svg-zufiyFkru5qrFnBo .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-zufiyFkru5qrFnBo .labelText,#mermaid-svg-zufiyFkru5qrFnBo .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-zufiyFkru5qrFnBo .loopText,#mermaid-svg-zufiyFkru5qrFnBo .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-zufiyFkru5qrFnBo .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-zufiyFkru5qrFnBo .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-zufiyFkru5qrFnBo .noteText,#mermaid-svg-zufiyFkru5qrFnBo .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-zufiyFkru5qrFnBo .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-zufiyFkru5qrFnBo .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-zufiyFkru5qrFnBo .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-zufiyFkru5qrFnBo .actorPopupMenu{position:absolute;}#mermaid-svg-zufiyFkru5qrFnBo .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-zufiyFkru5qrFnBo .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-zufiyFkru5qrFnBo .actor-man circle,#mermaid-svg-zufiyFkru5qrFnBo line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-zufiyFkru5qrFnBo :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} GET / (访问首页) location / 匹配 root html/dist 返回 dist/index.html POST /api/employee/login location ^~ /api/ 匹配 rewrite ^/api/(.*) /1 break /api/employee/login → /employee/login proxy_pass http://192.168.138.101:8080 POST /employee/login R<Employee> JSON 返回响应
一、dist 目录是什么
前端打包产物
前端开发有自己的技术栈,开发完成后同样需要打包。前端用 Webpack 打包,产物就是 dist 目录。
它和后端打包完全不同:
| 后端 | 前端 | |
|---|---|---|
| 打包工具 | Maven |
Webpack |
| 产物 | *.jar / *.war |
dist/ 目录 |
| 内容 | 字节码 + 依赖 | HTML / CSS / JS / 字体 / 图片 |
dist 目录的内容
txt
dist/
├── index.html # 首页入口
├── favicon.ico # 站点图标
├── static/
│ ├── css/
│ │ └── app.a1b2c3d4.css # 压缩后的样式
│ ├── js/
│ │ ├── app.e5f6g7h8.js # 压缩后的业务逻辑
│ │ ├── chunk-vendors.i9j0k1l2.js # 第三方库
│ │ └── ...
│ ├── fonts/ # 字体文件
│ └── img/ # 图片资源
└── ...
几个值得注意的点:
文件名带哈希 。app.a1b2c3d4.css 这种命名是 Webpack 的内容哈希------内容变了文件名就变,从而配合长缓存策略(Cache-Control: max-age=31536000)实现"内容更新时立即生效,未更新时永久缓存"。
文件被压缩 。打开 JS 文件会发现所有代码挤在一行,变量名被缩短成单字母,注释全部删除。这是生产构建的标准处理,能把体积压缩到原来的 1/3 甚至更小。
与源码不同 。打包后的文件名、目录结构与开发时的源码完全对不上------这是打包工具做了合并、压缩、重命名。所以不要试图在服务器上改 dist 里的代码,改源码重新打包才是正道。
二、部署第一步:上传 dist
操作
把课程资料中的 dist 目录整个 上传到 Nginx 的 html 目录:
txt
/usr/local/nginx/html/
├── 50x.html
├── index.html # Nginx 自带的默认首页
└── dist/ # ← 上传到这里
├── index.html
└── static/
用 FinalShell 等工具的上传功能,或 scp:
bash
scp -r ./dist root@192.168.138.100:/usr/local/nginx/html/
也可以用 lrzsz(第 4 篇讲过):
bash
cd /usr/local/nginx/html
rz # 选择文件上传(目录需先打包成 tar.gz)
tar -zxvf dist.tar.gz
验证
bash
cd /usr/local/nginx/html/dist
ls
# index.html static favicon.ico ...
目录结构与本地一致即上传成功。
三、部署第二步:修改 nginx.conf
完整配置
把原有的监听 80、82、8080 的虚拟主机注释掉,改为:
nginx
server {
listen 80;
server_name localhost;
location / {
root html/dist;
index index.html;
}
location ^~ /api/ {
rewrite ^/api/(.*)$ /$1 break;
proxy_pass http://192.168.138.101:8080;
}
location = /50x.html {
root html;
}
}
为什么要注释掉之前的 server :前面的练习配了监听 80(静态资源)、82(反向代理)、8080(负载均衡)三个虚拟主机。现在正式部署,只需要一个 80 端口的 server 同时承担静态资源与反向代理,多余的会干扰。
配置逐条解释
listen 80 + server_name localhost
监听 80 端口,用户访问时不用写端口号。
location / ------ 静态资源
nginx
location / {
root html/dist;
index index.html;
}
关键变化是 root 从 html 变成了 html/dist。
root html;→ 根目录是/usr/local/nginx/html,首页是Nginx自带的欢迎页root html/dist;→ 根目录是/usr/local/nginx/html/dist,首页是项目页面
这就是替换默认欢迎页的原理 ------不是删掉 index.html,而是把根指向了 dist。
location = /50x.html ------ 错误页
nginx
location = /50x.html {
root html;
}
注意这里的 root 是 html 而不是 html/dist------错误页用 Nginx 自带的那个,因为 dist 里没有 50x.html。
= 表示精确匹配,优先级最高。
location ^~ /api/ ------ 反向代理
nginx
location ^~ /api/ {
rewrite ^/api/(.*)$ /$1 break;
proxy_pass http://192.168.138.101:8080;
}
下一节详细展开。
检查与重启
bash
nginx -t
nginx -s reload
访问 http://192.168.138.100,看到的不再是 Welcome to nginx!,而是项目的登录页面------root 生效了。
四、为什么需要 rewrite
前端发出的请求
打开浏览器 F12,点登录按钮,观察请求:
txt
POST http://192.168.138.100/api/employee/login
注意三点:
IP是192.168.138.100------请求发给了Nginx自己(同源,无跨域)- 端口是
80------默认端口 - 路径是
/api/employee/login------带了/api前缀
后端能处理的路径
后端的 EmployeeController:
java
@PostMapping("/login")
public R<Employee> login(HttpServletRequest request, @RequestBody Employee employee) {
...
}
类上是 @RequestMapping("/employee"),方法上是 @PostMapping("/login"),所以完整路径是:
txt
/employee/login ← 没有 /api
冲突
| 路径 | |
|---|---|
| 前端发的 | /api/employee/login |
| 后端要的 | /employee/login |
如果不做处理直接转发 ,请求到后端变成 /api/employee/login,Spring MVC 找不到对应的 Handler,返回 404。
两种解法
解法一:改后端代码 ,给所有 Controller 加 /api 前缀(加 context-path 或改 @RequestMapping)。
yaml
server:
servlet:
context-path: /api
解法二:在 Nginx 层用 rewrite 剥掉前缀(课程采用)。
推荐第二种 ------后端接口路径不应被前端约定绑架。而且 /api 只是前端区分"动态请求"的标记,让 Nginx 用它做路由判断更合理:/api/** 走后端,其他走静态资源。
这也是实际项目中的主流做法 :前端统一加前缀,Nginx 靠前缀区分动静态,同时剥掉前缀转发。好处是后端接口路径保持干净,Nginx 配置一处即可。
五、rewrite 正则解析
配置
nginx
rewrite ^/api/(.*)$ /$1 break;
语法
rewrite <正则表达式> <替换后的路径> [标志];
逐段拆解
^/api/(.*)$ ------ 匹配规则
| 片段 | 含义 |
|---|---|
^ |
以......开始 |
/api/ |
字面量 /api/ |
(.*) |
捕获组:任意字符,任意长度 |
$ |
以......结束 |
/$1 ------ 替换结果
$1 引用第一个捕获组(即 (.*) 匹配到的内容),前面加 /。
break ------ 标志
表示重写完成后停止后续 rewrite 规则的处理,直接用新路径继续。
实战推演
以 /api/employee/login 为例:
txt
原始路径:/api/employee/login
正则匹配:^/api/(.*)$
├──┬──┘├─┬─┘
│ └─ (.*) 捕获到 "employee/login"
└─ 字面量 /api/
$1 = "employee/login"
替换结果:/$1 = /employee/login
即:
txt
/api/employee/login → /employee/login
再配合 proxy_pass http://192.168.138.101:8080;,最终请求:
txt
http://192.168.138.101:8080/employee/login
正好是后端能处理的路径。
更多例子
| 原始请求 | 重写后 | 转发到后端 |
|---|---|---|
/api/employee/login |
/employee/login |
8080/employee/login |
/api/employee/logout |
/employee/logout |
8080/employee/logout |
/api/dish/page?page=1 |
/dish/page?page=1 |
8080/dish/page?page=1 |
/api/category |
/category |
8080/category |
/api/common/upload |
/common/upload |
8080/common/upload |
Query 参数不受影响 ------rewrite 只处理路径部分,?page=1 这类查询串会自动附加。
break 与 last 的区别
| 标志 | 行为 |
|---|---|
break |
重写后停止 rewrite 阶段,用当前 location 继续处理 |
last |
重写后重新发起一轮 location 匹配 |
redirect |
返回 302 临时重定向,浏览器地址栏变化 |
permanent |
返回 301 永久重定向 |
这里用 break 是对的------重写完直接交给同 location 的 proxy_pass。
如果用 last ,重写后的 /employee/login 会重新走一遍 location 匹配,可能被 location / 捕获而变成请求静态资源,导致 404 。这是 rewrite 的高频坑。
六、^~ 前缀匹配的意义
nginx
location ^~ /api/ { ... }
^~ 是 location 的修饰符,表示前缀匹配且优先于正则。
回顾第 66 篇讲的优先级:
| 写法 | 类型 | 优先级 |
|---|---|---|
location = /path |
精确匹配 | 最高 |
location ^~ /prefix/ |
优先前缀匹配 | 次高(不再检查正则) |
location ~ /regex/ |
正则匹配 | 中 |
location /prefix/ |
普通前缀匹配 | 低(会被正则覆盖) |
location / |
通用匹配 | 最低 |
为什么这里必须用 ^~:
如果写成普通的 location /api/,它的优先级低于正则 location。当配置里有其他正则 location(比如前面第 66 篇演示过的 location ~* \.(jpg|png)$ 匹配图片)时,/api/common/upload 这种带图片语义的路径可能被正则抢走,导致转发失败。
用 ^~ /api/ 后,一旦匹配到 /api/ 前缀就立即确定用它,不再看任何正则,行为确定可控。
建议 :凡是做反向代理的 location,统一用 ^~。
七、完整配置与验证
最终 nginx.conf(相关部分)
nginx
server {
listen 80;
server_name localhost;
# 前端静态资源:dist 目录
location / {
root html/dist;
index index.html;
}
# 动态请求:剥掉 /api 前缀后转发到后端
location ^~ /api/ {
rewrite ^/api/(.*)$ /$1 break;
proxy_pass http://192.168.138.101:8080;
# 透传客户端信息
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_read_timeout 60s;
}
# 错误页(用 Nginx 自带的)
location = /50x.html {
root html;
}
}
SPA 路由回退
如果前端用了 Vue Router 的 history 模式,还需加 try_files:
nginx
location / {
root html/dist;
index index.html;
try_files $uri $uri/ /index.html;
}
否则刷新 /dish/list 这样的前端路由会 404。
验证步骤
第一步:静态资源
访问 http://192.168.138.100,能看到项目登录页------root html/dist 生效。
第二步:反向代理(后端未启动时)
点登录,请求 /api/employee/login,此时后端还没部署,会返回 502 Bad Gateway 或连接失败。
这不是配置错误,只是后端服务还没起来。第 76 篇部署后端后即可打通。
第三步:后端启动后
再点登录,请求经 Nginx 转发到 8080,返回登录结果,页面跳转------全链路贯通。
验证技巧 :在 Nginx 机器上直接 curl 后端,排除后端问题:
bash
curl -X POST http://192.168.138.101:8080/employee/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"123456"}'
能返回结果说明后端没问题,问题在 Nginx 配置;不能则返回后端排查。
八、常见问题
| 现象 | 原因 | 解决 |
|---|---|---|
访问 80 还是 Nginx 欢迎页 |
root 没改成 html/dist |
检查 location / 的 root |
| 页面能开但样式全无 | dist 上传不完整 |
检查 static/ 目录是否存在 |
| 点登录报 404 | rewrite 未生效或 proxy_pass 地址错 |
检查 rewrite 正则与后端端口 |
| 点登录报 502 | 后端未启动 | 先 curl 验证后端 |
| 刷新子页面 404 | 未配 try_files |
加 try_files $uri $uri/ /index.html; |
/api 请求被静态资源 location 吃掉 |
未用 ^~ |
改为 location ^~ /api/ |
用 last 后请求失败 |
重写后重新匹配到 location / |
改用 break |
API 速览
| 指令 | 作用 |
|---|---|
root html/dist |
静态资源根目录指向 dist |
index index.html |
默认首页 |
try_files $uri $uri/ /index.html |
SPA 路由回退,避免刷新 404 |
location ^~ /api/ |
优先前缀匹配,不再检查正则 |
rewrite <regex> <replacement> <flag> |
URL 重写 |
^/api/(.*)$ |
正则:以 /api/ 开头,捕获其后全部内容 |
$1 |
引用第一个捕获组 |
break |
重写后停止 rewrite 阶段,用当前 location 继续 |
last |
重写后重新匹配 location(此处会导致问题) |
proxy_pass http://ip:port |
反向代理目标(无尾斜杠,保留重写后的完整路径) |
scp -r ./dist root@host:/path |
上传目录到服务器 |
nginx -t / nginx -s reload |
检查配置 / 重载 |
官方文档
Nginxrewrite模块:https://nginx.org/en/docs/http/ngx_http_rewrite_module.htmlNginxlocation指令:https://nginx.org/en/docs/http/ngx_http_core_module.html#locationNginx反向代理:https://nginx.org/en/docs/http/ngx_http_proxy_module.htmlNginxtry_files:https://nginx.org/en/docs/http/ngx_http_core_module.html#try_filesWebpack生产构建:https://webpack.docschina.org/guides/production/
总结
前端部署就两步 :把 dist 目录整个传到 Nginx 的 html 下;把 nginx.conf 里 location / 的 root 从 html 改成 html/dist。访问 80 端口看到的就不再是 Nginx 欢迎页而是项目页面------不是删了默认首页,而是换了根路径。
rewrite 解决的是"前端带 /api 前缀、后端不带"的路径不匹配问题 。后端接口路径不应该被前端约定绑架,所以在 Nginx 层剥掉前缀更合理。正则 ^/api/(.*)$ 捕获 /api/ 之后的所有内容存入 $1,替换成 /$1 即得到 /employee/login。
break 与 last 的选择是关键 。用 break 重写后直接在当前 location 继续(交给 proxy_pass);用 last 会重新走 location 匹配,很可能被 location / 捕获变成请求静态资源,导致 404。这是 rewrite 最高频的坑。
反向代理的 location 统一用 ^~ 。普通 location /api/ 优先级低于正则 location,可能被图片之类的正则规则抢走;^~ 一旦匹配就不再检查正则,行为确定可控。
502 不等于配置错误 ------后端还没部署时就是这个表现。排查时先在 Nginx 机器上 curl 后端,能通说明后端正常、问题在 Nginx;不能通则先修后端。
下一篇部署后端:在服务器 B 上用 git clone 拉代码、上传 reggieStart.sh 脚本一键完成"拉代码 → Maven 打包 → java -jar 启动",并解决图片展示不出来这个最后一公里问题。