Skip to content

第69天:Element Plus 部署与生产环境优化

学习目标

  • 掌握 Element Plus 应用的部署策略和最佳实践
  • 学习生产环境的性能优化技巧
  • 了解 CDN、缓存和压缩等优化手段
  • 理解监控和错误追踪在生产环境中的重要性

知识点概览

1. 构建优化

1.1 Vite 生产构建配置

typescript
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import { visualizer } from 'rollup-plugin-visualizer'
import { compression } from 'vite-plugin-compression'
import { createHtmlPlugin } from 'vite-plugin-html'

export default defineConfig({
  plugins: [
    vue(),
    
    // HTML 模板处理
    createHtmlPlugin({
      minify: true,
      inject: {
        data: {
          title: 'Element Plus App',
          description: 'Production-ready Element Plus application'
        }
      }
    }),
    
    // Gzip 压缩
    compression({
      algorithm: 'gzip',
      ext: '.gz'
    }),
    
    // Brotli 压缩
    compression({
      algorithm: 'brotliCompress',
      ext: '.br'
    }),
    
    // 包分析
    visualizer({
      filename: 'dist/stats.html',
      open: true,
      gzipSize: true,
      brotliSize: true
    })
  ],
  
  // 构建配置
  build: {
    // 输出目录
    outDir: 'dist',
    
    // 静态资源目录
    assetsDir: 'assets',
    
    // 生成 source map
    sourcemap: process.env.NODE_ENV === 'development',
    
    // 代码分割阈值
    chunkSizeWarningLimit: 1000,
    
    // Rollup 配置
    rollupOptions: {
      input: {
        main: resolve(__dirname, 'index.html')
      },
      
      output: {
        // 手动分包
        manualChunks: {
          // Vue 相关
          vue: ['vue', 'vue-router', 'pinia'],
          
          // Element Plus
          'element-plus': ['element-plus'],
          
          // 图标
          'element-icons': ['@element-plus/icons-vue'],
          
          // 工具库
          utils: ['lodash-es', 'dayjs', 'axios'],
          
          // 图表库
          charts: ['echarts', 'vue-echarts']
        },
        
        // 文件命名
        chunkFileNames: 'assets/js/[name]-[hash].js',
        entryFileNames: 'assets/js/[name]-[hash].js',
        assetFileNames: (assetInfo) => {
          const info = assetInfo.name!.split('.')
          const ext = info[info.length - 1]
          
          if (/\.(mp4|webm|ogg|mp3|wav|flac|aac)$/.test(assetInfo.name!)) {
            return `assets/media/[name]-[hash].${ext}`
          }
          
          if (/\.(png|jpe?g|gif|svg|webp|avif)$/.test(assetInfo.name!)) {
            return `assets/images/[name]-[hash].${ext}`
          }
          
          if (/\.(woff2?|eot|ttf|otf)$/.test(assetInfo.name!)) {
            return `assets/fonts/[name]-[hash].${ext}`
          }
          
          return `assets/[ext]/[name]-[hash].${ext}`
        }
      },
      
      // 外部依赖(CDN)
      external: process.env.USE_CDN === 'true' ? [
        'vue',
        'vue-router',
        'pinia',
        'element-plus',
        '@element-plus/icons-vue'
      ] : []
    },
    
    // 压缩配置
    minify: 'terser',
    terserOptions: {
      compress: {
        // 移除 console
        drop_console: true,
        // 移除 debugger
        drop_debugger: true,
        // 移除无用代码
        dead_code: true,
        // 移除无用变量
        unused: true
      },
      mangle: {
        // 混淆变量名
        toplevel: true
      }
    }
  },
  
  // 依赖优化
  optimizeDeps: {
    include: [
      'vue',
      'vue-router',
      'pinia',
      'element-plus',
      '@element-plus/icons-vue',
      'axios',
      'dayjs'
    ]
  },
  
  // 服务器配置
  server: {
    port: 3000,
    open: true,
    cors: true
  },
  
  // 预览服务器配置
  preview: {
    port: 4173,
    open: true
  }
})

1.2 环境变量配置

bash
# .env.production
VITE_APP_TITLE=Element Plus App
VITE_APP_API_BASE_URL=https://api.example.com
VITE_APP_CDN_URL=https://cdn.example.com
VITE_APP_SENTRY_DSN=https://your-sentry-dsn
VITE_APP_ENABLE_ANALYTICS=true
VITE_APP_VERSION=1.0.0

