跳到主要内容

前端 CI/CD 部署系统

一、需求分析

1.1 CI/CD 核心概念

CI/CD 是现代软件工程的核心基础设施,包含三个层次递进的实践:

概念英文全称核心目标自动化程度人工介入
持续集成Continuous Integration频繁合并代码,自动运行检查和测试代码合并 + 测试自动化不需要
持续交付Continuous Delivery确保代码随时可发布到生产构建 + 测试 + 部署到预发环境发布需人工审批
持续部署Continuous Deployment每次通过测试的变更自动上线全流程自动化完全不需要
关键区别
  • 持续交付:代码通过所有测试后部署到 staging 环境,但发布到生产需要人工点击按钮
  • 持续部署:代码通过所有测试后自动部署到生产环境,无需人工干预
  • 大多数团队采用持续交付而非持续部署,因为生产发布通常需要业务审批和灰度策略

1.2 功能需求

一套完整的前端 CI/CD 部署系统需要覆盖以下核心能力:

功能模块核心能力关键指标
代码质量门禁ESLint、Prettier、TypeCheck、单元测试通过率 100% 才允许合并
自动化构建Webpack/Vite 构建、产物分析、缓存加速构建时间 < 3 分钟
多环境部署dev/staging/production 环境管理环境隔离、配置注入
部署策略蓝绿、滚动、金丝雀、A/B 测试零停机、快速回滚
静态资源管理CDN 发布、资源 hash、版本管理缓存命中率 > 95%
监控与通知部署状态、健康检查、性能基线异常 5 分钟内告警
回滚机制版本管理、一键回滚、自动回滚回滚时间 < 30 秒

1.3 非功能需求

面试加分项

在面试中回答系统设计题时,主动提出非功能需求能体现工程化深度思维。

非功能需求设计目标实现手段
快速反馈PR 提交到结果反馈 < 5 分钟并行执行、缓存策略、增量构建
可靠性流水线成功率 > 99%重试机制、幂等部署、健康检查
安全性密钥零泄露、权限最小化Secrets 管理、环境隔离、审计日志
可扩展支持多项目、多团队、多环境模板化配置、自定义插件、Monorepo
可观测流水线全链路追踪日志聚合、指标监控、通知集成
成本可控CI 资源利用率 > 70%并发控制、缓存复用、按需扩缩容

二、整体架构

2.1 CI/CD Pipeline 全景

2.2 数据流转时序


三、核心模块设计

3.1 CI 阶段:代码质量门禁

CI 阶段是流水线的第一道防线,确保进入后续阶段的代码质量合格。

代码检查(ESLint + Prettier)

scripts/ci-lint.ts
import { execSync } from 'child_process';

interface LintResult {
stage: string;
passed: boolean;
duration: number;
errors: number;
warnings: number;
}

/** 运行 ESLint 检查 */
function runLint(): LintResult {
const start = Date.now();
try {
execSync('npx eslint . --ext .ts,.tsx --format json --output-file lint-report.json', {
stdio: 'pipe',
});
return { stage: 'ESLint', passed: true, duration: Date.now() - start, errors: 0, warnings: 0 };
} catch (error) {
const report = JSON.parse(
execSync('cat lint-report.json', { encoding: 'utf-8' })
) as Array<{ errorCount: number; warningCount: number }>;
const errors = report.reduce((sum, file) => sum + file.errorCount, 0);
const warnings = report.reduce((sum, file) => sum + file.warningCount, 0);
return { stage: 'ESLint', passed: false, duration: Date.now() - start, errors, warnings };
}
}

/** 运行 Prettier 格式检查 */
function runFormatCheck(): LintResult {
const start = Date.now();
try {
execSync('npx prettier --check "src/**/*.{ts,tsx,css,json}"', { stdio: 'pipe' });
return { stage: 'Prettier', passed: true, duration: Date.now() - start, errors: 0, warnings: 0 };
} catch {
return { stage: 'Prettier', passed: false, duration: Date.now() - start, errors: 1, warnings: 0 };
}
}

/** 运行 TypeScript 类型检查 */
function runTypeCheck(): LintResult {
const start = Date.now();
try {
execSync('npx tsc --noEmit --pretty', { stdio: 'pipe' });
return { stage: 'TypeCheck', passed: true, duration: Date.now() - start, errors: 0, warnings: 0 };
} catch {
return { stage: 'TypeCheck', passed: false, duration: Date.now() - start, errors: 1, warnings: 0 };
}
}

// 并行运行所有检查
async function runAllChecks(): Promise<void> {
const results = await Promise.all([
Promise.resolve(runLint()),
Promise.resolve(runFormatCheck()),
Promise.resolve(runTypeCheck()),
]);

const failed = results.filter((r) => !r.passed);
if (failed.length > 0) {
console.error('CI 检查失败:', failed.map((r) => r.stage).join(', '));
process.exit(1);
}
console.log('所有检查通过!总耗时:', results.reduce((sum, r) => sum + r.duration, 0), 'ms');
}

runAllChecks();

单元测试与覆盖率

scripts/ci-test.ts
import { execSync } from 'child_process';

interface CoverageThreshold {
branches: number;
functions: number;
lines: number;
statements: number;
}

const COVERAGE_THRESHOLD: CoverageThreshold = {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
};

function runTests(): void {
try {
// 运行测试并收集覆盖率
execSync(
'npx vitest run --coverage --reporter=json --outputFile=test-report.json',
{ stdio: 'inherit' }
);

// 解析覆盖率报告
const coverageSummary = JSON.parse(
execSync('cat coverage/coverage-summary.json', { encoding: 'utf-8' })
) as { total: Record<string, { pct: number }> };

const total = coverageSummary.total;

// 检查覆盖率是否达标
const failures: string[] = [];
for (const [key, threshold] of Object.entries(COVERAGE_THRESHOLD)) {
const actual = total[key]?.pct ?? 0;
if (actual < threshold) {
failures.push(`${key}: ${actual}% < ${threshold}%`);
}
}

if (failures.length > 0) {
console.error('覆盖率未达标:\n' + failures.join('\n'));
process.exit(1);
}

console.log('测试通过,覆盖率达标!');
} catch {
console.error('测试执行失败');
process.exit(1);
}
}

