CI/CD 与部署实践
一、前端 CI/CD 流程全景
1.1 完整流水线
前端 CI/CD 流水线分为代码提交 → 代码检查 → 测试 → 构建 → 部署 → 通知六个阶段,每个阶段承担不同的质量保障职责。
开发提交代码
│
▼
┌─────────────────────────────────────────────────────┐
│ 1. 代码检查 (Lint) │
│ ├── ESLint — JavaScript/TypeScript 语法检查 │
│ ├── Prettier — 代码格式检查 │
│ ├── Stylelint — 样式规范检查 │
│ └── TypeScript 类型检查 (tsc --noEmit) │
└──────────────────────┬──────────────────────────────┘
▼
┌─────────────────────────────────────────────────────┐
│ 2. 自动化测试 (Test) │
│ ├── 单元测试 (Vitest / Jest) + 覆盖率报告 │
│ ├── 组件测试 (Testing Library) │
│ └── E2E 测试 (Playwright) │
└──────────────────────┬──────────────────────────────┘
▼
┌─────────────────────────────────────────────────────┐
│ 3. 构建 (Build) │
│ ├── 多环境构建 (dev / test / staging / production) │
│ ├── 产物哈希 (content hash) │
│ └── Docker 镜像构建 │
└──────────────────────┬──────────────────────────────┘
▼
┌─────────────────────────────────────────────────────┐
│ 4. 部署 (Deploy) │
│ ├── 静态资源 → CDN │
│ ├── 容器镜像 → K8s / Docker │
│ └── 全量/灰度发布 │
└──────────────────────┬──────────────────────────────┘
▼
┌─────────────────────────────────────────────────────┐
│ 5. 通知 (Notify) │
│ ├── 飞书/钉钉/企微机器人通知 │
│ ├── 构建状态 + 部署链接 │
│ └── 测试报告 / 覆盖率变化 │
└─────────────────────────────────────────────────────┘
1.2 各阶段责任人
| 阶段 |
触发时机 |
阻塞性 |
预期耗时 |
失败处理 |
| 代码检查 |
Pre-commit / PR |
是(阻塞合并) |
10-30s |
修复后重新提交 |
| 自动化测试 |
PR / Push |
是(阻塞合并) |
2-10min |
修复后重新触发 |
| 构建 |
Merge / Tag |
是(阻塞部署) |
3-15min |
回滚 + 排查 |
| 部署 |
构建完成 |
否(回滚机制) |
1-5min |
自动回滚或人工介入 |
| 通知 |
每个阶段结束 |
否 |
< 1s |
忽略 |
1.3 流水线配置原则
# 关键原则:快速反馈 + 逐步放行
# 原则一:按阶段分级,防止"一错到底"
# - Lint 失败 → 不跑测试
# - 测试失败 → 不构建
# - 构建失败 → 不部署
# 原则二:区分 PR 和 Merge
# - PR 阶段:Lint + 测试(快速反馈)
# - Merge 后:构建 + 部署(完整流水线)
# 原则三:环境隔离
# - dev:自动部署(无需审批)
# - staging:部署需审批
# - production:部署需审批 + 时间窗口
踩坑点
| 坑点 |
说明 |
解决方案 |
| 流水线过长 |
全部阶段串行,一次提交等 30min |
按阶段拆分,PR 只跑 Lint + 核心测试 |
| 环境差异导致部署失败 |
CI 构建环境与目标环境不一致 |
使用 Docker 构建,保证环境一致 |
| 测试不稳定 |
偶发失败导致流水线频繁中断 |
Flaky test 单独标记,允许重试一次 |
| 通知轰炸 |
每次提交都通知,团队成员被刷屏 |
仅通知 main 分支的部署结果 |
二、代码质量检查
2.1 工具链全景
现代前端项目通常组合使用以下工具形成代码质量防线:
| 工具 |
作用范围 |
检查内容 |
CI 集成 |
| ESLint |
.js/.ts/.tsx/.vue |
语法错误、最佳实践、代码风格 |
eslint . --max-warnings 0 |
| Prettier |
所有代码 |
缩进、引号、尾逗号等格式 |
prettier --check . |
| Stylelint |
.css/.scss/.less/.vue |
样式规则、顺序、兼容性 |
stylelint "**/*.css" |
| tsc |
.ts/.tsx |
类型错误 |
tsc --noEmit |
| Husky |
Git Hooks |
提交前自动触发 Lint |
本地 + CI |
| lint-staged |
Staged 文件 |
仅检查要提交的文件 |
仅本地 |
2.2 ESLint + Prettier 配置
// ---------- .eslintrc.cjs ----------
// 现代 ESLint Flat Config 风格(ESLint >= 9.0)
const eslintConfig = [
// ... 省略基础配置
// 生产代码禁止 console.log(允许 warn/error)
{
rules: {
'no-console': ['warn', { allow: ['warn', 'error'] }],
'no-debugger': 'error',
// 禁止未使用的变量
'@typescript-eslint/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
}],
},
},
]
// ---------- .prettierrc ----------
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "always",
"endOfLine": "lf"
}
// ---------- package.json 脚本 ----------
{
"scripts": {
"lint": "eslint . --max-warnings 0",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "tsc --noEmit",
"stylelint": "stylelint \"**/*.{css,scss,vue}\""
}
}
✅ 推荐 vs ❌ 不推荐
# ✅ CI 中强制检查
eslint . --max-warnings 0
# 如果有任何 warning,也会导致 CI 失败,严格要求
# ❌ 忽略 warning
eslint .
# warning 不会导致 CI 失败,易导致代码质量逐步下降
# ✅ 分阶段检查
# .husky/pre-commit
npx lint-staged
# CI 中做全量检查
eslint . --max-warnings 0
# ❌ 本地全量检查
# 大项目全量检查太慢,开发者会绕过 husky
2.3 Husky + lint-staged 自动化
# ---------- 初始化 ----------
npm install -D husky lint-staged
npx husky init
// ---------- package.json ----------
{
"lint-staged": {
"*.{js,ts,tsx,vue}": [
"eslint --fix",
"prettier --write"
],
"*.{css,scss,less}": [
"stylelint --fix",
"prettier --write"
],
"*.{json,md,yaml}": [
"prettier --write"
]
}
}
# ---------- .husky/pre-commit ----------
npx lint-staged
# ---------- .husky/commit-msg ----------
npx --no -- commitlint --edit "$1"
2.4 Commitlint 配置
// ---------- commitlint.config.js ----------
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [2, 'always', [
'feat', 'fix', 'refactor', 'perf', 'style', 'test',
'docs', 'chore', 'ci', 'revert',
]],
'scope-case': [2, 'always', 'kebab-case'],
'subject-case': [2, 'always', 'lower-case'],
'subject-empty': [2, 'never'],
'type-empty': [2, 'never'],
'header-max-length': [2, 'always', 72],
},
}
踩坑点
| 坑点 |
说明 |
解决方案 |
| Husky 未安装 |
git clone 后 husky hooks 未自动安装 |
在 package.json 配置 prepare: "husky" script |
| lint-staged 跳过新增文件 |
只检查 staged 文件,未 staged 的文件不会被检查 |
配合 CI 做全量检查兜底 |
| Prettier 与 ESLint 规则冲突 |
两者格式化规则不一致 |
使用 eslint-config-prettier 关闭冲突规则 |
| Mac/Linux 和 Windows 换行符差异 |
CI 中 endOfLine: "auto" 导致不一致 |
统一设置 endOfLine: "lf" |
2.5 CI 中的代码检查配置
# ---------- GitHub Actions:Lint Job ----------
name: Code Quality Check
on:
pull_request:
types: [opened, synchronize]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
# 类型检查
- name: TypeScript Type Check
run: npx tsc --noEmit
# ESLint 检查
- name: ESLint
run: npx eslint . --max-warnings 0
# Prettier 格式检查
- name: Prettier Check
run: npx prettier --check .
# Stylelint 样式检查
- name: Stylelint
run: npx stylelint "**/*.{css,scss,vue}"
三、自动化测试在 CI
3.1 单元测试 + 覆盖率报告
// ---------- package.json ----------
{
"scripts": {
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest"
}
}
// ---------- vitest.config.ts ----------
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./tests/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json-summary', 'lcov'],
// 覆盖率门槛
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
// 排除非业务代码
exclude: [
'src/main.ts',
'src/router/**',
'src/types/**',
'src/**/*.d.ts',
'tests/**',
],
},
},
})
覆盖率门槛对比
| 策略 |
说明 |
优点 |
缺点 |
| 整体门槛 |
全局覆盖率 ≥ 80% |
简单,易执行 |
可能忽视核心模块 |
| 文件级门槛 |
每个文件覆盖率 ≥ 80% |
精准控制 |
新文件易导致 CI 失败 |
| 差异覆盖率 |
新增代码覆盖率 ≥ 90% |
鼓励写测试 |
配置复杂 |
| 无门槛 |
只收集,不强制 |
无压力 |
覆盖率可能逐年下降 |
# ---------- GitHub Actions:Test Job ----------
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Run Unit Tests
run: npx vitest run --coverage
# 上传覆盖率报告
- name: Upload Coverage Report
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
# 可选:上传到 Codecov / Coveralls
- name: Upload to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
3.2 E2E 测试(Playwright)
# ---------- 安装 Playwright ----------
npm install -D @playwright/test
npx playwright install chromium
// ---------- playwright.config.ts ----------
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: [
['html', { outputFolder: 'playwright-report' }],
['json', { outputFile: 'playwright-report/results.json' }],
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:4173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
})
// ---------- e2e/login.spec.ts 示例 ----------
import { test, expect } from '@playwright/test'
test.describe('登录流程', () => {
test('用户可以使用有效凭证登录', async ({ page }) => {
await page.goto('/login')
// 填充登录表单
await page.fill('[data-testid="username"]', 'admin')
await page.fill('[data-testid="password"]', 'password123')
await page.click('[data-testid="login-button"]')
// 等待跳转到仪表盘
await expect(page).toHaveURL('/dashboard', { timeout: 10000 })
await expect(page.locator('[data-testid="welcome"]')).toContainText('欢迎回来')
})
test('无效凭证应该显示错误提示', async ({ page }) => {
await page.goto('/login')
await page.fill('[data-testid="username"]', 'admin')
await page.fill('[data-testid="password"]', 'wrong')
await page.click('[data-testid="login-button"]')
await expect(page.locator('[data-testid="error-message"]'))
.toBeVisible()
await expect(page).toHaveURL('/login')
})
})
E2E 测试在 CI 中的配置
# ---------- GitHub Actions:E2E Test Job ----------
jobs:
e2e:
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
# 构建生产版本
- run: npm run build
# 启动预览服务器(后台运行)
- name: Start Preview Server
run: npx vite preview --port 4173 &
# 安装 Playwright 浏览器
- name: Install Playwright Browsers
run: npx playwright install chromium
# 运行 E2E 测试
- name: Run E2E Tests
run: npx playwright test
# 上传测试报告(测试失败时也上传)
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 7
踩坑点
| 坑点 |
说明 |
解决方案 |
| E2E 测试不稳定 |
网络/异步导致偶发失败 |
使用 retries: 2 自动重试失败用例 |
| 测试与 CI 环境 port 冲突 |
预览服务器端口被占用 |
使用随机端口 port: 0 |
| CI 无图形界面 |
依赖 GPU 渲染的页面测试失败 |
使用 --headless 模式 |
| 测试数据污染 |
测试间共享状态 |
每个 test 使用独立的 page 实例 |
3.3 可视化回归测试
可视化回归测试用于检测 UI 样式的非预期变化,通常配合 Storybook 使用。
| 工具 |
原理 |
定价 |
集成复杂度 |
| Chromatic |
Storybook 官方工具,自动截图对比 |
免费 5000 张/月,超出付费 |
低(直接集成 Storybook) |
| Percy |
独立工具,支持多种框架 |
免费 5000 张/月 |
中(需额外配置) |
| Playwright Screenshot |
内置截图对比功能 |
免费 |
高(需自行管理 baseline) |
# ---------- GitHub Actions:Chromatic ----------
name: Visual Regression
on:
pull_request:
types: [opened, synchronize]
jobs:
chromatic:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Chromatic 需要完整历史
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Publish to Chromatic
uses: chromaui/action@latest
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}
// ---------- Playwright 截图对比(自建方案)----------
import { test, expect } from '@playwright/test'
test.describe('可视化回归测试', () => {
test('登录页面布局', async ({ page }) => {
await page.goto('/login')
// 等待页面完全加载
await page.waitForLoadState('networkidle')
// 与 baseline 截图对比
await expect(page).toHaveScreenshot('login-page.png', {
maxDiffPixels: 100, // 允许最大差异像素数
threshold: 0.1, // 像素差异阈值
})
})
})
四、构建与产出
4.1 多环境构建
现代前端项目通常需要针对不同环境进行差异化构建。
| 环境 |
用途 |
API 地址 |
Sourcemap |
压缩 |
调试工具 |
| development |
本地开发 |
localhost |
完整 |
否 |
Vue Devtools / React Devtools |
| test |
测试环境 |
test-api.example.com |
隐藏 |
部分 |
保留 |
| staging |
预发布验证 |
staging-api.example.com |
无 |
完整 |
关闭 |
| production |
生产环境 |
api.example.com |
无 |
完整+混淆 |
关闭 |
// ---------- Vite 多环境配置 ----------
// src/utils/env.ts
// 防御:永远不要信任 process.env,做运行时校验
interface EnvConfig {
apiBaseUrl: string
appTitle: string
sentryDsn?: string
enableDevTools: boolean
logLevel: 'debug' | 'info' | 'warn' | 'error'
}
function validateEnv(): EnvConfig {
// 防御:运行时校验必需环境变量
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL
if (!apiBaseUrl) {
throw new Error('VITE_API_BASE_URL 未配置,请检查 .env 文件')
}
return {
apiBaseUrl,
appTitle: import.meta.env.VITE_APP_TITLE || 'Default App',
sentryDsn: import.meta.env.VITE_SENTRY_DSN,
enableDevTools: import.meta.env.VITE_ENABLE_DEVTOOLS === 'true',
logLevel: (import.meta.env.VITE_LOG_LEVEL as EnvConfig['logLevel']) || 'info',
}
}
export const env = validateEnv()
# ---------- .env 文件示例 ----------
# .env.development(本地开发)
VITE_API_BASE_URL=http://localhost:8080/api
VITE_APP_TITLE=Dev
VITE_ENABLE_DEVTOOLS=true
VITE_LOG_LEVEL=debug
# .env.staging
VITE_API_BASE_URL=https://staging-api.example.com/api
VITE_APP_TITLE=Staging
VITE_ENABLE_DEVTOOLS=false
VITE_LOG_LEVEL=info
# .env.production
VITE_API_BASE_URL=https://api.example.com/api
VITE_APP_TITLE=Production
VITE_SENTRY_DSN=https://xxx@sentry.io/xxx
VITE_ENABLE_DEVTOOLS=false
VITE_LOG_LEVEL=error
// ---------- package.json ----------
{
"scripts": {
"build": "vite build",
"build:test": "vite build --mode test",
"build:staging": "vite build --mode staging",
"build:prod": "vite build --mode production"
}
}
4.2 产物哈希策略
// ---------- vite.config.ts ----------
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
build: {
// 文件指纹:内容哈希,保证非覆盖式发布
rollupOptions: {
output: {
entryFileNames: 'assets/[name].[hash:8].js',
chunkFileNames: 'assets/[name].[hash:8].js',
assetFileNames: 'assets/[name].[hash:8][extname]',
},
},
// 代码分割
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
priority: 10,
},
elementPlus: {
test: /[\\/]node_modules[\\/]element-plus[\\/]/,
name: 'element-plus',
priority: 20,
},
},
},
},
})
构建产物目录结构
dist/
├── index.html # 入口 HTML(不缓存或协商缓存)
├── assets/
│ ├── index.a1b2c3d4.js # 应用入口 JS(Hash 命名)
│ ├── vendor.e5f6g7h8.js # 第三方依赖
│ ├── element-plus.i9j0k1l2.js # UI 框架单独分包
│ ├── styles.m3n4o5p6.css # 样式文件
│ └── logo.q7r8s9t0.svg # 静态资源
└── favicon.ico
4.3 Docker 镜像构建
Dockerfile 最佳实践(多阶段构建)
# ---------- Dockerfile ----------
# ===== 构建阶段 =====
FROM node:20-alpine AS builder
# 安全:使用非 root 用户
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile
COPY . .
# 构建
ARG BUILD_MODE=production
RUN npm run build:${BUILD_MODE}
# ===== 运行阶段 =====
FROM nginx:1.25-alpine AS runner
# 安全:使用非 root 用户运行
RUN addgroup -g 1001 -S nodejs && \
adduser -S nginx -u 1001
# 从构建阶段复制产物
COPY --from=builder /app/dist /usr/share/nginx/html
# Nginx 配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 安全:修改权限
RUN chown -R nginx:nginx /usr/share/nginx/html && \
chmod -R 755 /usr/share/nginx/html
USER nginx
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q --spider http://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]
Dockerfile 方案对比
| 方案 |
镜像大小 |
构建速度 |
安全性 |
推荐 |
| 单阶段构建 |
大(含构建工具) |
快 |
低 |
不推荐 |
| 多阶段构建(alpine) |
小(~20MB) |
中 |
高 |
推荐 |
| 多阶段构建(distroless) |
极小(~10MB) |
中 |
最高 |
极致安全 |
| Serving 非 Node 镜像 |
小 |
快 |
高 |
推荐(如 Nginx) |
# ---------- docker-compose.yml ----------
version: '3.8'
services:
frontend:
build:
context: .
args:
BUILD_MODE: production
image: my-frontend:${VERSION:-latest}
ports:
- "80:80"
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost/"]
interval: 30s
timeout: 3s
retries: 3
4.4 Nginx 配置最佳实践
# ---------- nginx.conf ----------
server {
listen 80;
server_name example.com;
# Gzip 压缩
gzip on;
gzip_min_length 1000;
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml text/javascript image/svg+xml;
gzip_vary on;
gzip_comp_level 6;
# 根目录
root /usr/share/nginx/html;
index index.html;
# ===== SPA 路由 fallback =====
location / {
try_files $uri $uri/ /index.html;
}
# ===== 静态资源缓存策略 =====
# 带 Hash 的文件:长期缓存(1 年)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# HTML 文件:不缓存(确保每次请求都获取最新)
location = /index.html {
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# 其他静态文件(favicon, robots.txt)
location ~* \.(ico|webmanifest)$ {
expires 30d;
add_header Cache-Control "public";
access_log off;
}
# ===== 安全头部 =====
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# ===== 日志 =====
access_log /var/log/nginx/access.log main buffer=32k flush=5s;
error_log /var/log/nginx/error.log warn;
}
缓存策略对比
| 资源类型 |
缓存策略 |
说明 |
| HTML(index.html) |
no-cache, no-store |
每次都向服务器验证 |
| JS/CSS(带 Hash) |
public, immutable, max-age=31536000 |
一年缓存,内容变更后文件名改变 |
| 图片/字体 |
public, max-age=2592000 |
30 天缓存 |
| API 响应 |
不通过 Nginx 缓存 |
由后端控制 |
踩坑点
| 坑点 |
说明 |
解决方案 |
| SPA 路由刷新 404 |
用户直接访问 /users/123 时 Nginx 找不到文件 |
try_files $uri $uri/ /index.html; |
| 缓存策略错误导致发版后用户仍看到旧版本 |
HTML 被浏览器或 CDN 缓存 |
HTML 设置 no-cache,资源使用 Hash 命名 |
| Gzip 与 CDN 冲突 |
CDN 二次压缩或未正确传递 gzip 头部 |
CDN 层关闭源站压缩,统一由 CDN 处理 |
| 镜像安全漏洞 |
Nginx 基础镜像存在 CVE |
使用 nginx:1.25-alpine 并定期扫描 |
五、部署方案对比
5.1 四种主流方案
前端部署方案的选择取决于项目规模、团队能力和基础设施。
| 方案 |
原理 |
适用场景 |
成本 |
运维复杂度 |
回滚速度 |
| OSS + CDN |
静态资源上传对象存储,通过 CDN 分发 |
纯前端应用、静态站点 |
低 |
低 |
快(即时切换版本) |
| Nginx 服务器 |
自行维护 Nginx 服务器,部署前端产物 |
中小项目、需定制化配置 |
中 |
中 |
中(替换文件或版本目录) |
| Docker + K8s |
容器化部署,K8s 编排管理 |
中大型项目、微服务架构 |
高 |
高 |
快(K8s 滚动更新 + 回滚) |
| Vercel / Netlify |
Serverless 平台,自动部署 |
个人项目、小型团队 |
低(有免费额度) |
极低 |
极快(一键回滚) |
5.2 静态资源 CDN 部署(OSS + CDN)
# ---------- GitHub Actions:OSS + CDN 部署 ----------
name: Deploy to OSS + CDN
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build:prod
# 上传到 OSS
- name: Upload to OSS
uses: aliyun/oss-upload-action@v1
with:
access-key-id: ${{ secrets.OSS_ACCESS_KEY_ID }}
access-key-secret: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
bucket: my-app-static
source: ./dist/
target: /web-app/
# CDN 刷新(确保缓存立即失效)
- name: Refresh CDN Cache
run: |
aliyun cdn RefreshObjectCaches \
--ObjectPath https://cdn.example.com/web-app/index.html \
--ObjectType File
部署结构
OSS Bucket: my-app-static
└── web-app/
├── v1.0.0/ # 版本号目录
│ ├── index.html
│ └── assets/
│ ├── index.abc123.js
│ └── styles.def456.css
├── v1.1.0/
│ └── ...
├── current -> v1.1.0/ # 符号链接指向当前版本
└── rollback -> v1.0.0/ # 回滚版本
5.3 Nginx 服务器部署
# ---------- 手动部署 / CI 中的部署脚本 ----------
#!/bin/bash
set -euo pipefail
APP_NAME="my-frontend"
DEPLOY_DIR="/data/apps/${APP_NAME}"
VERSION=$(date +%Y%m%d%H%M%S)
# 1. 创建版本目录
mkdir -p "${DEPLOY_DIR}/releases/${VERSION}"
# 2. 复制构建产物
cp -r ./dist/* "${DEPLOY_DIR}/releases/${VERSION}/"
# 3. 更新软链接
ln -snf "${DEPLOY_DIR}/releases/${VERSION}" "${DEPLOY_DIR}/current"
# 4. 清理旧版本(保留最近 5 个版本)
ls -t "${DEPLOY_DIR}/releases/" | tail -n +6 | xargs -I {} rm -rf "${DEPLOY_DIR}/releases/{}"
echo "Deployed: ${VERSION}"
# ---------- Nginx 多版本部署配置 ----------
server {
root /data/apps/my-frontend/current;
# 使用符号链接实现零停机部署
# 更新软链接后 reload 即可
}
5.4 Docker + K8s 部署
# ---------- Kubernetes Deployment ----------
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
namespace: production
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1 # 最大不可用 Pod 数
maxSurge: 1 # 最大额外 Pod 数
selector:
matchLabels:
app: frontend
template:
metadata:
labels:
app: frontend
spec:
containers:
- name: frontend
image: registry.example.com/frontend:1.2.3
ports:
- containerPort: 80
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
livenessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /index.html
port: 80
initialDelaySeconds: 3
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: frontend-svc
spec:
selector:
app: frontend
ports:
- port: 80
targetPort: 80
type: ClusterIP
# ---------- K8s 滚动更新回滚命令 ----------
# 部署
kubectl set image deployment/frontend frontend=registry.example.com/frontend:1.2.4
# 查看状态
kubectl rollout status deployment/frontend
# 回滚到上一个版本
kubectl rollout undo deployment/frontend
# 回滚到指定版本
kubectl rollout undo deployment/frontend --to-revision=3
# 查看版本历史
kubectl rollout history deployment/frontend
5.5 Vercel / Netlify 部署
# ---------- Vercel 部署(vercel.json)----------
{
"buildCommand": "npm run build:prod",
"outputDirectory": "dist",
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
],
"headers": [
{
"source": "/assets/(.*)",
"headers": [
{ "key": "Cache-Control", "value": "public, immutable, max-age=31536000" }
]
}
]
}
# ---------- Netlify 部署(netlify.toml)----------
[build]
command = "npm run build:prod"
publish = "dist"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
[[headers]]
for = "/assets/*"
[headers.values]
Cache-Control = "public, immutable, max-age=31536000"
踩坑点
| 坑点 |
说明 |
解决方案 |
| OSS 没有符号链接功能 |
无法像文件系统一样使用软链接 |
使用 CDN 刷新 + 版本号目录 + 网关路由 |
| K8s 滚动更新期间用户访问到新旧混合版本 |
灰度期间新旧 Pod 并存 |
多版本共存时确保 API 兼容 |
| Vercel/Netlify 冷启动慢 |
Serverless 函数冷启动延迟 |
使用 launch.sh 预热或保持活跃 |
| CDN 缓存刷新未生效 |
刷新后边缘节点仍返回旧内容 |
使用版本号目录,资源 URL 带版本号 |
六、灰度发布
6.1 Nginx 流量分流
权重分流
# ---------- Nginx 权重分流 ----------
upstream frontend {
# 稳定版(90% 流量)
server 10.0.1.10:80 weight=9;
# 灰度版(10% 流量)
server 10.0.1.11:80 weight=1;
}
server {
location / {
proxy_pass http://frontend;
}
}
# ---------- Nginx Header/Cookie 灰度分流 ----------
upstream frontend-stable {
server 10.0.1.10:80;
}
upstream frontend-canary {
server 10.0.1.11:80;
}
server {
location / {
# 根据 Cookie 分流
if ($http_cookie ~* "canary=1") {
proxy_pass http://frontend-canary;
break;
}
# 根据 Header 分流
if ($http_x_canary) {
proxy_pass http://frontend-canary;
break;
}
# 默认走稳定版
proxy_pass http://frontend-stable;
}
}
6.2 K8s 灰度发布
# ---------- K8s Canary 部署 ----------
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend-canary
labels:
app: frontend
track: canary
spec:
replicas: 1 # 灰度实例数
selector:
matchLabels:
app: frontend
track: canary
template:
metadata:
labels:
app: frontend
track: canary
spec:
containers:
- name: frontend
image: registry.example.com/frontend:1.3.0-beta
---
# Service 通过 label selector 同时覆盖 stable 和 canary
apiVersion: v1
kind: Service
metadata:
name: frontend-svc
spec:
selector:
app: frontend
ports:
- port: 80
# 通过调整 canary 的 replicas 控制灰度比例
# stable: 9 个 Pod, canary: 1 个 Pod → canary 10% 流量
# stable: 8 个 Pod, canary: 2 个 Pod → canary 20% 流量
6.3 灰度发布策略对比
| 策略 |
原理 |
优势 |
劣势 |
适用场景 |
| 金丝雀发布 |
少量实例跑新版,验证后全量 |
风险可控,逐步放量 |
流程较长 |
常规功能发布 |
| 蓝绿部署 |
两套完整环境,一键切换 |
切换快,回滚快 |
资源翻倍 |
重大版本更新 |
| AB 测试 |
不同用户看到不同版本 |
数据驱动决策 |
需埋点和数据分析 |
UI/UX 改版 |
| 功能开关 |
代码中控制功能可见性 |
细粒度控制,即时开关 |
代码中侵入 |
渐进式功能上线 |
6.4 AB 实验平台
// ---------- AB 实验 SDK 封装 ----------
// src/utils/experiment.ts
// 防御:实验分组信息不可信,需验证
interface ExperimentConfig {
/** 实验名称 */
name: string
/** 实验分组 */
variants: string[]
/** 流量分配比例(总和 = 1) */
weights: number[]
}
class ABExperiment {
private experiments: Map<string, string>
constructor() {
this.experiments = new Map()
}
/**
* 从服务端获取实验分组
*/
async init(userId: string): Promise<void> {
try {
const res = await fetch('/api/experiments/assign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId }),
})
const data = await res.json()
// 防御:验证返回数据格式
if (data?.experiments && typeof data.experiments === 'object') {
Object.entries(data.experiments).forEach(([key, value]) => {
if (typeof value === 'string') {
this.experiments.set(key, value)
}
})
}
} catch {
// 防御:实验平台不可用,走默认分组
console.warn('AB 实验平台不可用,使用默认分组')
}
}
/**
* 获取当前用户的实验分组
*/
getVariant(experimentName: string, defaultValue = 'control'): string {
// 防御:返回默认值而不是抛错
return this.experiments.get(experimentName) ?? defaultValue
}
/**
* 判断用户是否在指定实验的实验组
*/
isInGroup(experimentName: string, variant: string): boolean {
return this.getVariant(experimentName) === variant
}
}
export const experiment = new ABExperiment()
// ---------- Vue 3:AB 实验使用 ----------
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { experiment } from '@/utils/experiment'
const showNewHomePage = ref(false)
const showBanner = ref(false)
onMounted(async () => {
await experiment.init(getCurrentUserId())
// 实验 A:新版首页 vs 旧版首页
showNewHomePage.value = experiment.isInGroup('homepage-redesign', 'treatment')
// 实验 B:横幅展示
showBanner.value = experiment.isInGroup('promo-banner', 'treatment')
})
</script>
<template>
<!-- 根据实验分组渲染不同版本 -->
<NewHomePage v-if="showNewHomePage" />
<OldHomePage v-else />
</template>
// ---------- React:AB 实验 Hook ----------
// src/hooks/useExperiment.ts
import { useState, useEffect } from 'react'
import { experiment } from '@/utils/experiment'
export function useExperiment(experimentName: string, defaultValue = 'control') {
const [variant, setVariant] = useState(defaultValue)
const [loaded, setLoaded] = useState(false)
useEffect(() => {
experiment.init(getCurrentUserId()).then(() => {
setVariant(experiment.getVariant(experimentName, defaultValue))
setLoaded(true)
})
}, [experimentName, defaultValue])
return {
variant,
loaded,
isTreatment: variant === 'treatment',
}
}
// ---------- 使用 ----------
function HomePage() {
const { variant, loaded, isTreatment } = useExperiment('homepage-redesign')
if (!loaded) return <Skeleton /> // 防御:实验未加载完成前显示骨架屏
return isTreatment ? <NewHomePage /> : <OldHomePage />
}
七、前端发布注意事项
7.1 非覆盖式发布
核心原则:永远不要覆盖已发布的静态资源文件。这是前端发布最底层的原则。
# ❌ 覆盖式发布(危险)
# 每次构建都生成相同文件名
dist/assets/index.js # 被覆盖,CDN 缓存未更新
dist/assets/styles.css # 被覆盖
# ✅ 非覆盖式发布(安全)
dist/v1.0.0/assets/index.a1b2c3d4.js
dist/v1.0.0/assets/styles.e5f6g7h8.css
dist/v1.0.1/assets/index.i9j0k1l2.js
dist/v1.0.1/assets/styles.m3n4o5p6.css
为什么必须非覆盖式发布
| 场景 |
覆盖式发布的问题 |
非覆盖式的优势 |
| 浏览器缓存了旧 JS |
用户看到页面但 JS 报错(API 变更) |
新 HTML 引用新 JS,旧 HTML 引用旧 JS |
| CDN 节点缓存未过期 |
部分用户拿到新旧混合资源 |
每个资源有唯一 URL,新旧互不影响 |
| 正在使用的用户 |
页面突然崩溃(资源被替换) |
旧页面继续使用旧资源 |
| 多版本并行 |
无法同时运行多个版本 |
版本目录完全隔离 |
7.2 缓存更新策略
# ---------- Nginx 多级缓存控制 ----------
# HTML:禁止缓存
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header Pragma "no-cache";
expires -1;
}
# 带 Hash 的资源:永久缓存
location /assets/ {
# 文件名包含 Hash,内容变则文件名变
expires 1y;
add_header Cache-Control "public, immutable";
}
// ---------- Service Worker 策略 ----------
// 对于 PWA 项目,使用 Service Worker 精细控制缓存
// sw.ts
const CACHE_NAME = 'app-v1'
// 安装时预缓存核心资源
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll([
'/index.html',
'/assets/vendor.a1b2c3d4.js',
'/assets/app.e5f6g7h8.js',
])
}),
)
})
// 激活时清理旧缓存
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => caches.delete(name)),
)
}),
)
})
踩坑点
| 坑点 |
说明 |
解决方案 |
| CDN 缓存了 index.html |
用户访问的是旧 HTML,引用旧资源 |
index.html 设置 Cache-Control: no-cache |
| Service Worker 更新后未立即生效 |
用户关闭所有标签页后才激活新 SW |
self.skipWaiting() + clients.claim() |
| CSS 中引用的图片被缓存 |
CSS 带 Hash,但其引用的图片没变 |
所有静态资源都使用 Hash 命名 |
| 浏览器预加载导致请求旧资源 |
<link rel="preload"> 加载了已更新的资源 |
确保 preload 的资源版本一致 |
7.3 回滚方案
| 部署方案 |
回滚操作 |
回滚速度 |
风险 |
| OSS + CDN |
切换符号链接 → 刷新 CDN |
< 5min |
CDN 缓存未完全清理 |
| Nginx 服务器 |
切换符号链接 → nginx -s reload |
< 1min |
新版本中修改的静态资源位置变化 |
| Docker + K8s |
kubectl rollout undo |
< 2min |
数据库 schema 不兼容(纯前端无需关注) |
| Vercel |
Dashboard 一键回滚 |
< 1min |
无 |
# ---------- Nginx 回滚脚本 ----------
#!/bin/bash
set -euo pipefail
APP_NAME="my-frontend"
DEPLOY_DIR="/data/apps/${APP_NAME}"
TARGET_VERSION="${1:-}"
if [ -z "$TARGET_VERSION" ]; then
# 默认回滚到上一个版本
CURRENT=$(readlink "${DEPLOY_DIR}/current")
PREVIOUS=$(ls -t "${DEPLOY_DIR}/releases/" | sed -n '2p')
TARGET_VERSION="$PREVIOUS"
fi
# 安全:检查版本目录是否存在
if [ ! -d "${DEPLOY_DIR}/releases/${TARGET_VERSION}" ]; then
echo "错误:版本 ${TARGET_VERSION} 不存在"
exit 1
fi
# 执行回滚
ln -snf "${DEPLOY_DIR}/releases/${TARGET_VERSION}" "${DEPLOY_DIR}/current"
nginx -s reload
echo "回滚到版本 ${TARGET_VERSION} 完成"
八、CI 工具配置示例
8.1 GitHub Actions 完整示例
# ---------- .github/workflows/ci-cd.yml ----------
name: CI/CD Pipeline
on:
push:
branches: [main, 'release/*']
pull_request:
branches: [main]
# 环境变量
env:
NODE_VERSION: 20
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# ===== 代码质量检查 =====
lint:
name: Code Quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npx tsc --noEmit
- run: npx eslint . --max-warnings 0
- run: npx prettier --check .
# ===== 单元测试 =====
test:
name: Unit Tests
needs: [lint]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- name: Run Tests with Coverage
run: npx vitest run --coverage
- name: Upload Coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
# ===== E2E 测试 =====
e2e:
name: E2E Tests
needs: [test]
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npm run build
- run: npx playwright install chromium
- name: Run E2E Tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
# ===== 构建与发布 =====
build-and-deploy:
name: Build & Deploy
# PR 不部署,只有合并到 main 才部署
if: github.ref == 'refs/heads/main'
needs: [e2e]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- name: Build
run: npm run build:prod
# 构建 Docker 镜像
- name: Log in to Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push Docker Image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
# 部署通知
- name: Notify Deployment
if: always()
run: |
STATUS="${{ job.status }}"
MESSAGE="前端部署 $([ "$STATUS" = "success" ] && echo '成功' || echo '失败')"
curl -X POST -H "Content-Type: application/json" \
-d "{\"msgtype\":\"text\",\"text\":{\"content\":\"${MESSAGE}\"}}" \
${{ secrets.WEBHOOK_URL }}
8.2 GitLab CI 完整示例
# ---------- .gitlab-ci.yml ----------
stages:
- lint
- test
- build
- deploy
variables:
NODE_VERSION: "20"
NPM_CACHE: "npm-cache"
# 缓存 node_modules
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
# ===== 代码质量 =====
lint:
stage: lint
image: node:${NODE_VERSION}
script:
- npm ci
- npx tsc --noEmit
- npx eslint . --max-warnings 0
- npx prettier --check .
# ===== 单元测试 =====
unit-test:
stage: test
image: node:${NODE_VERSION}
script:
- npm ci
- npx vitest run --coverage
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
# ===== E2E 测试 =====
e2e-test:
stage: test
image: mcr.microsoft.com/playwright:v1.45.0-focal
script:
- npm ci
- npx playwright install chromium
- npm run build
- npx playwright test
artifacts:
when: always
paths:
- playwright-report/
expire_in: 7 days
# ===== 构建 =====
build:
stage: build
image: docker:latest
services:
- docker:dind
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:latest
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
- docker push $CI_REGISTRY_IMAGE:latest
only:
- main
# ===== 部署到开发环境 =====
deploy-dev:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client
script:
- scp -r ./dist/* deploy@dev-server:/data/apps/frontend/
environment:
name: development
only:
- main
# ===== 部署到生产环境 =====
deploy-production:
stage: deploy
image: bitnami/kubectl:latest
script:
- kubectl set image deployment/frontend frontend=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
- kubectl rollout status deployment/frontend
environment:
name: production
when: manual # 手动触发部署
only:
- main
8.3 CI 工具对比
| 特性 |
GitHub Actions |
GitLab CI |
Jenkins |
| 配置方式 |
YAML(.github/workflows/) |
YAML(.gitlab-ci.yml) |
Groovy(Jenkinsfile)/ UI |
| 托管方式 |
GitHub 托管 |
GitLab 托管 / 自托管 |
自托管 |
| 免费额度 |
2000 min/月(公开仓库无限) |
400 min/月 |
无(需自建服务器) |
| Marketplace |
丰富(社区 Action 多) |
较少(Template 为主) |
插件生态丰富 |
| 缓存 |
内置 actions/cache |
内置 cache 关键字 |
需插件 |
| 矩阵构建 |
strategy.matrix |
parallel:matrix |
需配置 |
| 容器支持 |
原生 Docker 支持 |
Docker-in-Docker |
需要插件 |
| K8s 集成 |
通过 Action |
内置 |
需要插件 |
| 适用场景 |
GitHub 项目 |
GitLab 项目 |
企业内部自建 |
踩坑点
| 坑点 |
说明 |
解决方案 |
| npm ci 与 package-lock.json 不匹配 |
lockfile 未提交或与 package.json 不一致 |
lockfile 纳入版本控制,CI 使用 npm ci |
| GitHub Actions 缓存失效 |
actions/cache 的 key 策略不当导致缓存命中率低 |
使用 npm 的 lockfile hash + OS 作为 cache key |
| GitLab CI 的 Docker-in-Docker 安全风险 |
dind 需要特权模式 |
优先使用 docker:latest + socket 绑定 |
| Secrets 泄露到日志 |
环境变量被打印到构建日志 |
使用 add-mask 或 GitLab 的 masked variables |
参考资料
最后更新:2026/06/29