# 是否使用 CDN
USE_CDN=true

# 是否启用 PWA
ENABLE_PWA=true
typescript
// src/config/env.ts
interface AppConfig {
  title: string
  apiBaseUrl: string
  cdnUrl: string
  sentryDsn: string
  enableAnalytics: boolean
  version: string
  isDevelopment: boolean
  isProduction: boolean
  isTest: boolean
}

class EnvironmentConfig {
  private static instance: EnvironmentConfig
  private config: AppConfig
  
  private constructor() {
    this.config = {
      title: import.meta.env.VITE_APP_TITLE || 'Element Plus App',
      apiBaseUrl: import.meta.env.VITE_APP_API_BASE_URL || 'http://localhost:3001',
      cdnUrl: import.meta.env.VITE_APP_CDN_URL || '',
      sentryDsn: import.meta.env.VITE_APP_SENTRY_DSN || '',
      enableAnalytics: import.meta.env.VITE_APP_ENABLE_ANALYTICS === 'true',
      version: import.meta.env.VITE_APP_VERSION || '1.0.0',
      isDevelopment: import.meta.env.DEV,
      isProduction: import.meta.env.PROD,
      isTest: import.meta.env.MODE === 'test'
    }
  }
  
  static getInstance(): EnvironmentConfig {
    if (!this.instance) {
      this.instance = new EnvironmentConfig()
    }
    return this.instance
  }
  
  getConfig(): AppConfig {
    return { ...this.config }
  }
  
  get<K extends keyof AppConfig>(key: K): AppConfig[K] {
    return this.config[key]
  }
  
  // 验证必需的环境变量
  validateRequiredEnvVars(): void {
    const required = ['VITE_APP_API_BASE_URL']
    const missing = required.filter(key => !import.meta.env[key])
    
    if (missing.length > 0) {
      throw new Error(`Missing required environment variables: ${missing.join(', ')}`)
    }
  }
}

export const envConfig = EnvironmentConfig.getInstance()
export type { AppConfig }

2. CDN 集成

2.1 CDN 资源配置

typescript
// src/utils/cdn.ts
interface CDNResource {
  name: string
  library: string
  js: string
  css?: string
}

class CDNManager {
  private static readonly CDN_RESOURCES: CDNResource[] = [
    {
      name: 'vue',
      library: 'Vue',
      js: 'https://unpkg.com/vue@3/dist/vue.global.prod.js'
    },
    {
      name: 'vue-router',
      library: 'VueRouter',
      js: 'https://unpkg.com/vue-router@4/dist/vue-router.global.prod.js'
    },
    {
      name: 'pinia',
      library: 'Pinia',
      js: 'https://unpkg.com/pinia@2/dist/pinia.iife.prod.js'
    },
    {
      name: 'element-plus',
      library: 'ElementPlus',
      js: 'https://unpkg.com/element-plus@2/dist/index.full.min.js',
      css: 'https://unpkg.com/element-plus@2/dist/index.css'
    },
    {
      name: '@element-plus/icons-vue',
      library: 'ElementPlusIconsVue',
      js: 'https://unpkg.com/@element-plus/icons-vue@2/dist/index.iife.min.js'
    }
  ]
  
  // 生成 HTML 中的 CDN 链接
  static generateCDNLinks(): { js: string[], css: string[] } {
    const js: string[] = []
    const css: string[] = []
    
    this.CDN_RESOURCES.forEach(resource => {
      js.push(resource.js)
      if (resource.css) {
        css.push(resource.css)
      }
    })
    
    return { js, css }
  }
  
  // 生成 Vite 外部依赖配置
  static generateExternals(): Record<string, string> {
    const externals: Record<string, string> = {}
    
    this.CDN_RESOURCES.forEach(resource => {
      externals[resource.name] = resource.library
    })
    
    return externals
  }
  
  // 动态加载 CDN 资源
  static async loadCDNResource(url: string, type: 'js' | 'css' = 'js'): Promise<void> {
    return new Promise((resolve, reject) => {
      if (type === 'js') {
        const script = document.createElement('script')
        script.src = url
        script.onload = () => resolve()
        script.onerror = () => reject(new Error(`Failed to load script: ${url}`))
        document.head.appendChild(script)
      } else {
        const link = document.createElement('link')
        link.rel = 'stylesheet'
        link.href = url
        link.onload = () => resolve()
        link.onerror = () => reject(new Error(`Failed to load stylesheet: ${url}`))
        document.head.appendChild(link)
      }
    })
  }
  