runTests();

产物分析

scripts/analyze-bundle.ts
import * as fs from 'fs';

interface BundleInfo {
name: string;
size: number; // 原始大小 (bytes)
gzipSize: number; // gzip 后大小 (bytes)
}

interface BundleBudget {
maxTotalSize: number; // 总体积上限 (KB)
maxSingleChunkSize: number; // 单文件上限 (KB)
maxInitialSize: number; // 首屏加载上限 (KB)
}

const BUDGET: BundleBudget = {
maxTotalSize: 500, // 500KB
maxSingleChunkSize: 200, // 200KB
maxInitialSize: 150, // 150KB
};

function analyzeBundles(distDir: string): void {
const files = fs.readdirSync(distDir, { recursive: true }) as string[];
const jsFiles = files.filter((f) => f.endsWith('.js'));

const bundles: BundleInfo[] = jsFiles.map((file) => {
const filePath = `${distDir}/${file}`;
const stat = fs.statSync(filePath);
return {
name: file,
size: stat.size,
gzipSize: Math.round(stat.size * 0.3), // 估算 gzip 压缩率
};
});

const totalGzipKB = bundles.reduce((sum, b) => sum + b.gzipSize, 0) / 1024;

// 检查是否超出预算
const violations: string[] = [];

if (totalGzipKB > BUDGET.maxTotalSize) {
violations.push(`总体积 ${totalGzipKB.toFixed(1)}KB 超出预算 ${BUDGET.maxTotalSize}KB`);
}

bundles.forEach((b) => {
const sizeKB = b.gzipSize / 1024;
if (sizeKB > BUDGET.maxSingleChunkSize) {
violations.push(`${b.name} (${sizeKB.toFixed(1)}KB) 超出单文件上限 ${BUDGET.maxSingleChunkSize}KB`);
}
});

if (violations.length > 0) {
console.warn('产物体积告警:\n' + violations.join('\n'));
// 可以设置为 warning 而非 error,不阻塞部署
}

// 输出分析报告
console.table(bundles.map((b) => ({
文件: b.name,
'原始大小(KB)': (b.size / 1024).toFixed(1),
'Gzip(KB)': (b.gzipSize / 1024).toFixed(1),
})));
}

analyzeBundles('./dist');

3.2 构建优化

缓存策略

构建缓存是加速 CI/CD 流水线最有效的手段之一:

.github/workflows/ci.yml
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v2
with:
version: 9

# L1: 依赖缓存 - setup-node 内置缓存
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'

- run: pnpm install --frozen-lockfile

# L2: 构建缓存 - 手动配置
- uses: actions/cache@v4
with:
path: |
.next/cache
node_modules/.vite
node_modules/.cache
key: build-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('src/**') }}
restore-keys: |
build-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-
build-${{ runner.os }}-

- run: pnpm build

缓存优化效果

优化项无缓存耗时有缓存耗时节省比例
pnpm install~60s~5s92%
TypeScript 编译~30s~8s73%
Next.js 构建~120s~30s75%
Vite 构建~20s~6s70%
Docker 镜像构建~180s~20s89%
Turborepo 远程缓存~90s~3s97%

Docker 多阶段构建

Dockerfile
# 阶段 1:安装依赖
FROM node:20-alpine AS deps
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile

# 阶段 2:构建应用
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build

# 阶段 3:生产运行(最小镜像)
FROM nginx:alpine AS runner
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Docker 多阶段构建优势
  • 体积极小:最终镜像只包含 Nginx + 静态文件,通常 < 30MB
  • 构建缓存:每个阶段独立缓存,依赖未变则跳过安装阶段
  • 安全性高:生产镜像不包含源码、node_modules 等敏感内容

增量构建与并行构建

scripts/incremental-build.ts
import { execSync } from 'child_process';

interface ChangedFiles {
src: boolean;
tests: boolean;
config: boolean;
styles: boolean;
}

/** 检测变更文件范围 */
function detectChanges(baseBranch: string = 'main'): ChangedFiles {
const diff = execSync(`git diff --name-only ${baseBranch}...HEAD`, {
encoding: 'utf-8',
});

const files = diff.trim().split('\n');

return {
src: files.some((f) => f.startsWith('src/') && !f.endsWith('.test.ts')),
tests: files.some((f) => f.endsWith('.test.ts') || f.endsWith('.spec.ts')),
config: files.some((f) => f.match(/\.(config|rc)\.(ts|js|json)$/)),
styles: files.some((f) => f.match(/\.(css|scss|less)$/)),
};
}

/** 根据变更范围选择性执行 CI 步骤 */
function runIncrementalCI(): void {
const changes = detectChanges();

const tasks: Promise<void>[] = [];

// 只在源码或配置变更时运行 lint
if (changes.src || changes.config) {
tasks.push(runAsync('pnpm lint'));
}

// 只在源码变更时运行类型检查
if (changes.src) {
tasks.push(runAsync('pnpm tsc --noEmit'));
}

// 只在源码或测试变更时运行测试
if (changes.src || changes.tests) {
tasks.push(runAsync('pnpm test'));
}

Promise.all(tasks)
.then(() => {
console.log('增量 CI 检查全部通过');
// 构建始终执行
execSync('pnpm build', { stdio: 'inherit' });
})
.catch(() => {
process.exit(1);
});
}

function runAsync(command: string): Promise<void> {
return new Promise((resolve, reject) => {
try {
execSync(command, { stdio: 'inherit' });
resolve();
} catch {
reject(new Error(`Command failed: ${command}`));
}
});
}

runIncrementalCI();

3.3 部署策略

部署策略决定了新版本如何安全地到达用户。不同策略适用于不同的风险容忍度和系统规模。

策略对比

策略原理回滚速度资源开销风险等级适用场景
直接部署直接替换旧版本分钟级最低开发/测试环境
蓝绿部署两套环境切换秒级2x 资源对可用性要求高
滚动更新逐步替换实例分钟级较低K8s 集群部署
金丝雀发布小流量验证后全量秒级较低最低大流量生产系统
A/B 测试按用户分组分流秒级中等功能对比验证

