第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:构建优化配置
- 配置 Vite 生产构建,实现代码分割和压缩
- 设置 CDN 资源加载
- 配置环境变量管理
- 分析打包产物大小
练习 2:缓存策略实现
- 配置 Nginx 静态资源缓存
- 实现 Service Worker 缓存策略
- 设置 API 响应缓存
- 测试缓存效果
练习 3:监控系统搭建
- 集成性能监控
- 设置错误追踪
- 配置日志收集
- 创建监控仪表板
学习资源
作业
- 完成所有实践练习
- 为你的 Element Plus 项目配置完整的部署流程
- 实现性能监控和错误追踪
- 编写部署文档和运维指南
下一步学习计划
接下来我们将学习 Element Plus 项目实战总结与最佳实践,回顾整个学习过程,总结最佳实践,并展望未来的发展方向。