  // 预加载 CDN 资源
  static preloadCDNResources(): void {
    this.CDN_RESOURCES.forEach(resource => {
      // 预加载 JS
      const jsLink = document.createElement('link')
      jsLink.rel = 'preload'
      jsLink.as = 'script'
      jsLink.href = resource.js
      document.head.appendChild(jsLink)
      
      // 预加载 CSS
      if (resource.css) {
        const cssLink = document.createElement('link')
        cssLink.rel = 'preload'
        cssLink.as = 'style'
        cssLink.href = resource.css
        document.head.appendChild(cssLink)
      }
    })
  }
}

export { CDNManager }
export type { CDNResource }

2.2 HTML 模板配置

html
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title><%- title %></title>
  <meta name="description" content="<%- description %>" />
  
  <!-- DNS 预解析 -->
  <link rel="dns-prefetch" href="//unpkg.com" />
  <link rel="dns-prefetch" href="//cdn.jsdelivr.net" />
  
  <!-- 预连接 -->
  <link rel="preconnect" href="https://fonts.googleapis.com" />
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
  
  <!-- PWA 配置 -->
  <link rel="manifest" href="/manifest.json" />
  <meta name="theme-color" content="#409eff" />
  
  <!-- iOS PWA 配置 -->
  <meta name="apple-mobile-web-app-capable" content="yes" />
  <meta name="apple-mobile-web-app-status-bar-style" content="default" />
  <meta name="apple-mobile-web-app-title" content="<%- title %>" />
  <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
  
  <!-- 条件加载 CDN 资源 -->
  <% if (process.env.USE_CDN === 'true') { %>
    <!-- CSS CDN -->
    <link rel="stylesheet" href="https://unpkg.com/element-plus@2/dist/index.css" />
    
    <!-- JS CDN -->
    <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
    <script src="https://unpkg.com/vue-router@4/dist/vue-router.global.prod.js"></script>
    <script src="https://unpkg.com/pinia@2/dist/pinia.iife.prod.js"></script>
    <script src="https://unpkg.com/element-plus@2/dist/index.full.min.js"></script>
    <script src="https://unpkg.com/@element-plus/icons-vue@2/dist/index.iife.min.js"></script>
  <% } %>
  
  <!-- 关键 CSS 内联 -->
  <style>
    /* 加载动画 */
    .app-loading {
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background: #fff;
      display: flex;
      align-items: center;
      justify-content: center;
      z-index: 9999;
    }
    
    .loading-spinner {
      width: 40px;
      height: 40px;
      border: 4px solid #f3f3f3;
      border-top: 4px solid #409eff;
      border-radius: 50%;
      animation: spin 1s linear infinite;
    }
    
    @keyframes spin {
      0% { transform: rotate(0deg); }
      100% { transform: rotate(360deg); }
    }
  </style>
</head>
<body>
  <div id="app">
    <!-- 加载动画 -->
    <div class="app-loading">
      <div class="loading-spinner"></div>
    </div>
  </div>
  
  <!-- 错误边界 -->
  <script>
    window.addEventListener('error', function(event) {
      console.error('Global error:', event.error)
      // 发送错误到监控服务
    })
    
    window.addEventListener('unhandledrejection', function(event) {
      console.error('Unhandled promise rejection:', event.reason)
      // 发送错误到监控服务
    })
  </script>
  
  <script type="module" src="/src/main.ts"></script>
</body>
</html>

3. 缓存策略

3.1 HTTP 缓存配置