蓝绿部署

scripts/blue-green-deploy.ts
interface Environment {
name: 'blue' | 'green';
url: string;
port: number;
version: string;
status: 'active' | 'idle';
}

interface DeployConfig {
healthCheckUrl: string;
healthCheckTimeout: number; // ms
healthCheckRetries: number;
}

class BlueGreenDeployer {
private blue: Environment = {
name: 'blue', url: 'http://localhost:3001', port: 3001, version: '', status: 'active',
};
private green: Environment = {
name: 'green', url: 'http://localhost:3002', port: 3002, version: '', status: 'idle',
};

constructor(private config: DeployConfig) {}

/** 获取当前空闲环境 */
private getIdleEnv(): Environment {
return this.blue.status === 'idle' ? this.blue : this.green;
}

/** 获取当前活跃环境 */
private getActiveEnv(): Environment {
return this.blue.status === 'active' ? this.blue : this.green;
}

/** 健康检查 */
private async healthCheck(env: Environment): Promise<boolean> {
for (let i = 0; i < this.config.healthCheckRetries; i++) {
try {
const response = await fetch(`${env.url}${this.config.healthCheckUrl}`);
if (response.ok) return true;
} catch {
// 等待后重试
await new Promise((r) => setTimeout(r, 2000));
}
}
return false;
}

/** 执行蓝绿部署 */
async deploy(newVersion: string): Promise<void> {
const idle = this.getIdleEnv();
const active = this.getActiveEnv();

console.log(`部署 ${newVersion}${idle.name} 环境...`);

// 1. 部署到空闲环境
await this.deployToEnv(idle, newVersion);

// 2. 健康检查
const healthy = await this.healthCheck(idle);
if (!healthy) {
throw new Error(`${idle.name} 环境健康检查失败,中止部署`);
}

// 3. 切换流量(原子操作)
console.log(`切换流量: ${active.name} -> ${idle.name}`);
await this.switchTraffic(idle);

// 4. 更新状态
idle.status = 'active';
idle.version = newVersion;
active.status = 'idle';

console.log(`部署完成!当前版本: ${newVersion} (${idle.name})`);
}

/** 回滚:切换回上一个环境 */
async rollback(): Promise<void> {
const idle = this.getIdleEnv(); // 上一个版本
const active = this.getActiveEnv();

console.log(`回滚: ${active.name}(${active.version}) -> ${idle.name}(${idle.version})`);
await this.switchTraffic(idle);

idle.status = 'active';
active.status = 'idle';
}

private async deployToEnv(_env: Environment, _version: string): Promise<void> {
// 实际实现:拉取镜像、启动容器、等待就绪
}

private async switchTraffic(_target: Environment): Promise<void> {
// 实际实现:更新 Nginx upstream 或 K8s Service
}
}

金丝雀发布(灰度发布)

scripts/canary-deploy.ts
interface CanaryConfig {
/** 金丝雀阶段配置:[流量比例, 持续时间(分钟)] */
stages: Array<[number, number]>;
/** 健康指标阈值 */
metrics: {
errorRateThreshold: number; // 错误率阈值(%)
p99LatencyThreshold: number; // P99 延迟阈值(ms)
successRateThreshold: number; // 成功率阈值(%)
};
}

const CANARY_CONFIG: CanaryConfig = {
stages: [
[5, 5], // 5% 流量,观察 5 分钟
[25, 10], // 25% 流量,观察 10 分钟
[50, 10], // 50% 流量,观察 10 分钟
[100, 0], // 全量发布
],
metrics: {
errorRateThreshold: 1, // 错误率 < 1%
p99LatencyThreshold: 500, // P99 < 500ms
successRateThreshold: 99, // 成功率 > 99%
},
};

interface CanaryMetrics {
errorRate: number;
p99Latency: number;
successRate: number;
}

class CanaryDeployer {
constructor(private config: CanaryConfig) {}

async deploy(version: string): Promise<boolean> {
console.log(`开始金丝雀发布: ${version}`);

for (const [percentage, duration] of this.config.stages) {
console.log(`设置金丝雀流量: ${percentage}%`);
await this.setTrafficWeight(percentage);

if (duration > 0) {
// 观察期间持续检查指标
const healthy = await this.monitorForDuration(duration);
if (!healthy) {
console.error(`金丝雀指标异常,自动回滚`);
await this.rollback();
return false;
}
}
}

console.log(`金丝雀发布完成: ${version} 已全量上线`);
return true;
}

private async monitorForDuration(minutes: number): Promise<boolean> {
const checkInterval = 30_000; // 每 30 秒检查一次
const checks = (minutes * 60 * 1000) / checkInterval;

for (let i = 0; i < checks; i++) {
const metrics = await this.collectMetrics();

if (metrics.errorRate > this.config.metrics.errorRateThreshold) {
console.error(`错误率 ${metrics.errorRate}% 超过阈值`);
return false;
}
if (metrics.p99Latency > this.config.metrics.p99LatencyThreshold) {
console.error(`P99 延迟 ${metrics.p99Latency}ms 超过阈值`);
return false;
}
if (metrics.successRate < this.config.metrics.successRateThreshold) {
console.error(`成功率 ${metrics.successRate}% 低于阈值`);
return false;
}

await new Promise((r) => setTimeout(r, checkInterval));
}
return true;
}

private async collectMetrics(): Promise<CanaryMetrics> {
// 从监控系统(Prometheus/Grafana)采集指标
return { errorRate: 0.1, p99Latency: 200, successRate: 99.9 };
}

private async setTrafficWeight(_percentage: number): Promise<void> {
// 更新 Nginx/Istio/K8s Ingress 的流量权重
}

private async rollback(): Promise<void> {
await this.setTrafficWeight(0); // 将金丝雀流量降为 0
console.log('金丝雀已回滚');
}
}

Feature Flag(功能开关)

src/lib/feature-flags.ts
type FeatureFlagValue = boolean | string | number;

