CI/CD 与自动化部署
问题
什么是 CI/CD?如何设计一套完整的前端 CI/CD 流水线?GitHub Actions 和 GitLab CI 有什么区别?常见的自动化部署方案有哪些?
答案
CI/CD 是现代软件工程的核心实践,它通过自动化的方式将代码从开发者的本地环境安全、快速地交付到生产环境。对于前端项目而言,一套成熟的 CI/CD 流水线能极大提升团队效率,保障代码质量,减少人为失误。
CI/CD 核心概念
持续集成(CI)、持续交付(CD)、持续部署(CD)
这三个概念经常被混淆,但它们代表了不同的自动化程度:
| 概念 | 英文全称 | 核心目标 | 自动化程度 | 是否需要人工介入 |
|---|---|---|---|---|
| 持续集成 | Continuous Integration | 频繁合并代码,自动运行测试 | 代码合并 + 测试自动化 | 不需要 |
| 持续交付 | Continuous Delivery | 确保代码随时可发布 | 构建 + 测试 + 部署到预发环境 | 发布需要人工审批 |
| 持续部署 | Continuous Deployment | 每次变更自动上线 | 全流程自动化 | 完全不需要 |
- 持续交付:代码通过所有测试后,部署到 staging 环境,但发布到生产需要人工点击按钮
- 持续部署:代码通过所有测试后,自动部署到生产环境,无需人工干预
- 大多数团队采用持续交付而非持续部署,因为生产发布通常需要业务审批
前端 CI/CD 流水线全景
一个完整的前端 CI/CD 流水线通常包含以下阶段:
GitHub Actions 详解
GitHub Actions 是 GitHub 提供的 CI/CD 平台,与 GitHub 仓库深度集成,是目前前端项目最流行的 CI/CD 方案之一。
核心概念
| 概念 | 说明 | 类比 |
|---|---|---|
| Workflow | 整个自动化流程,由 .github/workflows/*.yml 定义 | 一条流水线 |
| Event | 触发 Workflow 的事件(push、PR、定时等) | 启动按钮 |
| Job | Workflow 中的一个任务单元,默认并行执行 | 流水线上的一个工位 |
| Step | Job 中的单个步骤,按顺序执行 | 工位上的一道工序 |
| Action | 可复用的步骤单元,可以引用社区或自建 Action | 标准化工具 |
| Runner | 执行 Job 的服务器(GitHub 托管或自托管) | 工人 / 机器 |
常用 Action
# 以下是常用的社区 Action
steps:
# 1. 检出代码
- uses: actions/checkout@v4
# 2. 设置 Node.js 环境
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm' # 自动缓存 pnpm 依赖
# 3. 设置 pnpm
- uses: pnpm/action-setup@v2
with:
version: 9
# 4. 缓存构建产物(Turborepo / Next.js 等)
- uses: actions/cache@v4
with:
path: |
.next/cache
node_modules/.cache
key: ${{ runner.os }}-build-${{ hashFiles('**/pnpm-lock.yaml') }}
# 5. 部署到 GitHub Pages
- uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./build
完整的前端 CI/CD 流水线
以下是一个生产级别的 GitHub Actions 配置,包含 lint、test、build、deploy 全流程:
name: Frontend CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
NODE_VERSION: '20'
PNPM_VERSION: '9'
CI: true
jobs:
# ========== 代码质量检查 ==========
lint-and-typecheck:
name: Lint & Type Check
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'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run ESLint
run: pnpm lint
- name: Run TypeScript type check
run: pnpm tsc --noEmit
# ========== 单元测试 ==========
test:
name: Unit Tests
runs-on: ubuntu-latest
needs: lint-and-typecheck
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'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests with coverage
run: pnpm test -- --coverage
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report
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'
- name: Install dependencies
run: pnpm install --frozen-lockfile
# 构建缓存优化
- name: Cache build output
uses: actions/cache@v4
with:
path: |
.next/cache
dist/.cache
key: build-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('src/**') }}
restore-keys: |
build-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-
build-${{ runner.os }}-
- name: Build project
run: pnpm build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
# ========== 部署到 Staging ==========
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/develop'
environment:
name: staging
url: https://staging.example.com
steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: build-output
path: dist/
- name: Deploy to staging server
run: |
rsync -avz --delete dist/ ${{ secrets.STAGING_USER }}@${{ secrets.STAGING_HOST }}:/var/www/staging/
# ========== 部署到 Production ==========
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main'
environment:
name: production
url: https://www.example.com
steps:
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: build-output
path: dist/
- name: Deploy to production
run: |
rsync -avz --delete dist/ ${{ secrets.PROD_USER }}@${{ secrets.PROD_HOST }}:/var/www/production/
- name: Health check
run: |
sleep 10
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://www.example.com)
if [ "$STATUS" != "200" ]; then
echo "Health check failed with status $STATUS"
exit 1
fi
--frozen-lockfile:确保 CI 环境使用和本地一致的依赖版本,不会自动更新 lock 文件needs: lint-and-typecheck:声明 Job 之间的依赖关系,test 必须在 lint 通过后才执行if: github.ref == 'refs/heads/main':条件判断,只在特定分支触发部署environment:关联 GitHub 的 Environment,可以配置保护规则和 secrets
Secrets 与环境变量管理
在 CI/CD 中管理敏感信息是至关重要的安全实践:
jobs:
deploy:
runs-on: ubuntu-latest
# 关联 GitHub Environment,使用环境级别的 secrets
environment:
name: production
steps:
- name: Deploy with secrets
env:
# Repository secrets(仓库级别)
API_KEY: ${{ secrets.API_KEY }}
# Environment secrets(环境级别,更安全)
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
run: |
echo "Deploying with secure credentials..."
- 永远不要在 Workflow 文件中硬编码密钥、密码等敏感信息
- 使用 GitHub Secrets 存储所有敏感数据,它们在日志中会被自动脱敏
- 推荐使用 Environment secrets 而非 Repository secrets,可以限制特定分支才能访问
- 定期轮转密钥,及时撤销离职人员的访问权限
GitLab CI 对比
GitLab CI/CD 是 GitLab 内置的 CI/CD 系统。如果你的项目托管在 GitLab 上,它是最自然的选择。
GitHub Actions vs GitLab CI
| 特性 | GitHub Actions | GitLab CI |
|---|---|---|
| 配置文件 | .github/workflows/*.yml(支持多文件) | .gitlab-ci.yml(单文件) |
| 执行器 | Runner(GitHub 托管 / 自托管) | Runner(共享 / 专用 / 自托管) |
| 触发方式 | Event(push、PR、schedule 等) | Pipeline trigger(push、MR、schedule) |
| 并行控制 | Jobs 默认并行,needs 声明依赖 | stages 按阶段串行,needs 支持 DAG |
| 缓存 | actions/cache Action | 内置 cache 关键字 |
| 制品 | actions/upload-artifact | 内置 artifacts 关键字 |
| 环境管理 | Environments | Environments |
| 复用机制 | Reusable Workflows / Composite Actions | include / extends / !reference |
| 市场生态 | GitHub Marketplace(Action 丰富) | 模板库(相对较少) |
| 定价 | 公开仓库免费,私有仓库有免费额度 | 公开项目免费,私有项目 400 分钟/月 |
| 容器支持 | 支持 Docker | 原生支持 Docker,默认在容器中运行 |
GitLab CI 配置示例
# 定义全局阶段
stages:
- install
- quality
- build
- deploy
# 全局缓存配置(比 GitHub Actions 更简洁)
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
- .pnpm-store/
# 变量定义
variables:
NODE_VERSION: '20'
# 安装依赖
install:
stage: install
image: node:20
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(仅 develop 分支)
deploy_staging:
stage: deploy
needs: [build]
script:
- rsync -avz --delete dist/ $STAGING_USER@$STAGING_HOST:/var/www/staging/
environment:
name: staging
url: https://staging.example.com
rules:
- if: $CI_COMMIT_BRANCH == "develop"
# 部署到 production(仅 main 分支,需要手动触发)
deploy_production:
stage: deploy
needs: [build]
script:
- rsync -avz --delete dist/ $PROD_USER@$PROD_HOST:/var/www/production/
environment:
name: production
url: https://www.example.com
rules:
- if: $CI_COMMIT_BRANCH == "main"
when: manual
- 内置
cache和artifacts关键字,不需要额外 Action - 原生支持
coverage正则提取覆盖率 rules+when: manual可以轻松实现手动审批发布- 支持
include引入远程配置,方便跨项目复用
自动化部署方案
方案一:Vercel(推荐用于前端项目)
Vercel 是 Next.js 团队打造的前端部署平台,提供开箱即用的 CI/CD 能力:
// Vercel 项目配置
{
// 构建配置
"buildCommand": "pnpm build",
"outputDirectory": "dist",
"installCommand": "pnpm install",
// 路由重写(SPA 应用必需)
"rewrites": [
{ "source": "/api/:path*", "destination": "/api/:path*" },
{ "source": "/(.*)", "destination": "/index.html" }
],
// 请求头配置
"headers": [
{
"source": "/assets/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=31536000, immutable"
}
]
}
],
// 环境变量(不同环境不同值)
"env": {
"NEXT_PUBLIC_API_URL": "@api-url"
}
}
Vercel 的核心优势:
- 零配置部署,连接 Git 仓库即可
- 每个 PR 自动生成 Preview URL,方便代码评审
- 全球 CDN 边缘网络,首屏加载极快
- 支持 Serverless Functions 和 Edge Functions
- 与 Next.js 深度集成,支持 ISR、SSR 等高级特性
方案二:Netlify
Netlify 是另一个流行的 Jamstack 部署平台:
[build]
command = "pnpm build"
publish = "dist"
functions = "netlify/functions"
[build.environment]
NODE_VERSION = "20"
NPM_FLAGS = "--prefix=/dev/null"
CI = "true"
# SPA 路由重定向
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
# 缓存控制
[[headers]]
for = "/assets/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
# 分支部署配置
[context.deploy-preview]
command = "pnpm build:preview"
[context.production]
command = "pnpm build:production"
[context.production.environment]
NODE_ENV = "production"
方案三:自建 Nginx 部署
对于需要完全掌控部署环境的团队,自建 Nginx 是最灵活的方案:
server {
listen 80;
server_name www.example.com;
# 静态资源根目录
root /var/www/production/current;
index index.html;
# SPA 路由 - 所有路径回退到 index.html
location / {
try_files $uri $uri/ /index.html;
}
# 带 hash 的静态资源 - 长期缓存
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# API 反向代理
location /api/ {
proxy_pass http://backend:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 开启 Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1024;
# 安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}
配合 GitHub Actions 的自动部署脚本:
name: Deploy to Nginx Server
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: production
url: https://www.example.com
steps:
- uses: actions/checkout@v4
- name: Setup and build
run: |
corepack enable
pnpm install --frozen-lockfile
pnpm build
# 使用符号链接实现零停机部署
- name: Deploy with zero downtime
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
RELEASE_DIR="/var/www/production/releases/$(date +%Y%m%d%H%M%S)"
mkdir -p $RELEASE_DIR
- name: Upload build files
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: "dist/*"
target: "/var/www/production/releases/latest/"
strip_components: 1
- name: Switch symlink and reload Nginx
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
# 切换符号链接(原子操作,零停机)
ln -sfn /var/www/production/releases/latest /var/www/production/current
# 重载 Nginx(不中断连接)
sudo nginx -s reload
# 保留最近 5 个版本,删除旧版本
cd /var/www/production/releases && ls -t | tail -n +6 | xargs rm -rf
部署方案对比
| 特性 | Vercel | Netlify | 自建 Nginx |
|---|---|---|---|
| 配置复杂度 | 极低 | 低 | 高 |
| 部署速度 | 极快 | 快 | 取决于网络 |
| 自定义能力 | 中等 | 中等 | 完全可控 |
| 费用 | 免费额度充足 | 免费额度充足 | 服务器费用 |
| SSR 支持 | 原生支持 | 通过 Functions | 需要自行配置 |
| CDN | 全球边缘节点 | 全球 CDN | 需要自行接入 |
| 回滚 | 一键回滚 | 一键回滚 | 需要自行实现 |
| Preview 部署 | 自动 | 自动 | 需要自行搭建 |
| 适用场景 | 中小型项目、开源项目 | 静态站点、Jamstack | 大型企业、特殊合规要求 |
环境管理
多环境策略
典型的前端项目需要至少三套环境:
| 环境 | 用途 | 触发方式 | 数据来源 |
|---|---|---|---|
| Development | 开发者本地开发 | pnpm dev | Mock 数据 / 开发 API |
| Preview | PR 代码评审 | 每次 PR 自动部署 | Staging API |
| Staging | QA 测试、UAT | develop 分支自动部署 | 与生产隔离的测试数据 |
| Production | 线上用户使用 | main 分支部署(可手动审批) | 真实数据 |
环境变量管理
使用 .env 文件 + CI/CD 变量管理不同环境的配置:
// 类型安全的环境变量读取
interface EnvConfig {
apiBaseUrl: string;
appEnv: 'development' | 'staging' | 'production';
enableMock: boolean;
sentryDsn: string;
featureFlags: Record<string, boolean>;
}
function getEnvConfig(): EnvConfig {
return {
apiBaseUrl: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000',
appEnv: (import.meta.env.VITE_APP_ENV as EnvConfig['appEnv']) || 'development',
enableMock: import.meta.env.VITE_ENABLE_MOCK === 'true',
sentryDsn: import.meta.env.VITE_SENTRY_DSN || '',
featureFlags: JSON.parse(import.meta.env.VITE_FEATURE_FLAGS || '{}'),
};
}
export const envConfig = getEnvConfig();
// 使用示例
console.log(`当前环境: ${envConfig.appEnv}`);
console.log(`API 地址: ${envConfig.apiBaseUrl}`);
VITE_APP_ENV=staging
VITE_API_BASE_URL=https://api-staging.example.com
VITE_ENABLE_MOCK=false
VITE_SENTRY_DSN=https://xxx@sentry.io/staging
VITE_FEATURE_FLAGS={"newDashboard":true,"darkMode":false}
VITE_APP_ENV=production
VITE_API_BASE_URL=https://api.example.com
VITE_ENABLE_MOCK=false
VITE_SENTRY_DSN=https://xxx@sentry.io/production
VITE_FEATURE_FLAGS={"newDashboard":false,"darkMode":false}
.env文件中不要存放真正的密钥(如 API Secret),它们会被打包到前端代码中- 前端环境变量只适合存放公开配置(如 API 域名、功能开关)
- 真正的密钥应放在 CI/CD 平台的 Secrets 中,仅在构建时注入
回滚策略
当生产环境出现问题时,快速回滚是保障用户体验的最后防线。
基于版本目录的回滚(自建部署)
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
interface ReleaseInfo {
version: string;
timestamp: string;
commitHash: string;
directory: string;
}
const RELEASES_DIR = '/var/www/production/releases';
const CURRENT_LINK = '/var/www/production/current';
/** 获取所有版本,按时间倒序 */
function listReleases(): ReleaseInfo[] {
const dirs = fs.readdirSync(RELEASES_DIR);
return dirs
.map((dir) => {
const metaPath = path.join(RELEASES_DIR, dir, 'release-meta.json');
if (fs.existsSync(metaPath)) {
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')) as ReleaseInfo;
return { ...meta, directory: path.join(RELEASES_DIR, dir) };
}
return null;
})
.filter((r): r is ReleaseInfo => r !== null)
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
}
/** 回滚到指定版本 */
function rollback(targetVersion?: string): void {
const releases = listReleases();
if (releases.length < 2) {
console.error('没有可回滚的版本');
process.exit(1);
}
// 默认回滚到上一个版本
const target = targetVersion
? releases.find((r) => r.version === targetVersion)
: releases[1]; // 上一个版本
if (!target) {
console.error(`找不到版本: ${targetVersion}`);
process.exit(1);
}
console.log(`正在回滚到版本 ${target.version} (${target.commitHash})`);
// 原子性切换符号链接
execSync(`ln -sfn ${target.directory} ${CURRENT_LINK}`);
// 重载 Nginx
execSync('sudo nginx -s reload');
console.log(`回滚成功!当前版本: ${target.version}`);
}
// 执行回滚
const targetVersion = process.argv[2];
rollback(targetVersion);
基于 Git Revert 的回滚
name: Production Rollback
on:
# 手动触发,输入要回滚到的 commit
workflow_dispatch:
inputs:
commit_sha:
description: '要回滚到的 commit SHA'
required: true
reason:
description: '回滚原因'
required: true
jobs:
rollback:
runs-on: ubuntu-latest
environment:
name: production
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Checkout target commit
run: git checkout ${{ github.event.inputs.commit_sha }}
- name: Build from target commit
run: |
corepack enable
pnpm install --frozen-lockfile
pnpm build
- name: Deploy rollback version
run: |
echo "Deploying rollback to commit ${{ github.event.inputs.commit_sha }}"
echo "Reason: ${{ github.event.inputs.reason }}"
# 部署逻辑...
- name: Notify team
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "Production Rollback executed by ${{ github.actor }}\nCommit: ${{ github.event.inputs.commit_sha }}\nReason: ${{ github.event.inputs.reason }}"
}
回滚策略对比
| 策略 | 回滚速度 | 实现复杂度 | 适用场景 |
|---|---|---|---|
| 符号链接切换 | 秒级 | 低 | 自建部署,保留多个版本目录 |
| 平台一键回滚 | 秒级 | 无需实现 | Vercel / Netlify 等平台 |
| Git Revert | 分钟级(需重新构建) | 低 | 需要保留完整 Git 历史 |
| Docker 镜像切换 | 秒级 | 中等 | 容器化部署 |
| 蓝绿部署 | 秒级 | 高 | 大规模生产系统 |
- 永远保留至少 5 个历史版本,确保可以回滚到任意一个
- 回滚操作应该是自动化的,不要依赖手动 SSH 上服务器操作
- 回滚后立即通知团队(Slack / 钉钉 / 飞书),并记录回滚原因
- 定期演练回滚流程,确保团队每个人都知道如何操作
缓存优化
CI/CD 流水线的执行时间直接影响开发体验。通过合理的缓存策略,可以大幅缩短流水线耗时。
依赖缓存
steps:
# 方式一:setup-node 内置缓存(推荐,最简单)
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
# 会自动缓存 pnpm store,key 基于 pnpm-lock.yaml 的 hash
# 方式二:手动配置 actions/cache(更灵活)
- name: Cache pnpm store
uses: actions/cache@v4
with:
path: |
~/.pnpm-store
node_modules
key: deps-${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
deps-${{ runner.os }}-pnpm-
# 方式三:Turborepo 远程缓存(Monorepo 项目推荐)
- name: Setup Turborepo cache
run: |
pnpm turbo build --cache-dir=.turbo
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
构建缓存
steps:
# Next.js 构建缓存
- name: Cache Next.js build
uses: actions/cache@v4
with:
path: |
.next/cache
key: nextjs-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('src/**', 'public/**') }}
restore-keys: |
nextjs-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-
nextjs-${{ runner.os }}-
# Vite 构建缓存
- name: Cache Vite build
uses: actions/cache@v4
with:
path: |
node_modules/.vite
key: vite-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('src/**') }}
缓存优化效果
| 优化项 | 无缓存耗时 | 有缓存耗时 | 节省比例 |
|---|---|---|---|
| pnpm install | ~60s | ~5s | 92% |
| TypeScript 编译 | ~30s | ~8s | 73% |
| Next.js 构建 | ~120s | ~30s | 75% |
| Docker 镜像构建 | ~180s | ~20s | 89% |
完整缓存策略示例
name: Optimized CI Pipeline
on: [push, pull_request]
jobs:
ci:
runs-on: ubuntu-latest
# 取消同一分支上正在运行的旧流水线
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
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: Get changed files
id: changed
uses: tj-actions/changed-files@v41
with:
files_yaml: |
src:
- 'src/**'
test:
- '__tests__/**'
- 'src/**/*.test.ts'
config:
- '*.config.*'
- 'tsconfig.json'
# 有 src 或 config 变更时才运行 lint
- name: Lint
if: steps.changed.outputs.src_any_changed == 'true' || steps.changed.outputs.config_any_changed == 'true'
run: pnpm lint
# 有 src 变更时才运行 typecheck
- name: Type Check
if: steps.changed.outputs.src_any_changed == 'true'
run: pnpm tsc --noEmit
# 有 src 或 test 变更时才运行测试
- name: Test
if: steps.changed.outputs.src_any_changed == 'true' || steps.changed.outputs.test_any_changed == 'true'
run: pnpm test
- name: Build
run: pnpm build
concurrency+cancel-in-progress:同一分支有新提交时,自动取消旧的流水线,避免资源浪费- 变更文件检测:只在相关文件发生变更时运行对应的检查,避免无意义的全量运行
- Monorepo 推荐 Turborepo:利用远程缓存和任务依赖图,只构建受影响的包
常见面试问题
Q1: 如何设计一个前端项目的 CI/CD 流水线?需要包含哪些步骤?
答案:
一个完整的前端 CI/CD 流水线应包含以下阶段,按依赖关系有序执行:
具体实现要点:
# 1. 安装依赖 - 使用 lock 文件锁定版本
- run: pnpm install --frozen-lockfile
# 2. 并行运行静态检查(节省时间)
# Lint + TypeCheck 可以并行
- run: pnpm lint &
- run: pnpm tsc --noEmit &
- wait
# 3. 运行测试并收集覆盖率
- run: pnpm test --coverage
# 可以设置覆盖率阈值,低于则失败
# "coverageThreshold": { "global": { "branches": 80 } }
# 4. 构建(利用缓存加速)
- run: pnpm build
# 确保构建产物可用
# 5. 部署到对应环境
# PR -> Preview, develop -> Staging, main -> Production
# 6. 部署后冒烟测试
# 验证关键接口可用、页面可访问
关键设计原则:
- 快速反馈:Lint 和 TypeCheck 放在最前面,它们执行最快,能最早发现问题
- 并行执行:没有依赖关系的任务并行运行(如 Lint 和 TypeCheck)
- 提前失败:任何一步失败立即终止,不浪费后续资源
- 环境隔离:不同分支部署到不同环境,避免交叉污染
- 缓存策略:缓存 node_modules 和构建产物,缩短流水线时间
Q2: GitHub Actions 和 GitLab CI 的核心区别是什么?你会如何选择?
答案:
两者都是成熟的 CI/CD 平台,核心区别体现在以下几个方面:
| 维度 | GitHub Actions | GitLab CI |
|---|---|---|
| 架构设计 | 事件驱动,基于 Event + Workflow | 阶段驱动,基于 Stage + Pipeline |
| 配置方式 | 多文件(.github/workflows/),按场景拆分 | 单文件(.gitlab-ci.yml),集中管理 |
| 复用机制 | Marketplace Action + Reusable Workflow | include + extends + YAML 锚点 |
| 执行模型 | Jobs 默认并行,needs 声明依赖 | 同 stage 并行,不同 stage 串行 |
| 缓存 | 需要 actions/cache Action | 内置 cache 关键字 |
| 容器支持 | 可选使用 container | 默认在 Docker 容器中运行 |
| 生态系统 | Marketplace 非常丰富 | 内置功能更全面 |
选择建议:
type CIPlatform = 'GitHub Actions' | 'GitLab CI';
function chooseCIPlatform(context: {
codeHost: 'GitHub' | 'GitLab' | 'Self-hosted';
teamSize: 'small' | 'medium' | 'large';
needsCompliance: boolean;
preferSimplicity: boolean;
}): CIPlatform {
// 规则 1:代码托管在哪就用哪个平台的 CI
if (context.codeHost === 'GitHub') return 'GitHub Actions';
if (context.codeHost === 'GitLab') return 'GitLab CI';
// 规则 2:需要合规审计,选 GitLab(内置更多企业特性)
if (context.needsCompliance) return 'GitLab CI';
// 规则 3:小团队追求简单,选 GitHub Actions(生态好)
if (context.teamSize === 'small' && context.preferSimplicity) {
return 'GitHub Actions';
}
// 默认:GitHub Actions(社区生态更丰富)
return 'GitHub Actions';
}
核心结论:代码托管在哪里,就优先使用那个平台的 CI/CD 工具。跨平台使用(如代码在 GitHub、CI 用 Jenkins)会增加维护成本和复杂度。
Q3: 如何实现前端项目的零停机部署和快速回滚?
答案:
零停机部署和快速回滚是生产环境部署的两个核心需求。实现方式取决于部署架构:
方案一:符号链接 + 版本目录(自建部署)
import { execSync } from 'child_process';
/** 部署目录结构:
* /var/www/app/
* ├── current -> releases/20260225120000 (符号链接)
* ├── releases/
* │ ├── 20260225120000/ (当前版本)
* │ ├── 20260224180000/ (上一个版本)
* │ └── 20260224100000/ (更早版本)
* └── shared/ (共享文件,如上传目录)
*/
const BASE_DIR = '/var/www/app';
const RELEASES_DIR = `${BASE_DIR}/releases`;
const CURRENT_LINK = `${BASE_DIR}/current`;
function deploy(buildDir: string): void {
const releaseId = new Date().toISOString().replace(/[-:T.Z]/g, '').slice(0, 14);
const releaseDir = `${RELEASES_DIR}/${releaseId}`;
// 1. 复制构建产物到新版本目录
execSync(`mkdir -p ${releaseDir}`);
execSync(`cp -r ${buildDir}/* ${releaseDir}/`);
// 2. 原子性切换符号链接(关键!ln -sfn 是原子操作)
// 用户请求不会中断,因为旧目录仍然存在
execSync(`ln -sfn ${releaseDir} ${CURRENT_LINK}`);
// 3. 重载 Nginx(不断开已有连接)
execSync('sudo nginx -s reload');
// 4. 清理旧版本(保留最近 5 个)
const releases = execSync(`ls -t ${RELEASES_DIR}`).toString().trim().split('\n');
releases.slice(5).forEach((old) => {
execSync(`rm -rf ${RELEASES_DIR}/${old}`);
});
console.log(`部署成功:${releaseId}`);
}
function rollback(): void {
const releases = execSync(`ls -t ${RELEASES_DIR}`).toString().trim().split('\n');
if (releases.length < 2) {
throw new Error('没有可回滚的版本');
}
const previousRelease = releases[1]; // 上一个版本
execSync(`ln -sfn ${RELEASES_DIR}/${previousRelease} ${CURRENT_LINK}`);
execSync('sudo nginx -s reload');
console.log(`回滚成功,当前版本:${previousRelease}`);
}
方案二:蓝绿部署(Docker / K8s)
# 蓝绿部署 docker-compose 配置
services:
blue:
image: frontend-app:stable
ports:
- "3001:80"
green:
image: frontend-app:latest
ports:
- "3002:80"
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx-upstream.conf:/etc/nginx/conf.d/default.conf
depends_on:
- blue
- green
回滚速度对比:
| 方案 | 回滚速度 | 原理 | 是否需要重新构建 |
|---|---|---|---|
| 符号链接切换 | < 1 秒 | 切换文件系统指向 | 否 |
| Vercel/Netlify 回滚 | < 5 秒 | 平台内切换部署版本 | 否 |
| Docker 镜像切换 | < 10 秒 | 重启容器指向旧镜像 | 否 |
| 蓝绿部署 | < 1 秒 | 负载均衡切换上游 | 否 |
| Git Revert + 重新部署 | 3-10 分钟 | 重新走完整 CI/CD | 是 |
最佳实践总结:
- 生产部署必须保留历史版本,不能只保留最新版本
- 回滚操作必须是自动化的,有明确的触发方式(CLI 命令或 Dashboard 按钮)
- 部署后必须执行健康检查,失败自动触发回滚
- 所有部署和回滚操作必须记录日志并通知团队
Q4: 前端项目的 CI/CD Pipeline 一般包含哪些步骤?
答案:
一个生产级前端项目的 CI/CD Pipeline 通常包含以下阶段,按依赖关系有序执行:
标准流程:Install -> Lint -> Test -> Build -> Deploy
各步骤详解:
| 步骤 | 目的 | 关键配置 | 耗时占比 |
|---|---|---|---|
| Install | 安装项目依赖 | --frozen-lockfile 锁定版本 | 20-40% |
| Lint | ESLint + Stylelint 代码检查 | 与 TypeCheck 并行执行 | 5-10% |
| TypeCheck | tsc --noEmit 类型检查 | 与 Lint 并行执行 | 5-10% |
| Test | 单元测试 + 覆盖率收集 | 设置覆盖率阈值 | 10-20% |
| Build | 生产构建 | 利用缓存加速 | 20-30% |
| Deploy | 部署到对应环境 | 按分支区分环境 | 5-10% |
完整 GitHub Actions 示例:
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
NODE_VERSION: '20'
jobs:
# ===== 1. 安装依赖(缓存加速) =====
install:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm' # 自动缓存 pnpm store
- run: pnpm install --frozen-lockfile
# 将 node_modules 缓存为 artifact,供后续 Job 复用
- uses: actions/cache/save@v4
with:
path: node_modules
key: nm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
# ===== 2. Lint 和 TypeCheck(并行执行) =====
lint:
needs: install
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- uses: actions/cache/restore@v4
with:
path: node_modules
key: nm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- run: pnpm lint
typecheck:
needs: install
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- uses: actions/cache/restore@v4
with:
path: node_modules
key: nm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- run: pnpm tsc --noEmit
# ===== 3. 单元测试 =====
test:
needs: [lint, typecheck] # Lint 和 TypeCheck 都通过后才运行
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- uses: actions/cache/restore@v4
with:
path: node_modules
key: nm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- run: pnpm test -- --coverage
- uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
# ===== 4. 构建 =====
build:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- uses: actions/cache/restore@v4
with:
path: node_modules
key: nm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
# 构建缓存(Next.js / Vite)
- uses: actions/cache@v4
with:
path: .next/cache
key: build-${{ runner.os }}-${{ hashFiles('src/**') }}
- run: pnpm build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
# ===== 5. 部署(按分支区分环境) =====
deploy:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Deploy to environment
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
echo "Deploying to production..."
# rsync / aws s3 sync / vercel deploy --prod
else
echo "Deploying to staging..."
fi
Pipeline 优化技巧:
| 优化手段 | 效果 | 实现方式 |
|---|---|---|
| 缓存 node_modules | 安装时间从 60s 降到 5s | actions/cache 或 setup-node 的 cache 选项 |
| Lint 和 TypeCheck 并行 | 节省 50% 静态检查时间 | 拆分为两个独立 Job |
| 构建缓存 | 增量构建时间大幅缩短 | 缓存 .next/cache、node_modules/.vite |
concurrency 取消旧任务 | 避免资源浪费 | concurrency: { group: ci-${{ github.ref }}, cancel-in-progress: true } |
| 变更文件检测 | 只对改动文件运行检查 | tj-actions/changed-files |
--frozen-lockfile | 确保依赖版本一致 | CI 中必须使用 |
Q5: 前端部署策略有哪些?如何实现灰度发布?
答案:
前端部署策略决定了新版本如何替换旧版本上线。不同策略在风险控制、回滚速度、资源消耗方面有显著差异。
常见部署策略对比
| 策略 | 原理 | 优点 | 缺点 | 回滚速度 | 适用场景 |
|---|---|---|---|---|---|
| 直接部署 | 直接用新版本覆盖旧版本 | 简单直接 | 部署期间服务中断,无法回滚 | 无法回滚 | 个人项目、开发环境 |
| 蓝绿部署 | 维护两套环境(Blue/Green),切换流量 | 零停机,秒级回滚 | 需要双倍资源 | < 1 秒 | 中小型生产系统 |
| 滚动部署 | 逐台服务器替换新版本 | 资源利用率高 | 过程中新旧版本共存 | 分钟级 | 多节点部署 |
| 金丝雀发布 | 先导一小部分流量到新版本 | 风险最低,渐进验证 | 实现复杂,需要流量管理 | 秒级 | 大型生产系统 |
蓝绿部署
部署新版本时,先将新版本部署到闲置环境(Green),验证通过后将 Nginx 上游切换到 Green,原来的 Blue 变为闲置。回滚时只需将流量切回 Blue。
金丝雀发布(灰度发布)
金丝雀发布是最安全的上线策略,核心思想是渐进式放量:
方案一:Nginx 配置灰度
通过 Nginx 的 split_clients 或 Cookie/Header 实现流量分配:
# 基于 Cookie 的灰度策略
map $cookie_canary $upstream_group {
"true" canary_backend; # 命中灰度
default stable_backend; # 稳定版
}
upstream stable_backend {
server 127.0.0.1:3001; # v1.0 稳定版
}
upstream canary_backend {
server 127.0.0.1:3002; # v1.1 灰度版
}
# 基于权重的百分比灰度
split_clients "${remote_addr}" $variant {
5% canary_backend; # 5% 流量走灰度
* stable_backend; # 95% 走稳定版
}
server {
listen 80;
location / {
# 使用 Cookie 策略或权重策略
proxy_pass http://$upstream_group;
}
}
方案二:Feature Flag 灰度
通过前端代码中的 Feature Flag 控制功能发布,不依赖部署策略:
interface FeatureConfig {
enabled: boolean;
/** 灰度比例 0-100 */
percentage: number;
/** 白名单用户 ID */
whitelist: string[];
/** 灰度规则 */
rules: Array<{
attribute: string;
operator: 'eq' | 'in' | 'gt' | 'lt';
value: unknown;
}>;
}
class FeatureFlagService {
private flags: Map<string, FeatureConfig> = new Map();
constructor(private userId: string) {}
async init(): Promise<void> {
// 从远端配置中心拉取 Feature Flag 配置
const response = await fetch('/api/feature-flags');
const configs = (await response.json()) as Record<string, FeatureConfig>;
for (const [key, config] of Object.entries(configs)) {
this.flags.set(key, config);
}
}
isEnabled(flagName: string): boolean {
const config = this.flags.get(flagName);
if (!config || !config.enabled) return false;
// 白名单用户直接开启
if (config.whitelist.includes(this.userId)) return true;
// 百分比灰度:基于用户 ID 的 hash 值决定
const hash = this.hashUserId(this.userId);
return (hash % 100) < config.percentage;
}
private hashUserId(userId: string): number {
let hash = 0;
for (let i = 0; i < userId.length; i++) {
const char = userId.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash |= 0; // 转为 32 位整数
}
return Math.abs(hash);
}
}
// 使用示例
const featureFlags = new FeatureFlagService(currentUser.id);
await featureFlags.init();
if (featureFlags.isEnabled('new-dashboard')) {
// 渲染新版 Dashboard
renderNewDashboard();
} else {
// 渲染旧版 Dashboard
renderOldDashboard();
}
方案三:CDN 多版本管理
将不同版本的前端资源部署到 CDN 不同路径,通过网关控制用户加载哪个版本的 HTML 入口:
interface GrayRule {
version: string;
percentage: number;
conditions?: Array<{
field: 'userId' | 'region' | 'platform';
operator: 'eq' | 'in';
value: string | string[];
}>;
}
function resolveVersion(userId: string, rules: GrayRule[]): string {
for (const rule of rules) {
// 检查是否命中灰度规则
if (rule.conditions?.every((cond) => matchCondition(userId, cond))) {
return rule.version; // 命中条件,返回灰度版本
}
// 百分比灰度
const hash = simpleHash(userId) % 100;
if (hash < rule.percentage) {
return rule.version;
}
}
return 'stable'; // 默认走稳定版
}
// CDN 路径映射
// stable -> https://cdn.example.com/v1.0/index.html
// canary -> https://cdn.example.com/v1.1/index.html
灰度发布的完整流程:
| 阶段 | 流量比例 | 持续时间 | 关注指标 |
|---|---|---|---|
| 内部测试 | 内部员工 | 1-2 天 | 功能正确性 |
| 小流量灰度 | 1-5% | 1-2 天 | 错误率、性能指标 |
| 中等流量 | 10-30% | 1-3 天 | 用户反馈、业务指标 |
| 大流量 | 50-80% | 1 天 | 全量指标 |
| 全量发布 | 100% | - | 持续监控 |
- 监控先行:灰度期间必须有完善的监控(错误率、性能、业务指标),异常时立即回滚
- 用户一致性:同一用户在灰度期间应始终看到同一个版本(基于 userId hash),避免体验跳变
- 快速回滚:灰度环境必须支持秒级回滚,不能依赖重新构建
- 灰度日志:记录用户命中的版本信息,方便问题排查