跳到主要内容

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、定时等)启动按钮
JobWorkflow 中的一个任务单元,默认并行执行流水线上的一个工位
StepJob 中的单个步骤,按顺序执行工位上的一道工序
Action可复用的步骤单元,可以引用社区或自建 Action标准化工具
Runner执行 Job 的服务器(GitHub 托管或自托管)工人 / 机器

常用 Action

.github/workflows/ci.yml
# 以下是常用的社区 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 全流程:

.github/workflows/ci-cd.yml
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
Workflow 关键点解析
  • --frozen-lockfile:确保 CI 环境使用和本地一致的依赖版本,不会自动更新 lock 文件
  • needs: lint-and-typecheck:声明 Job 之间的依赖关系,test 必须在 lint 通过后才执行
  • if: github.ref == 'refs/heads/main':条件判断,只在特定分支触发部署
  • environment:关联 GitHub 的 Environment,可以配置保护规则和 secrets

Secrets 与环境变量管理

在 CI/CD 中管理敏感信息是至关重要的安全实践:

.github/workflows/deploy.yml
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 ActionsGitLab 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 关键字
环境管理EnvironmentsEnvironments
复用机制Reusable Workflows / Composite Actionsinclude / extends / !reference
市场生态GitHub Marketplace(Action 丰富)模板库(相对较少)
定价公开仓库免费,私有仓库有免费额度公开项目免费,私有项目 400 分钟/月
容器支持支持 Docker原生支持 Docker,默认在容器中运行

GitLab CI 配置示例

.gitlab-ci.yml
# 定义全局阶段
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
GitLab CI 的优势
  • 内置 cacheartifacts 关键字,不需要额外 Action
  • 原生支持 coverage 正则提取覆盖率
  • rules + when: manual 可以轻松实现手动审批发布
  • 支持 include 引入远程配置,方便跨项目复用

自动化部署方案

方案一:Vercel(推荐用于前端项目)

Vercel 是 Next.js 团队打造的前端部署平台,提供开箱即用的 CI/CD 能力:

vercel.json
// 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 部署平台:

netlify.toml
[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 是最灵活的方案:

nginx.conf
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 的自动部署脚本

.github/workflows/deploy-nginx.yml
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

部署方案对比

特性VercelNetlify自建 Nginx
配置复杂度极低
部署速度极快取决于网络
自定义能力中等中等完全可控
费用免费额度充足免费额度充足服务器费用
SSR 支持原生支持通过 Functions需要自行配置
CDN全球边缘节点全球 CDN需要自行接入
回滚一键回滚一键回滚需要自行实现
Preview 部署自动自动需要自行搭建
适用场景中小型项目、开源项目静态站点、Jamstack大型企业、特殊合规要求

环境管理

多环境策略

典型的前端项目需要至少三套环境:

环境用途触发方式数据来源
Development开发者本地开发pnpm devMock 数据 / 开发 API
PreviewPR 代码评审每次 PR 自动部署Staging API
StagingQA 测试、UATdevelop 分支自动部署与生产隔离的测试数据
Production线上用户使用main 分支部署(可手动审批)真实数据

环境变量管理

使用 .env 文件 + CI/CD 变量管理不同环境的配置:

src/config/env.ts
// 类型安全的环境变量读取
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}`);
.env.staging
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}
.env.production
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 中,仅在构建时注入

回滚策略

当生产环境出现问题时,快速回滚是保障用户体验的最后防线。

基于版本目录的回滚(自建部署)

scripts/rollback.ts
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 的回滚

.github/workflows/rollback.yml
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 镜像切换秒级中等容器化部署
蓝绿部署秒级大规模生产系统
回滚最佳实践
  1. 永远保留至少 5 个历史版本,确保可以回滚到任意一个
  2. 回滚操作应该是自动化的,不要依赖手动 SSH 上服务器操作
  3. 回滚后立即通知团队(Slack / 钉钉 / 飞书),并记录回滚原因
  4. 定期演练回滚流程,确保团队每个人都知道如何操作

缓存优化

CI/CD 流水线的执行时间直接影响开发体验。通过合理的缓存策略,可以大幅缩短流水线耗时。

依赖缓存

.github/workflows/cache-example.yml
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 }}

构建缓存

.github/workflows/build-cache.yml
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~5s92%
TypeScript 编译~30s~8s73%
Next.js 构建~120s~30s75%
Docker 镜像构建~180s~20s89%

完整缓存策略示例

.github/workflows/optimized-pipeline.yml
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 ActionsGitLab CI
架构设计事件驱动,基于 Event + Workflow阶段驱动,基于 Stage + Pipeline
配置方式多文件(.github/workflows/),按场景拆分单文件(.gitlab-ci.yml),集中管理
复用机制Marketplace Action + Reusable Workflowinclude + 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: 如何实现前端项目的零停机部署和快速回滚?

答案

零停机部署和快速回滚是生产环境部署的两个核心需求。实现方式取决于部署架构:

方案一:符号链接 + 版本目录(自建部署)

deploy/zero-downtime.ts
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.blue-green.yml
# 蓝绿部署 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

最佳实践总结

  1. 生产部署必须保留历史版本,不能只保留最新版本
  2. 回滚操作必须是自动化的,有明确的触发方式(CLI 命令或 Dashboard 按钮)
  3. 部署后必须执行健康检查,失败自动触发回滚
  4. 所有部署和回滚操作必须记录日志通知团队

Q4: 前端项目的 CI/CD Pipeline 一般包含哪些步骤?

答案

一个生产级前端项目的 CI/CD Pipeline 通常包含以下阶段,按依赖关系有序执行:

标准流程:Install -> Lint -> Test -> Build -> Deploy

各步骤详解:

步骤目的关键配置耗时占比
Install安装项目依赖--frozen-lockfile 锁定版本20-40%
LintESLint + Stylelint 代码检查与 TypeCheck 并行执行5-10%
TypeChecktsc --noEmit 类型检查与 Lint 并行执行5-10%
Test单元测试 + 覆盖率收集设置覆盖率阈值10-20%
Build生产构建利用缓存加速20-30%
Deploy部署到对应环境按分支区分环境5-10%

完整 GitHub Actions 示例:

.github/workflows/ci-cd.yml
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 降到 5sactions/cachesetup-nodecache 选项
Lint 和 TypeCheck 并行节省 50% 静态检查时间拆分为两个独立 Job
构建缓存增量构建时间大幅缩短缓存 .next/cachenode_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 实现流量分配:

nginx-canary.conf
# 基于 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 控制功能发布,不依赖部署策略:

src/utils/feature-flag.ts
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%-持续监控
灰度发布最佳实践
  1. 监控先行:灰度期间必须有完善的监控(错误率、性能、业务指标),异常时立即回滚
  2. 用户一致性:同一用户在灰度期间应始终看到同一个版本(基于 userId hash),避免体验跳变
  3. 快速回滚:灰度环境必须支持秒级回滚,不能依赖重新构建
  4. 灰度日志:记录用户命中的版本信息,方便问题排查

相关链接