interface FeatureFlag {
key: string;
defaultValue: FeatureFlagValue;
/** 灰度规则 */
rules?: Array<{
/** 匹配条件 */
condition: {
type: 'userId' | 'percentage' | 'region' | 'userAgent';
value: string | number;
};
/** 匹配后的值 */
value: FeatureFlagValue;
}>;
}

class FeatureFlagService {
private flags: Map<string, FeatureFlag> = new Map();
private overrides: Map<string, FeatureFlagValue> = new Map();

/** 从配置中心加载 Feature Flags */
async loadFromRemote(configUrl: string): Promise<void> {
const response = await fetch(configUrl);
const flags = (await response.json()) as FeatureFlag[];
flags.forEach((flag) => this.flags.set(flag.key, flag));
}

/** 判断功能是否启用 */
isEnabled(key: string, context?: { userId?: string; region?: string }): boolean {
// 本地覆盖优先(用于开发调试)
if (this.overrides.has(key)) {
return Boolean(this.overrides.get(key));
}

const flag = this.flags.get(key);
if (!flag) return false;

// 评估规则
if (flag.rules && context) {
for (const rule of flag.rules) {
if (this.matchCondition(rule.condition, context)) {
return Boolean(rule.value);
}
}
}

return Boolean(flag.defaultValue);
}

/** 基于百分比的灰度 */
private matchCondition(
condition: FeatureFlag['rules'][0]['condition'],
context: { userId?: string; region?: string },
): boolean {
switch (condition.type) {
case 'percentage': {
if (!context.userId) return false;
// 使用 userId 的哈希值确保同一用户始终在同一分组
const hash = this.hashUserId(context.userId);
return hash % 100 < (condition.value as number);
}
case 'userId':
return context.userId === condition.value;
case 'region':
return context.region === condition.value;
default:
return false;
}
}

private hashUserId(userId: string): number {
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0;
}
return Math.abs(hash);
}
}

// 使用示例
const featureFlags = new FeatureFlagService();

// 在组件中使用
if (featureFlags.isEnabled('new-dashboard', { userId: 'user-123' })) {
// 渲染新版 Dashboard
} else {
// 渲染旧版 Dashboard
}
Feature Flag 注意事项
  • Feature Flag 是临时性的,功能全量上线后必须清理对应的 Flag 代码
  • 过多未清理的 Feature Flag 会导致代码复杂度急剧上升(技术债务)
  • 建议设置 Flag 的过期时间,过期后自动提示清理
  • Flag 的状态变更需要记录审计日志

3.4 静态资源部署

前端静态资源部署的核心原则是 HTML 与静态资源分离部署

为什么先部署静态资源,后部署 HTML?
  • 先部署 CSS/JS 到 CDN:新版本的静态资源 URL 带有 content hash,不会覆盖旧版本资源
  • 后更新 HTML:HTML 更新后引用新资源 URL,用户加载到新 HTML 时,新资源已在 CDN 就绪
  • 如果反过来:HTML 先更新引用了新资源 URL,但新资源还未上传到 CDN,用户会看到白屏
scripts/deploy-static.ts
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';

interface DeployOptions {
distDir: string;
cdnBucket: string;
cdnPrefix: string;
serverHost: string;
serverPath: string;
}

async function deployFrontend(options: DeployOptions): Promise<void> {
const { distDir, cdnBucket, cdnPrefix, serverHost, serverPath } = options;

// 第一步:上传静态资源到 CDN(带 hash 的文件)
console.log('Step 1: 上传静态资源到 CDN...');
const staticFiles = getStaticFiles(distDir); // JS, CSS, images, fonts

for (const file of staticFiles) {
const remotePath = `${cdnPrefix}/${file}`;
execSync(
`aws s3 cp ${distDir}/${file} s3://${cdnBucket}/${remotePath} ` +
'--cache-control "public, max-age=31536000, immutable" ' +
'--content-encoding gzip'
);
}
console.log(`已上传 ${staticFiles.length} 个静态文件`);

// 第二步:等待 CDN 同步(确保边缘节点就绪)
console.log('Step 2: 等待 CDN 同步...');
await waitForCDNSync(staticFiles.slice(0, 3), cdnPrefix); // 抽样验证

// 第三步:部署 HTML 到源站
console.log('Step 3: 部署 HTML 到源站...');
execSync(
`rsync -avz ${distDir}/index.html ${serverHost}:${serverPath}/index.html`
);

console.log('部署完成!');
}

/** 获取所有带 hash 的静态资源文件 */
function getStaticFiles(distDir: string): string[] {
const allFiles = fs.readdirSync(distDir, { recursive: true }) as string[];
// 过滤出带 hash 的文件(JS、CSS、图片、字体)
return allFiles.filter((file) =>
/\.(js|css|png|jpg|svg|woff2|woff)$/.test(file) && !file.endsWith('index.html')
);
}

/** 等待 CDN 边缘节点同步 */
async function waitForCDNSync(sampleFiles: string[], cdnPrefix: string): Promise<void> {
const cdnDomain = 'https://cdn.example.com';
const maxRetries = 10;

for (const file of sampleFiles) {
for (let i = 0; i < maxRetries; i++) {
const url = `${cdnDomain}/${cdnPrefix}/${file}`;
try {
const response = await fetch(url, { method: 'HEAD' });
if (response.ok) break;
} catch {
// ignore
}
await new Promise((r) => setTimeout(r, 3000));
}
}
}

deployFrontend({
distDir: './dist',
cdnBucket: 'my-static-bucket',
cdnPrefix: 'app/v1.2.3',
serverHost: 'deploy@prod-server',
serverPath: '/var/www/app',
});

3.5 环境管理

多环境配置架构

环境触发方式API 源配置来源数据
Developmentpnpm devMock / 本地 API.env.developmentMock 数据
PreviewPR 创建/更新Staging APICI Variables测试数据
Stagingdevelop 分支推送Staging APICI Variables隔离测试数据
Productionmain 分支 + 审批Production API配置中心 + CI Secrets真实数据
src/config/env.ts
/** 类型安全的环境配置 */
interface EnvConfig {
/** 应用环境标识 */
appEnv: 'development' | 'preview' | 'staging' | 'production';
/** API 基础地址 */
apiBaseUrl: string;
/** CDN 资源前缀 */
cdnPrefix: string;
/** 是否启用 Mock */
enableMock: boolean;
/** Sentry DSN */
sentryDsn: string;
/** 功能开关配置地址 */
featureFlagUrl: string;
/** 是否启用 Source Map 上报 */
enableSourceMap: boolean;
}

