16 · CI/CD 与部署运维
本篇解决什么问题: 前端部署的事故几乎都出在同一件事上——缓存。 代码发了但用户看到的还是旧的、或者 HTML 更新了但 JS 没更新导致白屏。 本篇讲清 流水线 → 制品 → 分发 → 灰度回滚,重点是缓存失效的正确姿势。
📌 与 A04 的分工:A04 讲浏览器端的缓存策略(
Cache-Control怎么用), 本篇讲发布侧如何让缓存正确失效。
开篇速览
| 环节 | 关键决策 |
|---|---|
| 流水线 | CI 跑什么(lint / 类型 / 测试 / 构建),多久 |
| 制品 | 产物怎么存、怎么回溯、要不要保留历史版本 |
| 分发 | Nginx / CDN / 对象存储,静态资源与 HTML 的策略不同 |
| 缓存失效 | HTML 不缓存 + 资源带 hash 永久缓存(本篇重点) |
| 灰度与回滚 | 怎么发一小部分、出问题怎么秒回 |
一、流水线:CI 该跑什么
1.1 典型阶段(按顺序,快失败优先)
yaml
# .github/workflows/ci.yml(示例)
jobs:
build:
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci # ✅ 用 ci 不用 install(严格按 lockfile)
- run: npm run lint # ① 快(秒级)
- run: npm run type-check # ② 中
- run: npm test -- --coverage # ③ 慢
- run: npm run build # ④ 最慢,放最后
- uses: actions/upload-artifact@v4
with: { name: dist, path: dist }排序原则:越快的越靠前,让错误尽快暴露(lint 报错就别跑 10 分钟的构建了)。
1.2 必须的门禁
| 门禁 | 为什么 |
|---|---|
npm ci | install 可能改 lockfile 导致本地与 CI 不一致 |
| lint | 本地 husky 可被 --no-verify 绕过(见 A28) |
type-check (tsc --noEmit) | 构建工具(Vite/esbuild)不做类型检查,TS 错误能"构建成功" |
| test | 回归保护 |
| 产物体积检查 | 防止误引入巨型依赖 |
⚠️ 常见疏漏:Vite/esbuild 为了速度跳过类型检查, 所以 CI 里必须单独跑
tsc --noEmit,否则类型错误会一路流到生产。
二、制品:产物怎么管理
| 做法 | 说明 |
|---|---|
| 每次构建产物独立归档 | 保留 dist-<commit-sha>-<时间戳>,便于回溯与回滚 |
| 产物与源码分离 | 不要提交 dist 到 Git(本站就是产物不走 Git 分支,由 wrangler 直传) |
| 带上构建元信息 | 写入 __BUILD_INFO__(commit sha / 时间 / 分支),便于线上排查 |
| 保留周期 | 至少保留最近 10 个可回滚版本 |
js
// vite.config.js:注入构建信息
import { execSync } from 'node:child_process'
define: {
__BUILD_INFO__: JSON.stringify({
sha: execSync('git rev-parse --short HEAD').toString().trim(),
time: new Date().toISOString(),
}),
}三、缓存策略(本篇核心)
3.1 黄金法则
HTML:不缓存(或每次校验);带 hash 的静态资源:永久缓存。
/index.html → Cache-Control: no-cache (每次都要问服务端)
/assets/app.8f3a2b.js → Cache-Control: max-age=31536000, immutable (一年,内容变了文件名就变)为什么这样设计:
| 文件 | 变了文件名吗 | 策略 |
|---|---|---|
| HTML | ❌ 永远叫 index.html | 不能长缓存,否则用户拿不到新版本入口 |
| JS/CSS(带 hash) | ✅ 内容变 → hash 变 → 文件名变 | 可以永久缓存,因为新版本是新文件名,不会覆盖旧的 |
用户访问 index.html(no-cache)
→ 拿到新的 HTML,里面引用 /assets/app.9c1d7e.js(新文件名)
→ 浏览器没缓存过这个名字 → 去下载 ✅
→ 旧的 app.8f3a2b.js 留在缓存里没人用,自然淘汰🎯 这就是"内容寻址(content hash)"的威力: 新版本不会覆盖旧版本,所以不存在"缓存了该不该更新"的两难。
3.2 必须避免的反模式
| ❌ 反模式 | 后果 |
|---|---|
给 index.html 设 max-age=3600 | 一小时内用户拿不到新版本,紧急修复发不出去 |
| 静态资源不带 hash 却长缓存 | 改了代码文件名没变 → 用户永远用旧的 |
用"版本号目录"(/v1.2.3/app.js)但没同步更新 HTML 引用 | 404 |
| CDN 上手动刷新缓存当常规手段 | 有延迟、易漏、不可靠(应该是应急手段) |
3.3 服务端配置示例
nginx
# Nginx
location = /index.html {
add_header Cache-Control "no-cache"; # 每次校验(可配合 ETag 返回 304)
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
try_files $uri $uri/ /index.html; # ✅ history 路由的 fallback(见 A15)
}txt
# Cloudflare Pages _redirects
/* /index.html 200四、部署方式
| 方式 | 做法 | 适合 |
|---|---|---|
| 静态托管(推荐) | 产物传到对象存储/CDN(OSS + CDN、Cloudflare Pages、Netlify、Vercel) | 纯静态前端(绝大多数) |
| 容器 | Docker 镜像里跑 Nginx,K8s 部署 | 需要 SSR(A13)或统一到后端基础设施 |
| 服务器直传 | rsync / scp 到 Nginx 目录 | 小项目、内网 |
📌 本站的做法:VitePress 构建产物由 wrangler 直传 Cloudflare Pages, 不走 Git 分支(避免产物污染仓库)。详见 deploy.md。
4.1 原子化发布(避免"半新半旧")
问题:直接覆盖上传时,用户可能在上传中途访问 → 一部分文件是新版,一部分是旧版 → 白屏。
解法:
| 方案 | 做法 |
|---|---|
| 不可变部署(推荐) | 每次发布到新目录(/releases/<sha>/),发布完成再切换软链接指向它 |
| 先传资源后传 HTML | 先传带 hash 的 JS/CSS(不影响旧版),最后覆盖 index.html |
| 对象存储版本化 | 配合 CDN 回源策略 |
🎯 "先上传新资源,最后切换 HTML" 是因为: HTML 是入口,最后切换能保证"要么完全是旧版,要么完全是新版"。
五、灰度与回滚
5.1 灰度(渐进发布)
| 层级 | 做法 |
|---|---|
| CDN 层 | 按 Cookie/Header 分流到不同版本目录 |
| 网关层 | Nginx 按权重/白名单转发 |
| 前端层 | 功能开关(Feature Flag),代码里控制是否启用 |
| 小程序/App | 平台自带的分阶段发布 |
推荐:前端用 Feature Flag 做"逻辑灰度"(成本低、秒级切换), 基础设施层做"版本灰度"(更安全但需要平台支持)。
5.2 回滚
| 回滚对象 | 做法 | 速度 |
|---|---|---|
| 版本回滚(推荐) | 把入口切回上一个 release 目录/软链接 | 秒级 |
| 重新构建 | 从旧 commit 重新 build + 部署 | 分钟级(且可能引入新差异) |
git revert | 源码层面回退 | 慢,适合逻辑错误 |
⚠️ 不要用"重新构建旧代码"来回滚——构建环境、依赖版本可能已变,产物未必与线上旧版一致。 必须保留历史制品,回滚时直接切换。
六、发布检查清单
发版前逐条确认:
- [ ] CI 全绿(lint / type-check / test / build)
- [ ] 产物包含带 hash 的静态资源
- [ ]
index.html的缓存策略是no-cache - [ ] history 路由的 fallback 已配置(否则刷新 404)
- [ ] 环境变量/接口地址确认是生产环境(最常见的事故:测试地址发到生产)
- [ ] 历史制品已归档,回滚路径可用
- [ ] 发布后访问线上验证(关键页面点一遍)
- [ ] 有监控/告警(见 A12)
自检清单
- [ ] 能说出 CI 各阶段的排序原则与"必须跑 type-check"的原因
- [ ] 能讲清 HTML 不缓存 + 带 hash 资源永久缓存的原理
- [ ] 知道"先传资源、最后切 HTML"的原因(避免半新半旧)
- [ ] 能说出回滚应该用历史制品切换而非重新构建
- [ ] 知道 history 模式必须配 fallback
常见误区
| ❌ 误区 | ✅ 事实 |
|---|---|
| "给所有文件设长缓存能提速" | HTML 长缓存会导致发不出去新版本 |
| "Vite 构建成功就说明 TS 没错误" | Vite/esbuild 不做类型检查,必须单独跑 tsc |
| "出问题就重新构建上一版" | 产物可能已不一致 → 应切换历史制品 |
| "CDN 刷新缓存是常规操作" | 应是应急手段;内容 hash 才是正解 |
| "部署就是把 dist 传上去" | 要考虑原子性(否则中途访问会白屏) |
动手练习
- 检查你们项目的响应头:
index.html与/assets/*.js的Cache-Control分别是多少?是否符合黄金法则。 - 故意给
index.html设max-age=3600,发布一个新版本,观察用户在一小时内能否看到更新。 - 设计一个"不可变部署"脚本:发布到
/releases/<sha>/,完成后切换软链接,并验证回滚只需改软链接。
相关文档
- 浏览器端缓存策略 → A04 · 浏览器与网络
- history 路由 fallback → A15 · 路由与数据加载
- 线上问题怎么定位 → A12 · 前端可观测性与线上排障
- 本站部署说明 → deploy.md