nginx
# nginx.conf
server {
    listen 80;
    server_name your-domain.com;
    root /var/www/html;
    index index.html;
    
    # Gzip 压缩
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_types
        text/plain
        text/css
        text/xml
        text/javascript
        application/json
        application/javascript
        application/xml+rss
        application/atom+xml
        image/svg+xml;
    
    # Brotli 压缩
    brotli on;
    brotli_comp_level 6;
    brotli_types
        text/plain
        text/css
        application/json
        application/javascript
        text/xml
        application/xml
        application/xml+rss
        text/javascript;
    
    # 静态资源缓存
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        add_header Vary "Accept-Encoding";
        
        # 启用 ETag
        etag on;
        
        # 预压缩文件
        location ~* \.(js|css)$ {
            gzip_static on;
            brotli_static on;
        }
    }
    
    # HTML 文件缓存
    location ~* \.html$ {
        expires 1h;
        add_header Cache-Control "public, must-revalidate";
        add_header Vary "Accept-Encoding";
    }
    
    # API 代理
    location /api/ {
        proxy_pass http://backend-server;
        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;
        
        # API 缓存
        proxy_cache api_cache;
        proxy_cache_valid 200 5m;
        proxy_cache_key $scheme$proxy_host$request_uri;
        add_header X-Cache-Status $upstream_cache_status;
    }
    
    # SPA 路由支持
    location / {
        try_files $uri $uri/ /index.html;
    }
    
    # 安全头
    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;
    add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://unpkg.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://api.example.com;" always;
}

3.2 Service Worker 缓存

typescript
// public/sw.js
const CACHE_NAME = 'element-plus-app-v1.0.0'
const STATIC_CACHE = 'static-cache-v1'
const DYNAMIC_CACHE = 'dynamic-cache-v1'

// 需要缓存的静态资源
const STATIC_ASSETS = [
  '/',
  '/index.html',
  '/manifest.json',
  // 添加其他静态资源
]

// 安装事件
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(STATIC_CACHE)
      .then((cache) => {
        console.log('Caching static assets')
        return cache.addAll(STATIC_ASSETS)
      })
      .then(() => {
        return self.skipWaiting()
      })
  )
})

// 激活事件
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys()
      .then((cacheNames) => {
        return Promise.all(
          cacheNames
            .filter((cacheName) => {
              return cacheName !== STATIC_CACHE && cacheName !== DYNAMIC_CACHE
            })
            .map((cacheName) => {
              console.log('Deleting old cache:', cacheName)
              return caches.delete(cacheName)
            })
        )
      })
      .then(() => {
        return self.clients.claim()
      })
  )
})

// 拦截请求
self.addEventListener('fetch', (event) => {
  const { request } = event
  const url = new URL(request.url)
  
  // 跳过非 GET 请求
  if (request.method !== 'GET') {
    return
  }
  
  // 跳过 Chrome 扩展请求
  if (url.protocol === 'chrome-extension:') {
    return
  }
  
  // API 请求策略:网络优先,缓存备用
  if (url.pathname.startsWith('/api/')) {
    event.respondWith(
      fetch(request)
        .then((response) => {
          // 缓存成功的 API 响应
          if (response.ok) {
            const responseClone = response.clone()
            caches.open(DYNAMIC_CACHE)
              .then((cache) => {
                cache.put(request, responseClone)
              })
          }
          return response
        })
        .catch(() => {
          // 网络失败时从缓存获取
          return caches.match(request)
        })
    )
    return
  }
  
  // 静态资源策略:缓存优先
  if (isStaticAsset(request.url)) {
    event.respondWith(
      caches.match(request)
        .then((response) => {
          if (response) {
            return response
          }
          
          return fetch(request)
            .then((response) => {
              if (response.ok) {
                const responseClone = response.clone()
                caches.open(STATIC_CACHE)
                  .then((cache) => {
                    cache.put(request, responseClone)
                  })
              }
              return response
            })
        })
    )
    return
  }
  
  // HTML 页面策略:网络优先,缓存备用
  event.respondWith(
    fetch(request)
      .then((response) => {
        if (response.ok) {
          const responseClone = response.clone()
          caches.open(DYNAMIC_CACHE)
            .then((cache) => {
              cache.put(request, responseClone)
            })
        }
        return response
      })
      .catch(() => {
        return caches.match(request)
          .then((response) => {
            return response || caches.match('/index.html')
          })
      })
  )
})

// 判断是否为静态资源
function isStaticAsset(url) {
  return /\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$/.test(url)
}

// 消息处理
self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    self.skipWaiting()
  }
})

4. 性能监控

4.1 性能指标收集

typescript
// src/utils/performance.ts
interface PerformanceMetrics {
  // Core Web Vitals
  fcp: number // First Contentful Paint
  lcp: number // Largest Contentful Paint
  fid: number // First Input Delay
  cls: number // Cumulative Layout Shift
  