/** 根据环境变量构建配置 */
function createEnvConfig(): EnvConfig {
const env = import.meta.env;

return {
appEnv: (env.VITE_APP_ENV as EnvConfig['appEnv']) || 'development',
apiBaseUrl: env.VITE_API_BASE_URL || 'http://localhost:3000',
cdnPrefix: env.VITE_CDN_PREFIX || '',
enableMock: env.VITE_ENABLE_MOCK === 'true',
sentryDsn: env.VITE_SENTRY_DSN || '',
featureFlagUrl: env.VITE_FEATURE_FLAG_URL || '',
enableSourceMap: env.VITE_ENABLE_SOURCE_MAP === 'true',
};
}

export const envConfig = createEnvConfig();

/** 环境判断工具函数 */
export const isDev = envConfig.appEnv === 'development';
export const isStaging = envConfig.appEnv === 'staging';
export const isProd = envConfig.appEnv === 'production';
前端环境变量安全

前端环境变量会被打包到客户端代码中,任何人都能在 DevTools 中查看。因此:

  • 只在环境变量中存放公开配置(API 域名、CDN 地址、功能开关)
  • 绝对不要存放 API Secret、数据库密码等敏感信息
  • 敏感操作应在服务端完成,通过 API 接口暴露

3.6 Monorepo CI/CD

对于 Monorepo 项目,CI/CD 的核心挑战是变更检测和选择性构建

.github/workflows/monorepo-turbo.yml
name: Monorepo CI/CD (Turborepo)
on:
push:
branches: [main, develop]
pull_request:
branches: [main]

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Turborepo 需要完整 git 历史来检测变更

- uses: pnpm/action-setup@v2
with:
version: 9

- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'

- run: pnpm install --frozen-lockfile

# Turborepo 远程缓存 - 跨 CI 运行共享构建缓存
- name: Build with Turborepo
run: pnpm turbo run build lint test --filter="...[HEAD^1]"
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}

# 只部署受影响的应用
- name: Deploy affected apps
run: |
# 检测哪些应用受影响
AFFECTED=$(pnpm turbo run build --filter="...[HEAD^1]" --dry-run=json | jq -r '.packages[]')
for app in $AFFECTED; do
echo "Deploying $app..."
done

四、关键技术实现

4.1 完整 Pipeline 配置

.github/workflows/ci-cd.yml
name: Frontend CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]

# 同一分支新提交时取消旧的运行
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

env:
NODE_VERSION: '20'
PNPM_VERSION: '9'

jobs:
# ==================== CI 阶段 ====================
quality:
name: Code Quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v2
with:
version: ${{ env.PNPM_VERSION }}

- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'

- run: pnpm install --frozen-lockfile

# 并行运行代码检查
- name: Lint
run: pnpm lint

- name: Type Check
run: pnpm tsc --noEmit

test:
name: Unit Tests
runs-on: ubuntu-latest
needs: quality
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v2
with:
version: ${{ env.PNPM_VERSION }}

- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'

- run: pnpm install --frozen-lockfile

- name: Run tests with coverage
run: pnpm test -- --coverage

- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/

build:
name: Build
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v2
with:
version: ${{ env.PNPM_VERSION }}

- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'

- run: pnpm install --frozen-lockfile

# 构建缓存
- uses: actions/cache@v4
with:
path: |
.next/cache
node_modules/.vite
key: build-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('src/**') }}
restore-keys: |
build-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-

- run: pnpm build

- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/

# ==================== CD 阶段 ====================
deploy-preview:
name: Deploy Preview
runs-on: ubuntu-latest
needs: build
if: github.event_name == 'pull_request'
environment:
name: preview
url: ${{ steps.deploy.outputs.url }}
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/

- name: Deploy to Preview
id: deploy
run: |
# 部署到 Vercel / Netlify Preview
echo "url=https://preview-pr-${{ github.event.number }}.example.com" >> $GITHUB_OUTPUT

deploy-staging:
name: Deploy Staging
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/develop'
environment:
name: staging
url: https://staging.example.com
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/

- name: Deploy to Staging
run: |
# 上传静态资源到 CDN
aws s3 sync dist/assets/ s3://cdn-bucket/staging/assets/ \
--cache-control "public, max-age=31536000, immutable"
# 部署 HTML
aws s3 cp dist/index.html s3://cdn-bucket/staging/index.html \
--cache-control "no-cache"

deploy-production:
name: Deploy Production
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main'
environment:
name: production
url: https://www.example.com
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/

- name: Upload static assets to CDN
run: |
aws s3 sync dist/assets/ s3://cdn-bucket/prod/assets/ \
--cache-control "public, max-age=31536000, immutable"

- name: Wait for CDN sync
run: sleep 10

- name: Deploy HTML
run: |
aws s3 cp dist/index.html s3://cdn-bucket/prod/index.html \
--cache-control "no-cache"

- name: Health check
run: |
for i in $(seq 1 10); do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://www.example.com)
if [ "$STATUS" = "200" ]; then
echo "Health check passed"
exit 0
fi
sleep 5
done
echo "Health check failed"
exit 1

- name: Notify team
if: always()
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "Production deploy ${{ job.status }} by ${{ github.actor }}\nCommit: ${{ github.sha }}"
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

4.2 平台选型对比

