Skip to content

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 ciinstall 可能改 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 传上去"要考虑原子性(否则中途访问会白屏)

动手练习 ​

  1. 检查你们项目的响应头:index.html 与 /assets/*.js 的 Cache-Control 分别是多少?是否符合黄金法则。
  2. 故意给 index.html 设 max-age=3600,发布一个新版本,观察用户在一小时内能否看到更新。
  3. 设计一个"不可变部署"脚本:发布到 /releases/<sha>/,完成后切换软链接,并验证回滚只需改软链接。

相关文档 ​