REACT · Vol.VI · LESSON 42 · 性能优化与部署
部署:静态站 / Nginx / CI 流水线
SPA 部署的本质:静态文件,加一条回退路由
第 40 篇你敲下 npm run build 得到 dist 目录,当时说「这是要发上服务器的东西」。部署前,先把产物看清楚:
dist/
├── index.html # 唯一 HTML 入口,引用下面带 hash 的资源
├── assets/
│ ├── index-Bq3f9x2a.js # 带 hash 的 JS(hash 规则第 40 篇讲过)
│ ├── index-Ck8d1p4m.css # 带 hash 的 CSS
│ └── vendor-Dx2e8f7b.js # 拆出来的第三方库包
└── favicon.ico
看明白了吗?SPA 部署的本质,就是把这堆静态文件放到一个 HTTP 服务器上——没有常驻进程、没有内存状态,逻辑全在浏览器里。这和部署 war 完全不同:
| 维度 | 后端:war 丢进 Tomcat | 前端:dist 丢给 Nginx |
|---|---|---|
| 产物形态 | 有状态进程,JVM 常驻跑你的代码 | 无状态文件,逻辑在浏览器执行 |
| 部署动作 | 放 webapps、重启 | 替换目录里的文件,即时生效 |
| 路由由谁决定 | Controller 注解说了算 | 文件路径 + 回退规则 |
| 典型故障 | 500、OOM、连接池打满 | 404、白屏、缓存不一致 |
SPA 部署最经典的坑就藏在「路由」这一行:应用里有 /bookmarks/3 这个页面,站内点击跳转一切正常——但用户按 F5 刷新,Nginx 直接给 404。
你部署后端时从不担心「Tomcat 怎么知道该响应 /bookmarks/3」,路由定义在代码里。前端反过来:路由只活在浏览器加载的 JS 中,服务器上根本不存在 /bookmarks/3 这个文件——站内跳转是 JS 拦截的,刷新才是真请求。所以必须有一条规则告诉 Nginx:文件找不到,就把 index.html 给它。这条回退路由(fallback),就是 SPA 部署的全部秘密。
Nginx 三件套:回退路由、差异化缓存、压缩
一份能扛生产的 Nginx 配置,浓缩下来就三件事,一次配齐:
server {
listen 80;
server_name bookmark.example.com;
root /var/www/bookmark/dist; # 指向第 40 篇的构建产物
index index.html;
gzip on; # 文本压缩:JS/CSS/HTML 体积约砍到 1/3
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1k;
# 1) 带 hash 的静态资源:内容不变文件名就不变,放心缓存一年
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# 2) 入口 HTML:绝不能长缓存,否则发布后用户拿不到新版本
location = /index.html {
add_header Cache-Control "no-cache";
}
# 3) 回退路由:SPA 部署的灵魂
location / {
try_files $uri $uri/ /index.html;
}
}
第 1、2 条是一对组合拳,呼应第 40 篇的 hash 文件名:发新版后 assets 文件名全变(新 hash),旧 URL 缓存再久也不会错;而引用它们的 index.html 路径没变、内容变了——必须每次回源验证(no-cache 是「可缓存但先用前问 304」,不是禁止缓存),一验证就拿到引用新 hash 的 HTML,用户立刻见到新版。配反了会怎样?HTML 长缓存、资源短缓存——发布了用户看到的还是旧页面,这是前端事故榜第一名「缓存不一致」。
immutable 是给浏览器的承诺:「一年内不会变,连协商请求都别发」,每次访问省掉一次 RTT。第 3 条 try_files $uri $uri/ /index.html 读作:先找文件,再找目录,都没有——回退 index.html,剩下交给 JS 路由。
压缩再补一句:gzip 必开;Brotli 压缩率再好 15~20%,但 Nginx 要额外装 ngx_brotli 模块(发行版常不带),常见折中是 CDN 层做 Brotli、源站只管 gzip。
- SPA 部署三件套:
root指向 dist、try_files回退到 index.html、HTML 与带 hash 资源差异化缓存。 - 缓存铁律:index.html 用
no-cache(协商缓存),/assets/用一年immutable——配反了就是「发布后用户看不到新版」。 - hash 文件名(第 40 篇)+ immutable 是天生一对;gzip 必开,Brotli 更优但依赖模块。
反代 /api 与环境差异化配置
真实项目里前端不会单独活着——身后还有你的 Spring Boot。往 server 块里补一段:
# 浏览器只见同源的 /api,跨域问题在这套架构里根本不会发生
location /api/ {
proxy_pass http://127.0.0.1:8080/; # 末尾带斜杠:/api/xxx 转成 /xxx
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;
}
后端 server.servlet.context-path 照旧,CORS 注解一行不用写——浏览器只认识一个域名,同源策略没机会触发。要拿真实客户端 IP?看 X-Forwarded-For,你在线上排查「日志里全是 Nginx 的 IP」时一定打过交道。
同域转发、隐藏真实后端、透传真实 IP——这段 location 就是 Spring Cloud Gateway 路由规则的最小集,只是载体从 YAML 换成了 nginx.conf。CORS 也顺带消失:跨域是浏览器的同源策略,服务器对服务器的调用从来不存在这个问题。
接着解决「环境」。后端你有 profile:三份配置,同一份 jar 启动时切换。前端有个关键差异——前端没有运行时配置,变量在构建时就被「烧」进产物里了:
# .env.production —— npm run build 时读取
VITE_API_BASE=/api
# .env.staging —— npm run build -- --mode staging 时读取
VITE_API_BASE=https://staging-api.example.com/api
export const API_BASE = import.meta.env.VITE_API_BASE ?? "/api";
import.meta.env.VITE_* 在 build 时被直接替换成字符串常量。规则变成:一次构建对应一个环境,三个环境就是三次构建——这正是下文 CI 里 build 要显式注入环境变量的原因。
| Spring Profile(运行时切换) | 前端构建注入(构建时烧死) | |
|---|---|---|
| 配置生效时机 | 启动时读配置,包是通用的 | build 时字符串替换,产物是定制的 |
| 切环境 | 重启进程,加启动参数 | 重新 build |
| 一份产物多环境 | 可以 | 不行 |
| 改配置要不要重发 | 改配置文件重启即可 | 必须重新构建发布 |
VITE_ 开头才会被注入?Vite 只暴露显式声明前缀的变量——这些值会被原样发给每一个用户浏览器,所以前端的环境配置里永远不该出现秘密。GitHub Actions:push 即发布
手工部署流程你都熟了,现在交给流水线。一份完整可用的 GitHub Actions 配置:
name: deploy
on:
push:
branches: [main] # 合进主干即触发,≈ 你配的 webhook
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20 # 锁 Node 大版本,≈ 锁 JDK
cache: npm
- run: npm ci # 严格按 package-lock.json 安装
- run: npm run lint
- run: npm test # 测试不过不许上线——铁律通用
- name: 构建(注入目标环境)
run: npm run build
env:
VITE_API_BASE: /api
- name: 同步产物到服务器
uses: easingthemes/ssh-deploy@v5
with:
SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_KEY }}
SOURCE: dist/
TARGET: /var/www/bookmark/dist/
ARGS: "-avz --delete"
三个后端工程师一眼就会点头的细节:
- npm ci 而不是 npm install:完全按 lockfile 安装,不一致直接报错——对应你「依赖版本必须锁死」的洁癖;install 可能悄悄更新小版本,CI 里是隐患。
- secrets 管理密钥:私钥配在仓库 Settings → Secrets,永不进代码库——等价于 Jenkins 的 credentials 绑定。
- --delete 全量替换:rsync 删掉产物里已不存在的旧 hash 文件,发布目录永远与当前构建一一对应,不留孤儿。
checkout → compile → test → package → deploy,这个阶段划分你闭着眼能画,只是 compile/build 换成了 vite。GitHub Actions 就是免运维的 Jenkins:workflow YAML 就是 Jenkinsfile,runner 就是 agent,secrets 就是 credentials,uses: 就是共享库插件。「质量门禁不过不发包」的纪律原样适用。
Docker 多阶段构建,与卷六收官
最后把产物装进镜像——你最熟悉的形状。前端版多阶段构建:
# ── 阶段一:构建层(node_modules 有几百 MB,不进最终镜像) ──
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ── 阶段二:运行层(Nginx + 静态文件,完事) ──
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
COPY --from=build 只把 dist 拷进运行层:node_modules、devDependencies、Node 本体全部留在构建层。最终镜像 = Nginx + 静态文件,几十 MB、攻击面小、CI 拉取飞快——和你 Java 的两段式 Dockerfile(Maven 层 → JRE 层)同一个模式,连「先 COPY 依赖清单再装依赖」的层缓存技巧都一样。
再往上一层是 CDN:把 dist 整体托管到 OSS + CDN / Cloudflare Pages / Vercel 这类边缘网络,源站只留 API——「静态上边缘、动态回源」,前面配的缓存头原样适用。展开属于另一门课,这句就够。
- 卷六六篇的完整链路:第 37 篇重渲染与 memo → 第 38 篇虚拟列表 → 第 39 篇代码分割 → 第 40 篇构建与 hash → 第 41 篇 Profiler 与 Web Vitals → 本篇部署与 CI。
- SPA 上线三件事配齐才算上线:回退路由、差异化缓存、API 反代;环境差异在构建时注入,一次构建一环境。
- 流水线、多阶段镜像、密钥管理、质量门禁——全是后端知识平移。前端部署只是你后端部署知识的一半新东西,另一半你早就会了。
本章回顾
本篇为第六卷收官。回望这一卷:测量(第 41 篇)、优化(37~39 篇)、构建(第 40 篇),今天又把 dist 送到用户手上——回退路由让刷新不再 404,差异化缓存让发布即时生效,反代让跨域消失,CI 让 push 即发布,多阶段镜像让交付物只剩几十 MB。「测量 → 优化 → 构建 → 部署 → 自动化」在你手里完整闭环。但「会前端」和「全栈」之间还差一张地图:先学什么、后学什么、学到什么程度算够。下一篇进入卷七,第 43 篇「Java 后端全栈转型路线图」,把这张图画给你。
Comments · 评论