特性GitHub ActionsGitLab CIJenkins
架构云原生 SaaSSaaS + 可自建完全自建
配置文件多文件 .github/workflows/*.yml单文件 .gitlab-ci.ymlJenkinsfile (Groovy)
并行控制Jobs 默认并行 + needsstages 串行 + needs DAGparallel
缓存actions/cache Action内置 cache 关键字共享卷/stash
复用机制Marketplace + Reusable Workflowsinclude + extendsShared Libraries
容器支持可选 container默认 Docker 运行Docker Pipeline 插件
审批控制environment 保护规则when: manualinput 步骤
生态Marketplace 极丰富内置功能全面插件最丰富但质量参差
成本公开仓库免费400 分钟/月免费服务器费用
适用场景开源、中小型团队企业私有化部署大型企业、复杂流水线
选型建议
  • 代码在 GitHub --> GitHub Actions(深度集成,生态丰富)
  • 代码在 GitLab --> GitLab CI(开箱即用,功能完整)
  • 需要完全掌控 --> Jenkins(灵活但运维成本高)
  • 大型企业合规 --> GitLab CI 或 Jenkins(支持私有化部署)

4.3 回滚机制

版本管理与快速回滚

scripts/rollback-manager.ts
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';

interface Release {
version: string;
commitHash: string;
timestamp: string;
deployer: string;
status: 'active' | 'archived' | 'rolled-back';
directory: string;
}

class RollbackManager {
private releasesDir: string;
private currentLink: string;
private maxReleases: number;

constructor(
private baseDir: string = '/var/www/app',
maxReleases: number = 10,
) {
this.releasesDir = path.join(baseDir, 'releases');
this.currentLink = path.join(baseDir, 'current');
this.maxReleases = maxReleases;
}

/** 获取所有版本,按时间倒序 */
listReleases(): Release[] {
if (!fs.existsSync(this.releasesDir)) return [];

return fs.readdirSync(this.releasesDir)
.map((dir) => {
const metaPath = path.join(this.releasesDir, dir, 'release.json');
if (!fs.existsSync(metaPath)) return null;
return JSON.parse(fs.readFileSync(metaPath, 'utf-8')) as Release;
})
.filter((r): r is Release => r !== null)
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
}

/** 获取当前活跃版本 */
getCurrentRelease(): Release | null {
try {
const currentDir = fs.readlinkSync(this.currentLink);
const metaPath = path.join(currentDir, 'release.json');
if (fs.existsSync(metaPath)) {
return JSON.parse(fs.readFileSync(metaPath, 'utf-8')) as Release;
}
} catch {
// ignore
}
return null;
}

/** 部署新版本 */
deploy(version: string, buildDir: string): void {
const releaseId = new Date().toISOString().replace(/[-:T.Z]/g, '').slice(0, 14);
const releaseDir = path.join(this.releasesDir, releaseId);

// 1. 创建版本目录并复制构建产物
fs.mkdirSync(releaseDir, { recursive: true });
execSync(`cp -r ${buildDir}/* ${releaseDir}/`);

// 2. 写入版本元信息
const release: Release = {
version,
commitHash: execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim(),
timestamp: new Date().toISOString(),
deployer: process.env.CI_ACTOR || 'manual',
status: 'active',
directory: releaseDir,
};
fs.writeFileSync(
path.join(releaseDir, 'release.json'),
JSON.stringify(release, null, 2),
);

// 3. 原子性切换符号链接
execSync(`ln -sfn ${releaseDir} ${this.currentLink}`);

// 4. 重载 Nginx
execSync('sudo nginx -s reload');

// 5. 清理旧版本
this.cleanup();

console.log(`部署成功: ${version} (${releaseId})`);
}

/** 回滚到指定版本 */
rollback(targetVersion?: string): void {
const releases = this.listReleases();

if (releases.length < 2) {
throw new Error('没有可回滚的历史版本');
}

const target = targetVersion
? releases.find((r) => r.version === targetVersion)
: releases[1]; // 默认回滚到上一个版本

if (!target) {
throw new Error(`版本 ${targetVersion} 不存在`);
}

// 原子切换
execSync(`ln -sfn ${target.directory} ${this.currentLink}`);
execSync('sudo nginx -s reload');

console.log(`回滚成功: 当前版本 ${target.version}`);
}

/** 清理过期版本 */
private cleanup(): void {
const releases = this.listReleases();
if (releases.length > this.maxReleases) {
releases.slice(this.maxReleases).forEach((r) => {
execSync(`rm -rf ${r.directory}`);
console.log(`清理旧版本: ${r.version}`);
});
}
}
}

// 使用示例
const manager = new RollbackManager('/var/www/app', 10);

// 部署
manager.deploy('v1.2.3', './dist');

// 回滚到上一个版本
// manager.rollback();

// 回滚到指定版本
// manager.rollback('v1.2.0');

回滚策略对比

策略回滚速度是否需要重建复杂度适用场景
符号链接切换< 1 秒自建服务器部署
CDN 版本回切< 5 秒CDN 静态资源部署
Docker 镜像回退< 10 秒容器化部署
K8s Rollout Undo< 30 秒Kubernetes 集群
平台一键回滚< 5 秒Vercel / Netlify
Git Revert + 重新部署3-10 分钟任何场景(兜底方案)

4.4 监控与通知

scripts/deploy-notify.ts
interface DeployEvent {
status: 'started' | 'success' | 'failed' | 'rolled-back';
version: string;
environment: string;
deployer: string;
commitHash: string;
commitMessage: string;
duration?: number; // 部署耗时(秒)
url?: string; // 部署地址
error?: string; // 失败原因
}

interface NotifyChannel {
send(event: DeployEvent): Promise<void>;
}

/** 飞书通知 */
class FeishuNotifier implements NotifyChannel {
constructor(private webhookUrl: string) {}

async send(event: DeployEvent): Promise<void> {
const color = {
started: 'blue',
success: 'green',
failed: 'red',
'rolled-back': 'orange',
}[event.status];

const statusText = {
started: '开始部署',
success: '部署成功',
failed: '部署失败',
'rolled-back': '已回滚',
}[event.status];

await fetch(this.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
msg_type: 'interactive',
card: {
header: {
title: { content: `[${event.environment}] ${statusText}`, tag: 'plain_text' },
template: color,
},
elements: [
{
tag: 'div',
fields: [
{ is_short: true, text: { content: `**版本**: ${event.version}`, tag: 'lark_md' } },
{ is_short: true, text: { content: `**部署人**: ${event.deployer}`, tag: 'lark_md' } },
{ is_short: true, text: { content: `**Commit**: ${event.commitHash.slice(0, 8)}`, tag: 'lark_md' } },
{ is_short: true, text: { content: `**耗时**: ${event.duration ?? '-'}s`, tag: 'lark_md' } },
],
},
...(event.error
? [{ tag: 'div', text: { content: `**错误**: ${event.error}`, tag: 'lark_md' } }]
: []),
...(event.url
? [{ tag: 'action', actions: [{ tag: 'button', text: { content: '查看部署', tag: 'plain_text' }, url: event.url, type: 'primary' }] }]
: []),
],
},
}),
});
}
}

