前端 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 Actions
- GitLab CI
- Jenkins
.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
.gitlab-ci.yml
# GitLab CI 内置 cache 关键字,配置更简洁
cache:
- key:
files:
- pnpm-lock.yaml
paths:
- node_modules/
- .pnpm-store/
policy: pull-push
- key: build-cache-$CI_COMMIT_REF_SLUG
paths:
- .next/cache/
- node_modules/.vite/
policy: pull-push
build:
stage: build
script:
- pnpm install --frozen-lockfile
- pnpm build
artifacts:
paths:
- dist/
expire_in: 1 week
Jenkinsfile
pipeline {
agent any
stages {
stage('Install') {
steps {
// Jenkins 使用 stash/unstash 或共享卷实现缓存
script {
if (fileExists('node_modules')) {
echo 'Using cached node_modules'
} else {
sh 'pnpm install --frozen-lockfile'
}
}
}
}
stage('Build') {
steps {
sh 'pnpm build'
stash includes: 'dist/**', name: 'build-output'
}
}
}
}
缓存优化效果
| 优化项 | 无缓存耗时 | 有缓存耗时 | 节省比例 |
|---|---|---|---|
| pnpm install | ~60s | ~5s | 92% |
| TypeScript 编译 | ~30s | ~8s | 73% |
| Next.js 构建 | ~120s | ~30s | 75% |
| Vite 构建 | ~20s | ~6s | 70% |
| Docker 镜像构建 | ~180s | ~20s | 89% |
| Turborepo 远程缓存 | ~90s | ~3s | 97% |
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 源 | 配置来源 | 数据 |
|---|---|---|---|---|
| Development | pnpm dev | Mock / 本地 API | .env.development | Mock 数据 |
| Preview | PR 创建/更新 | Staging API | CI Variables | 测试数据 |
| Staging | develop 分支推送 | Staging API | CI Variables | 隔离测试数据 |
| Production | main 分支 + 审批 | 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 的核心挑战是变更检测和选择性构建。
- Turborepo
- Nx
.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
.github/workflows/monorepo-nx.yml
name: Monorepo CI/CD (Nx)
on:
push:
branches: [main, develop]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v2
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
# Nx 受影响的项目检测
- name: Run affected commands
run: |
npx nx affected --target=lint --base=HEAD~1
npx nx affected --target=test --base=HEAD~1
npx nx affected --target=build --base=HEAD~1
# Nx Cloud 远程缓存
- name: Nx Cloud cache
env:
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_TOKEN }}
run: echo "Nx Cloud cache enabled"
四、关键技术实现
4.1 完整 Pipeline 配置
- GitHub Actions
- GitLab CI
- Jenkins
.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 }}
.gitlab-ci.yml
stages:
- install
- quality
- build
- deploy
# 全局缓存
cache:
- key:
files:
- pnpm-lock.yaml
paths:
- node_modules/
- .pnpm-store/
variables:
NODE_VERSION: '20'
# 安装依赖
install:
stage: install
image: node:20-alpine
script:
- corepack enable
- pnpm install --frozen-lockfile
# 代码检查(并行)
lint:
stage: quality
needs: [install]
script:
- pnpm lint
typecheck:
stage: quality
needs: [install]
script:
- pnpm tsc --noEmit
test:
stage: quality
needs: [install]
script:
- pnpm test --coverage
coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage/cobertura-coverage.xml
# 构建
build:
stage: build
needs: [lint, typecheck, test]
script:
- pnpm build
artifacts:
paths:
- dist/
expire_in: 1 week
# 部署到 staging
deploy_staging:
stage: deploy
needs: [build]
script:
- echo "Deploying to staging..."
environment:
name: staging
url: https://staging.example.com
rules:
- if: $CI_COMMIT_BRANCH == "develop"
# 部署到 production(手动审批)
deploy_production:
stage: deploy
needs: [build]
script:
- echo "Deploying to production..."
environment:
name: production
url: https://www.example.com
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
Jenkinsfile
pipeline {
agent any
environment {
NODE_VERSION = '20'
CI = 'true'
}
stages {
stage('Install') {
steps {
sh 'corepack enable && pnpm install --frozen-lockfile'
}
}
stage('Quality') {
parallel {
stage('Lint') {
steps { sh 'pnpm lint' }
}
stage('TypeCheck') {
steps { sh 'pnpm tsc --noEmit' }
}
stage('Test') {
steps { sh 'pnpm test -- --coverage' }
}
}
}
stage('Build') {
steps {
sh 'pnpm build'
archiveArtifacts artifacts: 'dist/**', fingerprint: true
}
}
stage('Deploy') {
when {
branch 'main'
}
steps {
input message: 'Deploy to production?', ok: 'Deploy'
sh 'echo "Deploying to production..."'
}
}
}
post {
failure {
slackSend(
color: 'danger',
message: "Build FAILED: ${env.JOB_NAME} #${env.BUILD_NUMBER}"
)
}
success {
slackSend(
color: 'good',
message: "Build SUCCESS: ${env.JOB_NAME} #${env.BUILD_NUMBER}"
)
}
}
}
4.2 平台选型对比
| 特性 | GitHub Actions | GitLab CI | Jenkins |
|---|---|---|---|
| 架构 | 云原生 SaaS | SaaS + 可自建 | 完全自建 |
| 配置文件 | 多文件 .github/workflows/*.yml | 单文件 .gitlab-ci.yml | Jenkinsfile (Groovy) |
| 并行控制 | Jobs 默认并行 + needs | stages 串行 + needs DAG | parallel 块 |
| 缓存 | actions/cache Action | 内置 cache 关键字 | 共享卷/stash |
| 复用机制 | Marketplace + Reusable Workflows | include + extends | Shared Libraries |
| 容器支持 | 可选 container | 默认 Docker 运行 | Docker Pipeline 插件 |
| 审批控制 | environment 保护规则 | when: manual | input 步骤 |
| 生态 | 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 优化前后对比
| 优化项 | 优化前 | 优化后 | 策略 |
|---|---|---|---|
| 依赖安装 | 60s | 5s | pnpm 缓存 |
| 代码检查 | 45s(串行) | 20s(并行) | Lint + TypeCheck 并行 |
| 单元测试 | 90s | 30s | 测试分片 + 缓存 |
| 构建 | 120s | 30s | 增量构建 + 缓存 |
| 总流水线 | 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 流水线按阶段依次包含:
核心设计原则:
- 快速失败:Lint 和 TypeCheck 放最前面,它们执行最快(10-20 秒),能最早发现问题
- 并行执行:无依赖关系的任务并行运行(如 Lint 和 TypeCheck 并行),缩短总时间
- 缓存加速:缓存 node_modules(基于 lockfile hash)和构建产物(基于源码 hash),二次运行时间减少 70%+
- 环境隔离:不同分支部署到不同环境——feature 分支到 Preview,develop 到 Staging,main 到 Production
- 制品传递:构建产物通过 artifacts 在 Job 之间传递,避免重复构建
- 人工审批:生产部署通常需要人工审批(
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 不缓存,静态资源强缓存:
实现步骤:
- 构建时为静态资源生成 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]',
},
},
},
});
- 不同资源类型使用不同缓存策略:
| 资源类型 | Cache-Control | 原因 |
|---|---|---|
index.html | no-cache 或 max-age=0 | 入口文件必须每次验证,确保引用最新资源 |
*.hash.js | max-age=31536000, immutable | 文件名含 hash,内容变则 URL 变,可永久缓存 |
*.hash.css | max-age=31536000, immutable | 同上 |
favicon.ico | max-age=86400 | 不常变更,缓存 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
# 重新部署...
最佳实践:
- 生产环境至少保留 10 个历史版本
- 回滚操作必须是一键自动化的,不能依赖人工 SSH
- 每次回滚必须记录原因并通知团队
- 回滚后必须创建 Hotfix 分支修复根本原因
- 定期演练回滚流程,确保关键时刻不出差错