第30章 Vue 3 应用部署

第三十章 Vue 3 应用部署

代码写完了,接下来最重要的事情就是部署。本章我们将从构建分析开始,讲解如何优化打包体积、配置 Nginx、实现 Docker 容器化、集成监控告警,以及搭建完整的 CI/CD 流程。学完本章,你的应用就能从"本地玩具"变成"生产级服务"。

30.1 构建与资源优化

30.1.1 构建分析

Vite 内置了构建分析工具,可以直观地看到打包后的体积分布:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  build: {
    // 生成打包分析报告
    rollupOptions: {
      plugins: [
        visualizer({
          filename: 'dist/stats.html',  // 分析报告输出位置
          open: true,                    // 自动打开
          gzipSize: true,                // 显示 gzip 后的大小
          brotliSize: true,              // 显示 brotli 压缩后的大小
        }),
      ],
    },
  },
})
1
2
3
# 构建后查看分析报告
npm run build
# 会在 dist/stats.html 生成可视化报告

30.1.2 优化打包体积

构建完成后,第一件事就是看"谁最肥"——哪个包的体积太大,需要优化。vite-plugin-visualizer 可以生成可视化报告,让你直观看到每个依赖占多大体积。针对性地优化最大的那个包,效果最明显。

除了分析,还有这些常用优化手段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  build: {
    // 目标浏览器
    target: 'es2015',
    
    // 输出路径
    outDir: 'dist',
    
    // 是否生成 sourcemap
    sourcemap: false,  // 生产环境不需要
    
    // 压缩配置
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: true,      // 移除 console
        drop_debugger: true,     // 移除 debugger
        pure_funcs: ['console.log'], // 移除特定函数
      },
    },
    
    // CSS 代码分割
    cssCodeSplit: true,
    
    // 动态 import 分割
    rollupOptions: {
      output: {
        // 手动分包
        manualChunks: {
          'vue-runtime': ['vue'],
          'vue-router': ['vue-router'],
          'pinia': ['pinia'],
          'vendor': ['lodash-es', 'axios'],
        },
      },
    },
    
    // 体积限制警告
    chunkSizeWarningLimit: 500,  // KB
  },
})

30.1.3 静态资源处理

静态资源(图片、字体、视频等)有不同的优化策略:太小的图片内联成 base64 可以减少请求数,太大的资源用 CDN 加载更快:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// vite.config.ts
export default defineConfig({
  build: {
    // assetsInlineLimit:小于多少字节的图片,会被转成 base64 直接嵌入 HTML
    // 4096 = 4KB,图片小于 4KB 时直接内联,减少 HTTP 请求
    // 太大的图片不适合内联(base64 比原文件大 33%),会反而拖慢加载
    assetsInlineLimit: 4096,

    // publicDir:静态资源根目录,打包时会原样复制到 dist
    // 放不需要处理的公共资源,如 favicon.ico、robots.txt
    publicDir: 'public',

    // assetsDir:构建产物中静态资源的输出路径
    assetsDir: 'assets',

    // 文件名哈希(缓存优化的关键)
    // 有哈希的文件名,内容变化时哈希就变,浏览器就会重新请求
    // 没有变化的文件,哈希不变,浏览器直接用缓存
    rollupOptions: {
      output: {
        // 带哈希的文件名
        entryFileNames: 'js/[name]-[hash].js',
        chunkFileNames: 'js/[name]-[hash].js',
        assetFileNames: (assetInfo) => {
          const info = assetInfo.name.split('.')
          const ext = info[info.length - 1]
          if (/\.(png|jpe?g|svg|gif|webp|ico)$/.test(assetInfo.name)) {
            return `images/[name]-[hash][extname]`
          }
          if (/\.(woff2?|eot|ttf|otf)$/i.test(assetInfo.name)) {
            return `fonts/[name]-[hash][extname]`
          }
          return `[name]-[hash][extname]`
        },
      },
    },
  },
})

30.1.4 CDN 配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// vite.config.ts
import { defineConfig } from 'vite'

const cdnBase = 'https://cdn.example.com'

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        // CDN 基础路径
        publicPath: '/',
        
        // 外部化 CDN 依赖
        external: ['vue', 'vue-router', 'pinia'],
      },
    },
  },
  
  // 或者在 HTML 模板中手动引入 CDN
  // 这种方式更灵活,可以选择具体的 CDN 服务商
})