/** Slack 通知 */
class SlackNotifier implements NotifyChannel {
constructor(private webhookUrl: string) {}

async send(event: DeployEvent): Promise<void> {
const emoji = {
started: ':rocket:',
success: ':white_check_mark:',
failed: ':x:',
'rolled-back': ':warning:',
}[event.status];

await fetch(this.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: [
`${emoji} *[${event.environment}] Deploy ${event.status}*`,
`Version: ${event.version} | Deployer: ${event.deployer}`,
`Commit: \`${event.commitHash.slice(0, 8)}\` - ${event.commitMessage}`,
event.duration ? `Duration: ${event.duration}s` : '',
event.error ? `Error: ${event.error}` : '',
event.url ? `<${event.url}|View deployment>` : '',
]
.filter(Boolean)
.join('\n'),
}),
});
}
}

/** 部署后自动化验证 */
async function postDeployVerification(url: string): Promise<boolean> {
const checks = [
{ name: 'HTTP 状态码', check: async () => {
const res = await fetch(url);
return res.ok;
}},
{ name: '核心资源加载', check: async () => {
const html = await (await fetch(url)).text();
return html.includes('<script') && html.includes('<link');
}},
{ name: 'API 健康检查', check: async () => {
const res = await fetch(`${url}/api/health`);
return res.ok;
}},
];

for (const { name, check } of checks) {
try {
const passed = await check();
if (!passed) {
console.error(`验证失败: ${name}`);
return false;
}
console.log(`验证通过: ${name}`);
} catch (error) {
console.error(`验证异常: ${name}`, error);
return false;
}
}

return true;
}

五、性能优化

5.1 流水线提速策略

5.2 优化前后对比

优化项优化前优化后策略
依赖安装60s5spnpm 缓存
代码检查45s(串行)20s(并行)Lint + TypeCheck 并行
单元测试90s30s测试分片 + 缓存
构建120s30s增量构建 + 缓存
总流水线5-8 分钟1.5-2 分钟综合优化

六、扩展设计

6.1 Preview 部署(PR 预览)

每个 PR 自动生成独立的预览环境,方便 Code Review 时实际查看效果:

.github/workflows/preview.yml
name: Preview Deploy
on:
pull_request:
types: [opened, synchronize]

jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v2
with:
version: 9

- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'

- run: pnpm install --frozen-lockfile

- name: Build with PR env
run: pnpm build
env:
VITE_APP_ENV: preview
VITE_API_BASE_URL: https://api-staging.example.com

- name: Deploy Preview
id: deploy
run: |
# 部署到独立的 Preview URL
PREVIEW_URL="https://pr-${{ github.event.number }}.preview.example.com"
echo "url=$PREVIEW_URL" >> $GITHUB_OUTPUT

- name: Comment PR
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## Preview Deploy\nURL: ${{ steps.deploy.outputs.url }}`
})

6.2 性能基线对比

scripts/performance-baseline.ts
interface PerformanceBaseline {
bundleSize: number; // 总体积 (KB)
largestChunk: number; // 最大 chunk (KB)
buildTime: number; // 构建时间 (ms)
lhPerformance: number; // Lighthouse Performance 分数
lhAccessibility: number; // Lighthouse Accessibility 分数
fcp: number; // First Contentful Paint (ms)
lcp: number; // Largest Contentful Paint (ms)
}

async function compareWithBaseline(
current: PerformanceBaseline,
baselinePath: string,
): Promise<{ passed: boolean; report: string }> {
const baseline = JSON.parse(
await import('fs').then((fs) => fs.promises.readFile(baselinePath, 'utf-8'))
) as PerformanceBaseline;

const diffs: string[] = [];
let passed = true;

// 体积增长超过 10% 告警
const sizeGrowth = ((current.bundleSize - baseline.bundleSize) / baseline.bundleSize) * 100;
if (sizeGrowth > 10) {
diffs.push(`Bundle size +${sizeGrowth.toFixed(1)}% (${baseline.bundleSize}KB -> ${current.bundleSize}KB)`);
passed = false;
}

// Lighthouse 分数下降超过 5 分告警
if (current.lhPerformance < baseline.lhPerformance - 5) {
diffs.push(`Lighthouse Performance: ${baseline.lhPerformance} -> ${current.lhPerformance}`);
passed = false;
}

// LCP 增长超过 200ms 告警
if (current.lcp > baseline.lcp + 200) {
diffs.push(`LCP: ${baseline.lcp}ms -> ${current.lcp}ms`);
passed = false;
}

return {
passed,
report: passed
? 'Performance baseline check passed'
: `Performance regression detected:\n${diffs.join('\n')}`,
};
}

七、常见面试问题

Q1: 前端项目怎么做 CI/CD?请描述一个完整的流水线设计

答案

一个完整的前端 CI/CD 流水线按阶段依次包含:

核心设计原则

  1. 快速失败:Lint 和 TypeCheck 放最前面,它们执行最快(10-20 秒),能最早发现问题
  2. 并行执行:无依赖关系的任务并行运行(如 Lint 和 TypeCheck 并行),缩短总时间
  3. 缓存加速:缓存 node_modules(基于 lockfile hash)和构建产物(基于源码 hash),二次运行时间减少 70%+
  4. 环境隔离:不同分支部署到不同环境——feature 分支到 Preview,develop 到 Staging,main 到 Production
  5. 制品传递:构建产物通过 artifacts 在 Job 之间传递,避免重复构建
  6. 人工审批:生产部署通常需要人工审批(environment 保护规则),保留人类判断的关口