  // 其他指标
  ttfb: number // Time to First Byte
  domContentLoaded: number
  loadComplete: number
  
  // 自定义指标
  appInitTime: number
  routeChangeTime: number
  apiResponseTime: number
}

class PerformanceMonitor {
  private metrics: Partial<PerformanceMetrics> = {}
  private observer: PerformanceObserver | null = null
  
  constructor() {
    this.initPerformanceObserver()
    this.collectNavigationMetrics()
  }
  
  private initPerformanceObserver(): void {
    if ('PerformanceObserver' in window) {
      this.observer = new PerformanceObserver((list) => {
        for (const entry of list.getEntries()) {
          this.handlePerformanceEntry(entry)
        }
      })
      
      // 观察不同类型的性能条目
      try {
        this.observer.observe({ entryTypes: ['paint', 'largest-contentful-paint', 'first-input', 'layout-shift'] })
      } catch (error) {
        console.warn('Performance observer not supported:', error)
      }
    }
  }
  
  private handlePerformanceEntry(entry: PerformanceEntry): void {
    switch (entry.entryType) {
      case 'paint':
        if (entry.name === 'first-contentful-paint') {
          this.metrics.fcp = entry.startTime
        }
        break
        
      case 'largest-contentful-paint':
        this.metrics.lcp = entry.startTime
        break
        
      case 'first-input':
        this.metrics.fid = (entry as PerformanceEventTiming).processingStart - entry.startTime
        break
        
      case 'layout-shift':
        if (!(entry as any).hadRecentInput) {
          this.metrics.cls = (this.metrics.cls || 0) + (entry as any).value
        }
        break
    }
  }
  
  private collectNavigationMetrics(): void {
    if ('performance' in window && 'getEntriesByType' in performance) {
      const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
      
      if (navigation) {
        this.metrics.ttfb = navigation.responseStart - navigation.requestStart
        this.metrics.domContentLoaded = navigation.domContentLoadedEventEnd - navigation.navigationStart
        this.metrics.loadComplete = navigation.loadEventEnd - navigation.navigationStart
      }
    }
  }
  
  // 记录应用初始化时间
  recordAppInitTime(startTime: number): void {
    this.metrics.appInitTime = performance.now() - startTime
  }
  
  // 记录路由切换时间
  recordRouteChangeTime(startTime: number): void {
    this.metrics.routeChangeTime = performance.now() - startTime
  }
  
  // 记录 API 响应时间
  recordApiResponseTime(url: string, duration: number): void {
    // 可以按 URL 分类记录
    console.log(`API ${url} response time: ${duration}ms`)
  }
  
  // 获取所有指标
  getMetrics(): Partial<PerformanceMetrics> {
    return { ...this.metrics }
  }
  
  // 发送指标到监控服务
  async sendMetrics(): Promise<void> {
    const metrics = this.getMetrics()
    
    try {
      await fetch('/api/metrics', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({
          metrics,
          timestamp: Date.now(),
          userAgent: navigator.userAgent,
          url: window.location.href
        })
      })
    } catch (error) {
      console.error('Failed to send metrics:', error)
    }
  }
  
  // 清理资源
  destroy(): void {
    if (this.observer) {
      this.observer.disconnect()
      this.observer = null
    }
  }
}

// 全局性能监控实例
export const performanceMonitor = new PerformanceMonitor()

// 页面可见性变化时发送指标
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    performanceMonitor.sendMetrics()
  }
})

// 页面卸载时发送指标
window.addEventListener('beforeunload', () => {
  performanceMonitor.sendMetrics()
})

4.2 错误监控

typescript
// src/utils/error-tracking.ts
import * as Sentry from '@sentry/vue'
import { App } from 'vue'
import { Router } from 'vue-router'
import { envConfig } from '@/config/env'

interface ErrorInfo {
  message: string
  stack?: string
  componentName?: string
  propsData?: any
  url: string
  userAgent: string
  timestamp: number
  userId?: string
}

class ErrorTracker {
  private static instance: ErrorTracker
  private errors: ErrorInfo[] = []
  private maxErrors = 100
  
  private constructor() {
    this.setupGlobalErrorHandlers()
  }
  
  static getInstance(): ErrorTracker {
    if (!this.instance) {
      this.instance = new ErrorTracker()
    }
    return this.instance
  }
  