// index.html
/*
<script src="https://cdn.bootcdn.net/ajax/libs/vue/3.4.0/vue.global.prod.js"></script>
*/

30.2 Nginx 配置

30.2.1 SPA 路由配置

Vue Router 使用 history 模式时,URL 像普通 URL 一样,但服务器上并没有对应的文件。需要配置 Nginx 将所有请求重定向到 index.html:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# /etc/nginx/conf.d/vue-app.conf

server {
    listen 80;
    server_name example.com;
    
    # Vue Router history 模式的关键配置
    location / {
        root /usr/share/nginx/html;  # 静态文件目录
        index index.html;
        
        # try_files 是关键:
        # 尝试查找文件,找不到就返回 index.html
        try_files $uri $uri/ /index.html;
    }
    
    # API 代理(如果前后端分离)
    location /api/ {
        proxy_pass http://backend:3000/;
        proxy_http_version 1.1;
        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;
    }
    
    # WebSocket 支持
    location /ws/ {
        proxy_pass http://backend:3000/ws/;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
    
    # 静态资源缓存
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
        root /usr/share/nginx/html;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

30.2.2 Gzip 压缩

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
server {
    # 开启 gzip
    gzip on;
    gzip_vary on;
    
    # gzip 压缩级别(1-9,越高压缩率越大,CPU 消耗越多)
    gzip_comp_level 6;
    
    # 不压缩的资源
    gzip_proxied any;
    gzip_min_length 1000;
    
    # 压缩的文件类型
    gzip_types 
        text/plain
        text/css
        text/xml
        application/json
        application/javascript
        application/xml
        application/xml+rss
        application/vnd.ms-fontobject
        application/x-font-ttf
        font/opentype
        image/svg+xml
        image/x-icon;
    
    location / {
        root /usr/share/nginx/html;
        index index.html;
        try_files $uri $uri/ /index.html;
    }
}

30.2.3 安全配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
server {
    listen 443 ssl http2;
    server_name example.com;
    
    # SSL 证书配置
    ssl_certificate /etc/nginx/ssl/example.com.pem;
    ssl_certificate_key /etc/nginx/ssl/example.com.key;
    
    # SSL 安全配置
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers on;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;
    
    # 安全头
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    
    # HSTS(可选,启用后浏览器会强制使用 HTTPS)
    # add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    
    location / {
        root /usr/share/nginx/html;
        index index.html;
        try_files $uri $uri/ /index.html;
    }
}

30.2.4 负载均衡配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# upstream 定义后端服务器组
upstream backend {
    least_conn;  # 最少连接数负载均衡
    server backend1.example.com:3000;
    server backend2.example.com:3000;
    server backend3.example.com:3000;
}

server {
    listen 80;
    server_name example.com;
    
    location /api/ {
        proxy_pass http://backend/;
        proxy_http_version 1.1;
        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;
        
        # 连接超时配置
        proxy_connect_timeout 5s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
        
        # 失败重试
        proxy_next_upstream error timeout http_500 http_502 http_503;
    }
    
    location / {
        root /usr/share/nginx/html;
        index index.html;
        try_files $uri $uri/ /index.html;
    }
}

30.3 Docker 容器化

30.3.1 Dockerfile 基础

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# Stage 1: 构建阶段
FROM node:20-alpine AS builder

WORKDIR /app

# 复制依赖文件
COPY package*.json ./

# 安装依赖
RUN npm ci --only=production

# 复制源码
COPY . .

# 构建
RUN npm run build

# Stage 2: 运行阶段
FROM nginx:alpine

# 复制构建产物
COPY --from=builder /app/dist /usr/share/nginx/html

# 复制 Nginx 配置
COPY nginx.conf /etc/nginx/conf.d/default.conf

# 暴露端口
EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# nginx.conf
server {
    listen 80;
    server_name localhost;
    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }

    # API 代理(如果有后端)
    location /api/ {
        proxy_pass http://backend:3000/;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

30.3.2 多阶段构建优化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 构建阶段
FROM node:20-alpine AS builder

WORKDIR /app

# 利用 Docker 缓存,只在 package.json 变化时重新安装依赖
COPY package*.json ./
RUN npm ci

# 复制源码
COPY . .

# 使用 npm 而非pnpm(Alpine 生态更完善)
# 构建生产版本
RUN npm run build -- --mode production

# 运行阶段 - 使用更小的镜像
FROM node:20-alpine AS runner

WORKDIR /app

# 复制构建产物
COPY --from=builder /app/dist ./dist

# 如果是 SSR 应用,还需要复制 node_modules
# COPY --from=builder /app/node_modules ./node_modules

ENV NODE_ENV=production

# 非 root 用户运行(更安全)
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 appuser
    
USER appuser

CMD ["node", "dist/server.js"]  # SSR 应用的启动命令

30.3.3 docker-compose 编排

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# docker-compose.yml
version: '3.8'

services:
  # 前端应用
  frontend:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "80:80"
    depends_on:
      - backend
    networks:
      - app-network

  # 后端 API
  backend:
    image: node:20-alpine
    working_dir: /app
    command: node server.js
    volumes:
      - ./server:/app
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
      - DATABASE_URL=postgresql://user:pass@db:5432/mydb
    depends_on:
      - db
    networks:
      - app-network

  # 数据库
  db:
    image: postgres:15-alpine
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=pass
      - POSTGRES_DB=mydb
    volumes:
      - postgres-data:/var/lib/postgresql/data
    networks:
      - app-network

networks:
  app-network:
    driver: bridge

volumes:
  postgres-data:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 启动所有服务
docker-compose up -d

# 查看日志
docker-compose logs -f

# 停止服务
docker-compose down

# 重新构建
docker-compose up -d --build

30.3.4 Nuxt 3 的 Docker 配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# 用于 Nuxt SSR 应用的 Dockerfile
FROM node:20-alpine AS builder

WORKDIR /app

# 安装依赖
COPY package*.json ./
RUN npm ci

# 复制源码
COPY . .

# 构建 Nuxt 应用
RUN npm run build

# 运行阶段
FROM node:20-alpine AS runner

WORKDIR /app

ENV NODE_ENV=production

# 安装 dumb-init(处理信号传递)
RUN apk add --no-cache dumb-init

# 创建非 root 用户
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nuxt

# 复制构建产物
COPY --from=builder --chown=nuxt:nodejs /app/.output /app/.output

USER nuxt

EXPOSE 3000

# 使用 dumb-init 正确处理 SIGTERM 信号
CMD ["dumb-init", "node", ".output/server/index.mjs"]

30.4 监控与性能

30.4.1 Sentry 错误监控

Sentry 是最流行的前端错误监控平台:

1
npm install @sentry/vue @sentry/tracing
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// main.ts 或 composables/useSentry.ts
import * as Sentry from '@sentry/vue'
import { BrowserTracing } from '@sentry/tracing'

export function initSentry(app: App) {
  Sentry.init({
    app,
    dsn: 'https://xxxxx@sentry.io/xxxxx',  // 你的 DSN
    
    // 采样率(0-1)
    tracesSampleRate: 0.1,
    
    // 采样错误
    sampleRate: 1.0,
    
    // 集成
    integrations: [
      new BrowserTracing({
        // 追踪页面性能
        routingInstrumentation: Sentry.vueRouterInstrumentation(router),
        // 追踪资源加载
        resourceTracking: true,
      }),
      new Sentry.Replay(),  // 录屏功能
    ],
    
    // 忽略的错误
    ignoreErrors: [
      'ResizeObserver loop',
      'Non-Error promise rejection',
    ],
    
    // beforeSend 可以在发送前修改错误数据
    beforeSend(event) {
      // 过滤敏感信息
      if (event.user) {
        delete event.user.email
        delete event.user.ip_address
      }
      return event
    },
  })
}
1
2
3
4
5
6
7
8
<script setup lang="ts">
import { initSentry } from '~/composables/useSentry'

// 在 setup 中初始化
onMounted(() => {
  initSentry(app)
})
</script>

30.4.2 性能监控(Web Vitals)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// composables/useWebVitals.ts
import { onMounted } from 'vue'

export function useWebVitals(callback: (metric: any) => void) {
  onMounted(() => {
    if ('PerformanceObserver' in window) {
      // LCP - 最大内容绘制
      const lcpObserver = new PerformanceObserver((list) => {
        const entries = list.getEntries()
        const lastEntry = entries[entries.length - 1] as any
        callback({
          name: 'LCP',
          value: lastEntry.renderTime || lastEntry.loadTime,
          rating: lastEntry.renderTime > 2500 ? 'poor' : 'good',
        })
      })
      lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true })
      
      // FID - 首次输入延迟
      const fidObserver = new PerformanceObserver((list) => {
        for (const entry of list.getEntries()) {
          callback({
            name: 'FID',
            value: entry.processingTime - entry.startTime,
            rating: entry.processingTime > 100 ? 'poor' : 'good',
          })
        }
      })
      fidObserver.observe({ type: 'first-input', buffered: true })
      
      // CLS - 累积布局偏移
      let clsValue = 0
      let clsEntries: any[] = []
      
      const clsObserver = new PerformanceObserver((list) => {
        for (const entry of list.getEntries()) {
          if (!(entry as any).hadRecentInput) {
            clsEntries.push(entry)
            clsValue += (entry as any).value
          }
        }
      })
      clsObserver.observe({ type: 'layout-shift', buffered: true })
      
      // 页面隐藏时报告 CLS
      document.addEventListener('visibilitychange', () => {
        if (document.visibilityState === 'hidden') {
          callback({
            name: 'CLS',
            value: clsValue,
            entries: clsEntries,
            rating: clsValue > 0.1 ? 'poor' : 'good',
          })
        }
      })
    }
  })
}

// 使用
// useWebVitals((metric) => {
//   // 上报到你的监控服务
//   console.log(metric)
// })

30.4.3 自定义埋点

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// utils/analytics.ts

// 页面访问
export function trackPageView(path: string) {
  // 发送到你的分析服务
  fetch('/api/analytics', {
    method: 'POST',
    body: JSON.stringify({
      event: 'page_view',
      path,
      timestamp: Date.now(),
      userAgent: navigator.userAgent,
    }),
  }).catch(() => {})  // 埋点失败不影响业务
}

// 点击事件
export function trackClick(category: string, action: string, label?: string) {
  fetch('/api/analytics', {
    method: 'POST',
    body: JSON.stringify({
      event: 'click',
      category,
      action,
      label,
      timestamp: Date.now(),
    }),
  }).catch(() => {})
}

// Vue 指令
export const track = {
  mounted(el: HTMLElement, binding: any) {
    el.addEventListener('click', () => {
      trackClick(
        binding.value.category,
        binding.value.action,
        binding.value.label
      )
    })
  },
}

// 使用
// <button v-track="{ category: 'button', action: 'click', label: '提交' }">

30.5 CI/CD 流水线

30.5.1 GitHub Actions 基础

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  # 测试 job
  test:
    name: Test
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Type check
        run: npm run type-check
      
      - name: Lint
        run: npm run lint
      
      - name: Unit tests
        run: npm run test:unit
      
      - name: E2E tests
        run: npm run test:e2e

  # 构建 job
  build:
    name: Build
    runs-on: ubuntu-latest
    needs: test
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Build
        run: npm run build
        env:
          NODE_ENV: production
      
      - name: Upload artifacts
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist
          retention-days: 7

  # 部署 job
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    needs: build
    if: github.ref == 'refs/heads/main'
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Download artifacts
        uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist
      
      - name: Deploy to server
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          script: |
            # 部署命令
            cd /var/www/vue-app
            docker-compose pull
            docker-compose up -d

30.5.2 完整的部署流水线

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# 完整的 CI/CD 配置
name: CI/CD Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

env:
  NODE_VERSION: '20'
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  lint-and-test:
    name: Lint & Test
    runs-on: ubuntu-latest
    
    services:
      # 如果需要数据库
      postgres:
        image: postgres:15
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: test
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Type check
        run: npm run type-check
      
      - name: Lint
        run: npm run lint
      
      - name: Unit tests with coverage
        run: npm run test:unit -- --coverage
        env:
          DATABASE_URL: postgresql://test:test@localhost:5432/test
      
      - name: E2E tests
        run: npm run test:e2e
        env:
          CYPRESS_BASE_URL: http://localhost:3000

  build-image:
    name: Build Docker Image
    runs-on: ubuntu-latest
    needs: lint-and-test
    if: github.event_name == 'push'
    
    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      
      - name: Log in to Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      
      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=ref,event=branch
            type=semver,pattern={{version}}
      
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy-staging:
    name: Deploy to Staging
    runs-on: ubuntu-latest
    needs: build-image
    if: github.ref == 'refs/heads/develop'
    
    environment:
      name: staging
      url: https://staging.example.com
    
    steps:
      - name: Deploy to staging server
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.STAGING_HOST }}
          username: ${{ secrets.STAGING_USER }}
          key: ${{ secrets.STAGING_SSH_KEY }}
          envs: IMAGE_TAG
          script: |
            # 拉取最新镜像
            docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
            
            # 更新 docker-compose
            sed -i 's|image:.*|image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}|' docker-compose.staging.yml
            
            # 重启服务
            docker-compose -f docker-compose.staging.yml up -d
            
            # 等待健康检查
            sleep 10

  deploy-production:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: build-image
    if: github.ref == 'refs/heads/main'
    
    environment:
      name: production
      url: https://example.com
    
    steps:
      - name: Deploy to production server
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.PRODUCTION_HOST }}
          username: ${{ secrets.PRODUCTION_USER }}
          key: ${{ secrets.PRODUCTION_SSH_KEY }}
          script: |
            # 部署流程(更谨慎)
            cd /var/www/vue-app
            
            # 备份当前版本
            docker-compose exec -T app tar czf /tmp/backup-$(date +%Y%m%d%H%M%S).tar.gz ./data
            
            # 滚动更新
            docker-compose pull
            docker-compose up -d --no-deps app
            
            # 健康检查
            for i in {1..30}; do
              if curl -sf http://localhost/health > /dev/null; then
                echo "Health check passed"
                exit 0
              fi
              echo "Waiting for health check... ($i/30)"
              sleep 2
            done
            
            echo "Health check failed, rolling back..."
            docker-compose pull backup
            docker-compose up -d --no-deps app
            exit 1