关键配置要点
# 1. 锁定依赖版本
- run: pnpm install --frozen-lockfile

# 2. 并发控制:新提交自动取消旧运行
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

# 3. 条件部署
- if: github.ref == 'refs/heads/main'

# 4. 环境保护(需要审批)
environment:
name: production

Q2: 如何实现灰度发布(金丝雀发布)?

答案

灰度发布的核心思路是将新版本先推送给小部分用户,观察指标正常后再逐步扩大。前端实现灰度发布主要有三种方案:

方案一:Nginx 权重分流

nginx-canary.conf
upstream backend {
server stable-v1.0:80 weight=95; # 95% 流量到稳定版
server canary-v1.1:80 weight=5; # 5% 流量到金丝雀版
}

server {
listen 80;
location / {
proxy_pass http://backend;
}
}

方案二:基于 Cookie/Header 分流

灰度分流逻辑
// 服务端中间件:根据用户 ID 决定分流
function canaryMiddleware(
req: { cookies: Record<string, string>; headers: Record<string, string> },
res: { setHeader: (key: string, value: string) => void },
next: () => void,
): void {
const userId = req.cookies['user_id'] || '';
const hash = simpleHash(userId) % 100;

if (hash < 5) {
// 5% 用户走金丝雀版本
res.setHeader('X-Canary', 'true');
// 代理到金丝雀服务
} else {
// 95% 用户走稳定版本
res.setHeader('X-Canary', 'false');
}
next();
}

function simpleHash(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
}
return Math.abs(hash);
}

方案三:Feature Flag 灰度

不需要部署两套服务,通过功能开关在代码层面控制:

Feature Flag 灰度
// 同一份代码,通过 Feature Flag 控制展示哪个版本
function DashboardPage(): JSX.Element {
const showNewDashboard = featureFlags.isEnabled('new-dashboard', {
userId: currentUser.id,
});

return showNewDashboard ? <NewDashboard /> : <OldDashboard />;
}
方案灰度粒度实现复杂度回滚速度适用场景
Nginx 权重分流按比例秒级整站灰度
Cookie/Header 分流按用户秒级定向灰度
Feature Flag按功能即时功能级灰度
K8s Canary按比例+指标秒级容器化部署

Q3: CDN 缓存更新策略是什么?如何保证用户加载到最新资源?

答案

前端资源的 CDN 缓存策略核心原则是 HTML 不缓存,静态资源强缓存

实现步骤

  1. 构建时为静态资源生成 content hash 文件名
vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
build: {
rollupOptions: {
output: {
// JS: app.a1b2c3d4.js
entryFileNames: 'assets/[name].[hash].js',
chunkFileNames: 'assets/[name].[hash].js',
// CSS: style.e5f6g7h8.css
assetFileNames: 'assets/[name].[hash].[ext]',
},
},
},
});
  1. 不同资源类型使用不同缓存策略
资源类型Cache-Control原因
index.htmlno-cachemax-age=0入口文件必须每次验证,确保引用最新资源
*.hash.jsmax-age=31536000, immutable文件名含 hash,内容变则 URL 变,可永久缓存
*.hash.cssmax-age=31536000, immutable同上
favicon.icomax-age=86400不常变更,缓存 1 天
  1. 部署顺序至关重要:先上传新的静态资源到 CDN,再更新 HTML 文件。反过来则可能导致用户加载到新 HTML 但新资源还未就绪,出现白屏。
CDN 缓存常见坑
  • 不要用 max-age=0, must-revalidate 替代 no-cache,行为不完全等同
  • immutable 指示浏览器在 max-age 期间完全不发送条件请求,连 304 协商都省掉
  • 如果忘记给 HTML 设置 no-cache,用户可能缓存了旧 HTML,长时间加载旧版本
  • CDN 刷新(purge)通常需要时间传播到所有边缘节点,不能依赖即时生效

Q4: 如何做版本回滚?请描述你的回滚方案

答案

版本回滚的核心是保留历史版本,支持快速切换。推荐的回滚方案分为三个层次:

第一层:符号链接回滚(秒级)

最快速的回滚方式,适用于自建服务器部署:

回滚核心逻辑
// 部署目录结构
// /var/www/app/
// current -> releases/20260227120000 (符号链接)
// releases/
// 20260227120000/ (当前版本)
// 20260226180000/ (上一个版本)
// 20260225100000/ (更早版本)

function rollback(): void {
const releases = listReleasesByDate(); // 按时间倒序

// 切换符号链接到上一个版本(原子操作)
execSync(`ln -sfn ${releases[1].path} /var/www/app/current`);
execSync('sudo nginx -s reload');
}

第二层:CDN 版本切换

对于纯静态站点,在 CDN 层面切换版本:

CDN 回滚
// 每个版本部署到独立路径
// CDN: /v1.2.3/assets/... /v1.2.2/assets/...

// 回滚 = 更新 HTML 中的资源路径前缀
function rollbackCDN(targetVersion: string): void {
// 将 origin 的 HTML 文件中的 CDN 前缀切换到目标版本
const html = fs.readFileSync('index.html', 'utf-8');
const updated = html.replace(/\/v[\d.]+\//g, `/${targetVersion}/`);
fs.writeFileSync('index.html', updated);
}

第三层:Git Revert(兜底方案)

当需要永久撤销某次变更时使用:

手动触发回滚 Workflow
name: Rollback
on:
workflow_dispatch:
inputs:
commit_sha:
description: 'Target commit SHA'
required: true

jobs:
rollback:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: git checkout ${{ github.event.inputs.commit_sha }}
- run: pnpm install --frozen-lockfile && pnpm build
# 重新部署...

最佳实践

  1. 生产环境至少保留 10 个历史版本
  2. 回滚操作必须是一键自动化的,不能依赖人工 SSH
  3. 每次回滚必须记录原因通知团队
  4. 回滚后必须创建 Hotfix 分支修复根本原因
  5. 定期演练回滚流程,确保关键时刻不出差错

相关链接