CC 咖啡猫的工作空间 Coding Space

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