  // 初始化 Sentry
  static initSentry(app: App, router: Router): void {
    if (envConfig.get('sentryDsn') && envConfig.get('isProduction')) {
      Sentry.init({
        app,
        dsn: envConfig.get('sentryDsn'),
        environment: envConfig.get('isProduction') ? 'production' : 'development',
        release: envConfig.get('version'),
        
        integrations: [
          new Sentry.BrowserTracing({
            router,
            routingInstrumentation: Sentry.vueRouterInstrumentation(router)
          })
        ],
        
        // 性能监控
        tracesSampleRate: 0.1,
        
        // 错误过滤
        beforeSend(event, hint) {
          // 过滤掉一些不重要的错误
          const error = hint.originalException
          
          if (error && typeof error === 'object') {
            // 过滤网络错误
            if ('message' in error && typeof error.message === 'string') {
              if (error.message.includes('Network Error') || 
                  error.message.includes('Failed to fetch')) {
                return null
              }
            }
          }
          
          return event
        },
        
        // 用户上下文
        initialScope: {
          tags: {
            component: 'element-plus-app'
          }
        }
      })
    }
  }
  
  private setupGlobalErrorHandlers(): void {
    // 捕获 JavaScript 错误
    window.addEventListener('error', (event) => {
      this.captureError({
        message: event.message,
        stack: event.error?.stack,
        url: window.location.href,
        userAgent: navigator.userAgent,
        timestamp: Date.now()
      })
    })
    
    // 捕获 Promise 拒绝
    window.addEventListener('unhandledrejection', (event) => {
      this.captureError({
        message: `Unhandled Promise Rejection: ${event.reason}`,
        stack: event.reason?.stack,
        url: window.location.href,
        userAgent: navigator.userAgent,
        timestamp: Date.now()
      })
    })
  }
  
  // 捕获错误
  captureError(errorInfo: Partial<ErrorInfo>): void {
    const error: ErrorInfo = {
      message: errorInfo.message || 'Unknown error',
      stack: errorInfo.stack,
      componentName: errorInfo.componentName,
      propsData: errorInfo.propsData,
      url: errorInfo.url || window.location.href,
      userAgent: errorInfo.userAgent || navigator.userAgent,
      timestamp: errorInfo.timestamp || Date.now(),
      userId: this.getCurrentUserId()
    }
    
    // 添加到本地错误列表
    this.errors.push(error)
    
    // 限制错误数量
    if (this.errors.length > this.maxErrors) {
      this.errors.shift()
    }
    
    // 发送到 Sentry
    if (envConfig.get('isProduction')) {
      Sentry.captureException(new Error(error.message), {
        extra: {
          componentName: error.componentName,
          propsData: error.propsData,
          stack: error.stack
        },
        user: {
          id: error.userId
        }
      })
    }
    
    // 发送到自定义错误服务
    this.sendErrorToService(error)
  }
  
  // Vue 错误处理器
  vueErrorHandler(err: Error, instance: any, info: string): void {
    this.captureError({
      message: err.message,
      stack: err.stack,
      componentName: instance?.$options.name || instance?.$options.__name,
      propsData: instance?.$props,
      url: window.location.href,
      userAgent: navigator.userAgent,
      timestamp: Date.now()
    })
  }
  
  private getCurrentUserId(): string | undefined {
    // 从用户状态或本地存储获取用户 ID
    return localStorage.getItem('userId') || undefined
  }
  
  private async sendErrorToService(error: ErrorInfo): Promise<void> {
    try {
      await fetch('/api/errors', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(error)
      })
    } catch (err) {
      console.error('Failed to send error to service:', err)
    }
  }
  
  // 获取错误列表
  getErrors(): ErrorInfo[] {
    return [...this.errors]
  }
  
  // 清除错误
  clearErrors(): void {
    this.errors = []
  }
}

export const errorTracker = ErrorTracker.getInstance()
export { ErrorTracker }

5. 部署自动化

5.1 Docker 配置

dockerfile
# Dockerfile
# 多阶段构建
FROM node:18-alpine AS builder

# 设置工作目录
WORKDIR /app

# 复制 package 文件
COPY package*.json pnpm-lock.yaml ./

# 安装 pnpm
RUN npm install -g pnpm

# 安装依赖
RUN pnpm install --frozen-lockfile

# 复制源代码
COPY . .

# 构建应用
RUN pnpm build

# 生产阶段
FROM nginx:alpine

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