30.5.3 环境变量管理

1
2
3
4
5
6
7
# .env.staging
VITE_API_BASE=https://api.staging.example.com
VITE_SENTRY_DSN=https://xxx@staging.sentry.io/xxx

# .env.production
VITE_API_BASE=https://api.example.com
VITE_SENTRY_DSN=https://xxx@sentry.io/xxx
1
2
3
4
5
6
7
# .gitignore
.env
.env.local
.env.*.local

# 但保留示例文件
# !.env.example

30.5.4 GitHub Secrets 配置

在 GitHub 仓库的 Settings > Secrets and variables > Actions 中配置:

Secret 名称说明
SERVER_HOST服务器 IP 或域名
SERVER_USERSSH 用户名
SERVER_SSH_KEYSSH 私钥
STAGING_HOST预发布环境服务器
STAGING_USER预发布 SSH 用户
STAGING_SSH_KEY预发布 SSH 私钥
PRODUCTION_HOST生产环境服务器
PRODUCTION_USER生产 SSH 用户
PRODUCTION_SSH_KEY生产 SSH 私钥
DOCKER_USERNAMEDocker Hub 用户名
DOCKER_PASSWORDDocker Hub 密码

30.6 本章小结

本章我们学习了 Vue 3 应用部署的完整流程:

  1. 构建分析:使用 visualizer 分析打包体积,配置优化选项
  2. Nginx 配置:SPA 路由、Gzip 压缩、安全头、负载均衡
  3. Docker 容器化:多阶段构建、docker-compose 编排
  4. 监控与埋点:Sentry 错误监控、Web Vitals 性能监控、自定义埋点
  5. CI/CD 流水线:GitHub Actions 自动测试、构建、部署

部署是软件开发的最后一公里,也是最重要的一环。只有部署好了,用户才能真正用到你的应用。

下一章,也是本教程的最后一章,我们将深入 Vue 3 的源码,从宏观到微观,全面理解 Vue 3 的设计理念和实现细节。🔍