# 复制 nginx 配置
COPY nginx.conf /etc/nginx/nginx.conf

# 暴露端口
EXPOSE 80

# 启动 nginx
CMD ["nginx", "-g", "daemon off;"]
yaml
# docker-compose.yml
version: '3.8'

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "80:80"
    environment:
      - NODE_ENV=production
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    restart: unless-stopped
    
  # 可选:添加 Redis 缓存
  redis:
    image: redis:alpine
    ports:
      - "6379:6379"
    restart: unless-stopped
    
  # 可选:添加监控
  prometheus:
    image: prom/prometheus
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
    restart: unless-stopped

5.2 CI/CD 流水线

yaml
# .github/workflows/deploy.yml
name: Deploy to Production

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

env:
  NODE_VERSION: '18'
  PNPM_VERSION: '8'

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
        
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: ${{ env.NODE_VERSION }}
          
      - name: Setup pnpm
        uses: pnpm/action-setup@v2
        with:
          version: ${{ env.PNPM_VERSION }}
          
      - name: Get pnpm store directory
        id: pnpm-cache
        shell: bash
        run: |
          echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
          
      - name: Setup pnpm cache
        uses: actions/cache@v3
        with:
          path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
          restore-keys: |
            ${{ runner.os }}-pnpm-store-
            
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
        
      - name: Run linting
        run: pnpm lint
        
      - name: Run type checking
        run: pnpm typecheck
        
      - name: Run tests
        run: pnpm test:coverage
        
      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v3
        with:
          file: ./coverage/lcov.info
          
  build:
    needs: test
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
        
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: ${{ env.NODE_VERSION }}
          
      - name: Setup pnpm
        uses: pnpm/action-setup@v2
        with:
          version: ${{ env.PNPM_VERSION }}
          
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
        
      - name: Build application
        run: pnpm build
        env:
          VITE_APP_API_BASE_URL: ${{ secrets.API_BASE_URL }}
          VITE_APP_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
          USE_CDN: true
          
      - name: Upload build artifacts
        uses: actions/upload-artifact@v3
        with:
          name: dist
          path: dist/
          
  deploy:
    needs: build
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
        
      - name: Download build artifacts
        uses: actions/download-artifact@v3
        with:
          name: dist
          path: dist/
          
      - name: Setup Docker Buildx
        uses: docker/setup-buildx-action@v2
        
      - name: Login to Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}
          
      - name: Build and push Docker image
        uses: docker/build-push-action@v4
        with:
          context: .
          push: true
          tags: |
            ${{ secrets.DOCKER_USERNAME }}/element-plus-app:latest
            ${{ secrets.DOCKER_USERNAME }}/element-plus-app:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          
      - name: Deploy to server
        uses: appleboy/ssh-action@v0.1.5
        with:
          host: ${{ secrets.HOST }}
          username: ${{ secrets.USERNAME }}
          key: ${{ secrets.SSH_KEY }}
          script: |
            docker pull ${{ secrets.DOCKER_USERNAME }}/element-plus-app:latest
            docker stop element-plus-app || true
            docker rm element-plus-app || true
            docker run -d \
              --name element-plus-app \
              -p 80:80 \
              --restart unless-stopped \
              ${{ secrets.DOCKER_USERNAME }}/element-plus-app:latest
              
      - name: Health check
        run: |
          sleep 30
          curl -f http://${{ secrets.HOST }} || exit 1

实践练习

练习 1:构建优化配置

  1. 配置 Vite 生产构建,实现代码分割和压缩
  2. 设置 CDN 资源加载
  3. 配置环境变量管理
  4. 分析打包产物大小

练习 2:缓存策略实现

  1. 配置 Nginx 静态资源缓存
  2. 实现 Service Worker 缓存策略
  3. 设置 API 响应缓存
  4. 测试缓存效果

练习 3:监控系统搭建

  1. 集成性能监控
  2. 设置错误追踪
  3. 配置日志收集
  4. 创建监控仪表板

学习资源

作业

  1. 完成所有实践练习
  2. 为你的 Element Plus 项目配置完整的部署流程
  3. 实现性能监控和错误追踪
  4. 编写部署文档和运维指南

下一步学习计划

接下来我们将学习 Element Plus 项目实战总结与最佳实践,回顾整个学习过程,总结最佳实践,并展望未来的发展方向。