Vue 3 构建优化与部署策略:从开发到生产的完整指南
Orion K Lv6

在现代前端开发中,构建优化和部署策略对应用性能和用户体验至关重要。本文将深入探讨 Vue 3 应用的构建优化技巧、部署最佳实践,以及如何实现高效的 CI/CD 流程。

Vite 构建优化配置

1. 基础构建配置

Vite 配置优化

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
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
// vite.config.ts
import { defineConfig, loadEnv } 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'
import legacy from '@vitejs/plugin-legacy'

export default defineConfig(({ command, mode }) => {
// 加载环境变量
const env = loadEnv(mode, process.cwd(), '')

return {
plugins: [
vue(),

// HTML 模板处理
createHtmlPlugin({
minify: command === 'build',
inject: {
data: {
title: env.VITE_APP_TITLE || 'Vue 3 App',
description: env.VITE_APP_DESCRIPTION || 'A Vue 3 application'
}
}
}),

// 传统浏览器兼容性
legacy({
targets: ['defaults', 'not IE 11']
}),

// Gzip 压缩
compression({
algorithm: 'gzip',
ext: '.gz'
}),

// Brotli 压缩
compression({
algorithm: 'brotliCompress',
ext: '.br'
}),

// 构建分析
mode === 'analyze' && visualizer({
filename: 'dist/stats.html',
open: true,
gzipSize: true,
brotliSize: true
})
].filter(Boolean),

// 路径别名
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'@components': resolve(__dirname, 'src/components'),
'@utils': resolve(__dirname, 'src/utils'),
'@stores': resolve(__dirname, 'src/stores'),
'@assets': resolve(__dirname, 'src/assets')
}
},

// CSS 配置
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "@/styles/variables.scss";`
}
},
// CSS 代码分割
codeSplit: true
},

// 构建配置
build: {
// 输出目录
outDir: 'dist',

// 静态资源目录
assetsDir: 'assets',

// 生成 sourcemap
sourcemap: mode === 'development',

// 压缩配置
minify: 'terser',
terserOptions: {
compress: {
// 移除 console
drop_console: mode === 'production',
// 移除 debugger
drop_debugger: true,
// 移除无用代码
pure_funcs: ['console.log', 'console.info']
},
format: {
// 移除注释
comments: false
}
},

// Rollup 配置
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html')
},
output: {
// 代码分割
manualChunks: {
// 第三方库分离
vendor: ['vue', 'vue-router', 'pinia'],
// UI 库分离
ui: ['element-plus', '@element-plus/icons-vue'],
// 工具库分离
utils: ['lodash-es', 'dayjs', 'axios']
},
// 文件命名
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)(\?.*)?$/i.test(assetInfo.name)) {
return `assets/media/[name]-[hash].${ext}`
}
if (/\.(png|jpe?g|gif|svg|webp|avif)(\?.*)?$/i.test(assetInfo.name)) {
return `assets/images/[name]-[hash].${ext}`
}
if (/\.(woff2?|eot|ttf|otf)(\?.*)?$/i.test(assetInfo.name)) {
return `assets/fonts/[name]-[hash].${ext}`
}
return `assets/[ext]/[name]-[hash].${ext}`
}
},
// 外部依赖
external: mode === 'library' ? ['vue'] : []
},

// 构建目标
target: 'es2015',

// 资源内联阈值
assetsInlineLimit: 4096,

// CSS 代码分割
cssCodeSplit: true,

// 清空输出目录
emptyOutDir: true
},

// 开发服务器配置
server: {
host: '0.0.0.0',
port: 3000,
open: true,
cors: true,
proxy: {
'/api': {
target: env.VITE_API_BASE_URL,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
},

// 预览服务器配置
preview: {
host: '0.0.0.0',
port: 4173,
cors: true
},

// 依赖优化
optimizeDeps: {
include: [
'vue',
'vue-router',
'pinia',
'axios',
'lodash-es'
],
exclude: [
'vue-demi'
]
},

// 环境变量
define: {
__VUE_OPTIONS_API__: true,
__VUE_PROD_DEVTOOLS__: false,
__APP_VERSION__: JSON.stringify(process.env.npm_package_version)
}
}
})

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
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'

// 路由懒加载函数
const lazyLoad = (view: string) => {
return () => import(`@/views/${view}.vue`)
}

// 带错误处理的懒加载
const lazyLoadWithErrorHandling = (view: string) => {
return () => {
return import(`@/views/${view}.vue`).catch(error => {
console.error(`Failed to load view: ${view}`, error)
// 返回错误页面组件
return import('@/views/ErrorPage.vue')
})
}
}

// 预加载函数
const preloadRoute = (view: string) => {
const componentImport = () => import(`@/views/${view}.vue`)
// 预加载但不立即执行
if (typeof window !== 'undefined') {
requestIdleCallback(() => {
componentImport()
})
}
return componentImport
}

const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'Home',
component: () => import('@/views/HomePage.vue'),
meta: {
title: '首页',
preload: true // 标记需要预加载
}
},
{
path: '/dashboard',
name: 'Dashboard',
component: lazyLoadWithErrorHandling('DashboardPage'),
meta: {
title: '仪表板',
requiresAuth: true
}
},
{
path: '/products',
name: 'Products',
component: () => import(
/* webpackChunkName: "products" */
'@/views/ProductsPage.vue'
),
children: [
{
path: ':id',
name: 'ProductDetail',
component: () => import(
/* webpackChunkName: "product-detail" */
'@/views/ProductDetailPage.vue'
)
}
]
},
{
path: '/admin',
name: 'Admin',
component: () => import(
/* webpackChunkName: "admin" */
'@/layouts/AdminLayout.vue'
),
children: [
{
path: 'users',
component: () => import(
/* webpackChunkName: "admin-users" */
'@/views/admin/UsersPage.vue'
)
},
{
path: 'settings',
component: () => import(
/* webpackChunkName: "admin-settings" */
'@/views/admin/SettingsPage.vue'
)
}
]
}
]

const router = createRouter({
history: createWebHistory(),
routes
})

// 路由预加载
router.beforeEach((to, from, next) => {
// 预加载下一个可能访问的路由
if (to.meta?.preload) {
const preloadRoutes = getPreloadRoutes(to.name as string)
preloadRoutes.forEach(routeName => {
const route = routes.find(r => r.name === routeName)
if (route?.component) {
;(route.component as Function)()
}
})
}
next()
})

// 获取需要预加载的路由
function getPreloadRoutes(currentRoute: string): string[] {
const preloadMap: Record<string, string[]> = {
'Home': ['Dashboard', 'Products'],
'Dashboard': ['Products'],
'Products': ['ProductDetail']
}
return preloadMap[currentRoute] || []
}

export default router

组件级代码分割

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
// src/components/LazyComponent.vue
<template>
<div class="lazy-component">
<Suspense>
<template #default>
<AsyncComponent v-if="shouldLoad" v-bind="$attrs" />
</template>
<template #fallback>
<div class="loading-skeleton">
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
</template>
</Suspense>
</div>
</template>

<script setup lang="ts">
import { ref, onMounted, defineAsyncComponent } from 'vue'

interface Props {
componentName: string
delay?: number
timeout?: number
retryDelay?: number
maxRetries?: number
}

const props = withDefaults(defineProps<Props>(), {
delay: 200,
timeout: 10000,
retryDelay: 1000,
maxRetries: 3
})

const shouldLoad = ref(false)
const retryCount = ref(0)

// 动态导入组件
const AsyncComponent = defineAsyncComponent({
loader: () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
import(`@/components/${props.componentName}.vue`)
.then(resolve)
.catch(error => {
console.error(`Failed to load component: ${props.componentName}`, error)

// 重试机制
if (retryCount.value < props.maxRetries) {
retryCount.value++
setTimeout(() => {
import(`@/components/${props.componentName}.vue`)
.then(resolve)
.catch(reject)
}, props.retryDelay)
} else {
reject(error)
}
})
}, props.delay)
})
},

loadingComponent: {
template: '<div class="component-loading">加载中...</div>'
},

errorComponent: {
template: '<div class="component-error">组件加载失败</div>'
},

delay: props.delay,
timeout: props.timeout
})

// 使用 Intersection Observer 实现可视区域加载
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
shouldLoad.value = true
observer.disconnect()
}
})
},
{
rootMargin: '50px'
}
)

onMounted(() => {
const element = document.querySelector('.lazy-component')
if (element) {
observer.observe(element)
}
})
</script>

<style scoped>
.loading-skeleton {
padding: 20px;
}

.skeleton-line {
height: 20px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
margin-bottom: 10px;
border-radius: 4px;
}

.skeleton-line.short {
width: 60%;
}

@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}

.component-loading,
.component-error {
padding: 20px;
text-align: center;
color: #666;
}

.component-error {
color: #f56565;
}
</style>

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
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
// src/composables/useImageOptimization.ts
import { ref, onMounted } from 'vue'

/**
* 图片优化 Hook
*/
export function useImageOptimization() {
const supportsWebP = ref(false)
const supportsAVIF = ref(false)

// 检测浏览器支持的图片格式
const checkImageSupport = async () => {
// 检测 WebP 支持
const webpCanvas = document.createElement('canvas')
webpCanvas.width = 1
webpCanvas.height = 1
supportsWebP.value = webpCanvas.toDataURL('image/webp').indexOf('data:image/webp') === 0

// 检测 AVIF 支持
try {
const avifImage = new Image()
avifImage.src = 'data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAAB0AAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAIAAAACAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQ0MAAAAABNjb2xybmNseAACAAIAAYAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAACVtZGF0EgAKCBgABogQEAwgMg8f8D///8WfhwB8+ErK42A='
await new Promise((resolve, reject) => {
avifImage.onload = resolve
avifImage.onerror = reject
setTimeout(reject, 100)
})
supportsAVIF.value = true
} catch {
supportsAVIF.value = false
}
}

/**
* 获取最优图片格式
*/
const getOptimalImageSrc = (baseSrc: string, options: {
width?: number
height?: number
quality?: number
} = {}) => {
const { width, height, quality = 80 } = options

// 构建查询参数
const params = new URLSearchParams()
if (width) params.set('w', width.toString())
if (height) params.set('h', height.toString())
params.set('q', quality.toString())

// 根据浏览器支持选择格式
if (supportsAVIF.value) {
params.set('f', 'avif')
} else if (supportsWebP.value) {
params.set('f', 'webp')
}

return `${baseSrc}?${params.toString()}`
}

/**
* 生成响应式图片源集
*/
const generateSrcSet = (baseSrc: string, sizes: number[]) => {
return sizes
.map(size => {
const src = getOptimalImageSrc(baseSrc, { width: size })
return `${src} ${size}w`
})
.join(', ')
}

/**
* 预加载关键图片
*/
const preloadImage = (src: string, priority: 'high' | 'low' = 'low') => {
const link = document.createElement('link')
link.rel = 'preload'
link.as = 'image'
link.href = src
if (priority === 'high') {
link.setAttribute('fetchpriority', 'high')
}
document.head.appendChild(link)
}

onMounted(() => {
checkImageSupport()
})

return {
supportsWebP,
supportsAVIF,
getOptimalImageSrc,
generateSrcSet,
preloadImage
}
}

字体优化

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
/* src/styles/fonts.css */

/* 字体预加载 */
@font-face {
font-family: 'CustomFont';
src: url('@/assets/fonts/custom-font.woff2') format('woff2'),
url('@/assets/fonts/custom-font.woff') format('woff');
font-weight: 400;
font-style: normal;
font-display: swap; /* 字体交换策略 */
}

/* 字体子集化 */
@font-face {
font-family: 'CustomFont-Latin';
src: url('@/assets/fonts/custom-font-latin.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
font-family: 'CustomFont-Chinese';
src: url('@/assets/fonts/custom-font-chinese.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
unicode-range: U+4E00-9FFF;
}

/* 字体回退策略 */
.font-primary {
font-family: 'CustomFont', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
}

/* 字体加载优化 */
.font-loading {
font-family: system-ui, -apple-system, sans-serif;
visibility: hidden;
}

.font-loaded .font-loading {
font-family: 'CustomFont', system-ui, -apple-system, sans-serif;
visibility: visible;
}

性能监控与分析

1. 构建分析工具

Bundle 分析器

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
// scripts/analyze-bundle.ts
import { build } from 'vite'
import { visualizer } from 'rollup-plugin-visualizer'
import { resolve } from 'path'

/**
* 构建分析脚本
*/
async function analyzeBuild() {
console.log('🔍 开始构建分析...')

try {
await build({
configFile: resolve(__dirname, '../vite.config.ts'),
mode: 'production',
plugins: [
visualizer({
filename: 'dist/bundle-analysis.html',
open: true,
gzipSize: true,
brotliSize: true,
template: 'treemap' // 'treemap' | 'sunburst' | 'network'
})
]
})

console.log('✅ 构建分析完成!')
console.log('📊 分析报告已生成:dist/bundle-analysis.html')

// 生成性能报告
await generatePerformanceReport()

} catch (error) {
console.error('❌ 构建分析失败:', error)
process.exit(1)
}
}

/**
* 生成性能报告
*/
async function generatePerformanceReport() {
const fs = await import('fs/promises')
const path = await import('path')

const distPath = resolve(__dirname, '../dist')
const files = await fs.readdir(distPath, { recursive: true })

const report = {
timestamp: new Date().toISOString(),
totalFiles: 0,
totalSize: 0,
jsFiles: [],
cssFiles: [],
imageFiles: [],
recommendations: []
}

for (const file of files) {
if (typeof file !== 'string') continue

const filePath = path.resolve(distPath, file)
const stats = await fs.stat(filePath)

if (stats.isFile()) {
report.totalFiles++
report.totalSize += stats.size

const ext = path.extname(file)
const sizeKB = Math.round(stats.size / 1024)

const fileInfo = {
name: file,
size: stats.size,
sizeKB,
sizeMB: Math.round(stats.size / 1024 / 1024 * 100) / 100
}

if (ext === '.js') {
report.jsFiles.push(fileInfo)
if (sizeKB > 500) {
report.recommendations.push(`⚠️ JS文件 ${file} 过大 (${sizeKB}KB),建议进行代码分割`)
}
} else if (ext === '.css') {
report.cssFiles.push(fileInfo)
if (sizeKB > 100) {
report.recommendations.push(`⚠️ CSS文件 ${file} 过大 (${sizeKB}KB),建议优化样式`)
}
} else if (['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.avif'].includes(ext)) {
report.imageFiles.push(fileInfo)
if (sizeKB > 200) {
report.recommendations.push(`⚠️ 图片文件 ${file} 过大 (${sizeKB}KB),建议压缩或使用现代格式`)
}
}
}
}

// 排序文件(按大小降序)
report.jsFiles.sort((a, b) => b.size - a.size)
report.cssFiles.sort((a, b) => b.size - a.size)
report.imageFiles.sort((a, b) => b.size - a.size)

// 生成报告
const reportContent = `
# 构建性能报告

生成时间: ${report.timestamp}

## 总览
- 总文件数: ${report.totalFiles}
- 总大小: ${Math.round(report.totalSize / 1024 / 1024 * 100) / 100} MB

## JavaScript 文件 (${report.jsFiles.length})
${report.jsFiles.map(f => `- ${f.name}: ${f.sizeKB} KB`).join('\n')}

## CSS 文件 (${report.cssFiles.length})
${report.cssFiles.map(f => `- ${f.name}: ${f.sizeKB} KB`).join('\n')}

## 图片文件 (${report.imageFiles.length})
${report.imageFiles.map(f => `- ${f.name}: ${f.sizeKB} KB`).join('\n')}

## 优化建议
${report.recommendations.join('\n')}
`

await fs.writeFile(
resolve(distPath, 'performance-report.md'),
reportContent,
'utf-8'
)

console.log('📋 性能报告已生成:dist/performance-report.md')
}

if (require.main === module) {
analyzeBuild()
}

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
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
// src/utils/performance.ts

/**
* 性能监控工具
*/
export class PerformanceMonitor {
private static instance: PerformanceMonitor
private metrics: Map<string, number> = new Map()
private observers: PerformanceObserver[] = []

static getInstance(): PerformanceMonitor {
if (!this.instance) {
this.instance = new PerformanceMonitor()
}
return this.instance
}

/**
* 初始化性能监控
*/
init() {
this.observeNavigationTiming()
this.observeResourceTiming()
this.observeLargestContentfulPaint()
this.observeFirstInputDelay()
this.observeCumulativeLayoutShift()
}

/**
* 监控导航时间
*/
private observeNavigationTiming() {
if ('performance' in window && 'getEntriesByType' in performance) {
const navigationEntries = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[]

if (navigationEntries.length > 0) {
const entry = navigationEntries[0]

this.metrics.set('DNS查询时间', entry.domainLookupEnd - entry.domainLookupStart)
this.metrics.set('TCP连接时间', entry.connectEnd - entry.connectStart)
this.metrics.set('SSL握手时间', entry.connectEnd - entry.secureConnectionStart)
this.metrics.set('首字节时间(TTFB)', entry.responseStart - entry.requestStart)
this.metrics.set('DOM解析时间', entry.domContentLoadedEventEnd - entry.domContentLoadedEventStart)
this.metrics.set('页面加载时间', entry.loadEventEnd - entry.loadEventStart)
this.metrics.set('总加载时间', entry.loadEventEnd - entry.navigationStart)
}
}
}

/**
* 监控资源加载时间
*/
private observeResourceTiming() {
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (entry.entryType === 'resource') {
const resourceEntry = entry as PerformanceResourceTiming
const loadTime = resourceEntry.responseEnd - resourceEntry.startTime

// 记录慢资源
if (loadTime > 1000) {
console.warn(`慢资源加载: ${resourceEntry.name} - ${Math.round(loadTime)}ms`)
}
}
})
})

observer.observe({ entryTypes: ['resource'] })
this.observers.push(observer)
}

/**
* 监控最大内容绘制 (LCP)
*/
private observeLargestContentfulPaint() {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries()
const lastEntry = entries[entries.length - 1]

this.metrics.set('LCP', lastEntry.startTime)

// LCP 超过 2.5s 认为需要优化
if (lastEntry.startTime > 2500) {
console.warn(`LCP 过慢: ${Math.round(lastEntry.startTime)}ms`)
}
})

observer.observe({ entryTypes: ['largest-contentful-paint'] })
this.observers.push(observer)
}

/**
* 监控首次输入延迟 (FID)
*/
private observeFirstInputDelay() {
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
const fidEntry = entry as PerformanceEventTiming
const fid = fidEntry.processingStart - fidEntry.startTime

this.metrics.set('FID', fid)

// FID 超过 100ms 认为需要优化
if (fid > 100) {
console.warn(`FID 过慢: ${Math.round(fid)}ms`)
}
})
})

observer.observe({ entryTypes: ['first-input'] })
this.observers.push(observer)
}

/**
* 监控累积布局偏移 (CLS)
*/
private observeCumulativeLayoutShift() {
let clsValue = 0

const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
const layoutShiftEntry = entry as any
if (!layoutShiftEntry.hadRecentInput) {
clsValue += layoutShiftEntry.value
}
})

this.metrics.set('CLS', clsValue)

// CLS 超过 0.1 认为需要优化
if (clsValue > 0.1) {
console.warn(`CLS 过高: ${clsValue.toFixed(3)}`)
}
})

observer.observe({ entryTypes: ['layout-shift'] })
this.observers.push(observer)
}

/**
* 测量自定义指标
*/
measure(name: string, startMark?: string, endMark?: string) {
if ('performance' in window && 'measure' in performance) {
try {
performance.measure(name, startMark, endMark)
const measures = performance.getEntriesByName(name, 'measure')
if (measures.length > 0) {
const duration = measures[measures.length - 1].duration
this.metrics.set(name, duration)
return duration
}
} catch (error) {
console.error('性能测量失败:', error)
}
}
return 0
}

/**
* 标记时间点
*/
mark(name: string) {
if ('performance' in window && 'mark' in performance) {
performance.mark(name)
}
}

/**
* 获取所有指标
*/
getMetrics() {
return Object.fromEntries(this.metrics)
}

/**
* 上报性能数据
*/
async reportMetrics(endpoint: string) {
const metrics = this.getMetrics()

try {
await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: window.location.href,
userAgent: navigator.userAgent,
timestamp: Date.now(),
metrics
})
})
} catch (error) {
console.error('性能数据上报失败:', error)
}
}

/**
* 清理监控器
*/
destroy() {
this.observers.forEach(observer => observer.disconnect())
this.observers = []
this.metrics.clear()
}
}

// 导出单例
export const performanceMonitor = PerformanceMonitor.getInstance()

部署策略与配置

1. Docker 容器化部署

多阶段构建 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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# Dockerfile
# 构建阶段
FROM node:18-alpine AS builder

# 设置工作目录
WORKDIR /app

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

# 安装 pnpm
RUN npm install -g pnpm

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

# 复制源代码
COPY . .

# 构建应用
RUN pnpm run build

# 生产阶段
FROM nginx:alpine AS production

# 安装必要工具
RUN apk add --no-cache curl

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

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

# 创建日志目录
RUN mkdir -p /var/log/nginx

# 设置权限
RUN chown -R nginx:nginx /usr/share/nginx/html
RUN chown -R nginx:nginx /var/log/nginx

# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost/ || exit 1

# 暴露端口
EXPOSE 80

# 启动 Nginx
CMD ["nginx", "-g", "daemon off;"]

Nginx 配置

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
# nginx.conf
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;

events {
worker_connections 1024;
use epoll;
multi_accept on;
}

http {
include /etc/nginx/mime.types;
default_type application/octet-stream;

# 日志格式
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" '
'$request_time $upstream_response_time';

access_log /var/log/nginx/access.log main;

# 基础配置
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 20M;

# Gzip 压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
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;

include /etc/nginx/conf.d/*.conf;
}
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
# default.conf
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index 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'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:; media-src 'self'; object-src 'none'; child-src 'none'; frame-src 'none'; worker-src 'self'; frame-ancestors 'none'; form-action 'self'; base-uri 'self';" always;

# 缓存策略
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";

# 预压缩文件支持
location ~* \.(js|css)$ {
gzip_static on;
# brotli_static on;
}
}

# HTML 文件缓存
location ~* \.html$ {
expires 1h;
add_header Cache-Control "public, must-revalidate";
}

# API 代理
location /api/ {
proxy_pass http://backend:3000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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_cache_bypass $http_upgrade;

# 超时设置
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 30s;
}

# SPA 路由支持
location / {
try_files $uri $uri/ /index.html;
}

# 健康检查
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}

# 禁止访问隐藏文件
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}

# 错误页面
error_page 404 /index.html;
error_page 500 502 503 504 /50x.html;

location = /50x.html {
root /usr/share/nginx/html;
}
}

2. CI/CD 流水线

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
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
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
# .github/workflows/deploy.yml
name: Build and Deploy

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

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

jobs:
# 代码质量检查
quality:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
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: Lint
run: pnpm run lint

- name: Type check
run: pnpm run type-check

- name: Unit tests
run: pnpm run test:unit

- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: ./coverage/lcov.info

# 构建
build:
needs: quality
runs-on: ubuntu-latest
strategy:
matrix:
environment: [staging, production]
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
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 for ${{ matrix.environment }}
run: pnpm run build:${{ matrix.environment }}
env:
VITE_APP_ENV: ${{ matrix.environment }}
VITE_API_BASE_URL: ${{ secrets[format('API_BASE_URL_{0}', upper(matrix.environment))] }}

- name: Run bundle analysis
run: pnpm run analyze

- name: Upload build artifacts
uses: actions/upload-artifact@v3
with:
name: dist-${{ matrix.environment }}
path: dist/
retention-days: 7

- name: Upload bundle analysis
uses: actions/upload-artifact@v3
with:
name: bundle-analysis-${{ matrix.environment }}
path: |
dist/bundle-analysis.html
dist/performance-report.md
retention-days: 30

# E2E 测试
e2e:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
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: Download build artifacts
uses: actions/download-artifact@v3
with:
name: dist-staging
path: dist/

- name: Install Playwright
run: pnpm exec playwright install --with-deps

- name: Run E2E tests
run: pnpm run test:e2e

- name: Upload E2E results
uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30

# Docker 构建和推送
docker:
needs: [quality, build]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}

- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKER_USERNAME }}/vue-app
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}

- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

# 部署到 Staging
deploy-staging:
needs: [e2e, docker]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/develop'
environment:
name: staging
url: https://staging.example.com
steps:
- name: Deploy to staging
run: |
echo "Deploying to staging environment..."
# 这里添加实际的部署脚本

# 部署到 Production
deploy-production:
needs: [e2e, docker]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
environment:
name: production
url: https://example.com
steps:
- name: Deploy to production
run: |
echo "Deploying to production environment..."
# 这里添加实际的部署脚本

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
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
// src/config/env.ts

/**
* 环境配置类型
*/
export interface EnvConfig {
APP_ENV: 'development' | 'staging' | 'production'
API_BASE_URL: string
APP_TITLE: string
APP_DESCRIPTION: string
ENABLE_MOCK: boolean
ENABLE_DEVTOOLS: boolean
SENTRY_DSN?: string
GA_TRACKING_ID?: string
CDN_BASE_URL?: string
}

/**
* 获取环境配置
*/
function getEnvConfig(): EnvConfig {
return {
APP_ENV: (import.meta.env.VITE_APP_ENV as EnvConfig['APP_ENV']) || 'development',
API_BASE_URL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000',
APP_TITLE: import.meta.env.VITE_APP_TITLE || 'Vue 3 App',
APP_DESCRIPTION: import.meta.env.VITE_APP_DESCRIPTION || 'A Vue 3 application',
ENABLE_MOCK: import.meta.env.VITE_ENABLE_MOCK === 'true',
ENABLE_DEVTOOLS: import.meta.env.VITE_ENABLE_DEVTOOLS === 'true',
SENTRY_DSN: import.meta.env.VITE_SENTRY_DSN,
GA_TRACKING_ID: import.meta.env.VITE_GA_TRACKING_ID,
CDN_BASE_URL: import.meta.env.VITE_CDN_BASE_URL
}
}

/**
* 验证环境配置
*/
function validateEnvConfig(config: EnvConfig): void {
const requiredFields: (keyof EnvConfig)[] = [
'APP_ENV',
'API_BASE_URL',
'APP_TITLE'
]

for (const field of requiredFields) {
if (!config[field]) {
throw new Error(`Missing required environment variable: ${field}`)
}
}

// 验证 API_BASE_URL 格式
try {
new URL(config.API_BASE_URL)
} catch {
throw new Error(`Invalid API_BASE_URL: ${config.API_BASE_URL}`)
}
}

// 导出配置
export const envConfig = getEnvConfig()

// 开发环境下验证配置
if (import.meta.env.DEV) {
try {
validateEnvConfig(envConfig)
console.log('✅ 环境配置验证通过', envConfig)
} catch (error) {
console.error('❌ 环境配置验证失败:', error)
}
}

多环境配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# .env.development
VITE_APP_ENV=development
VITE_API_BASE_URL=http://localhost:3000
VITE_APP_TITLE=Vue 3 App (开发)
VITE_ENABLE_MOCK=true
VITE_ENABLE_DEVTOOLS=true

# .env.staging
VITE_APP_ENV=staging
VITE_API_BASE_URL=https://api-staging.example.com
VITE_APP_TITLE=Vue 3 App (测试)
VITE_ENABLE_MOCK=false
VITE_ENABLE_DEVTOOLS=true
VITE_SENTRY_DSN=https://xxx@sentry.io/xxx

# .env.production
VITE_APP_ENV=production
VITE_API_BASE_URL=https://api.example.com
VITE_APP_TITLE=Vue 3 App
VITE_ENABLE_MOCK=false
VITE_ENABLE_DEVTOOLS=false
VITE_SENTRY_DSN=https://xxx@sentry.io/xxx
VITE_GA_TRACKING_ID=G-XXXXXXXXXX
VITE_CDN_BASE_URL=https://cdn.example.com

总结

Vue 3 的构建优化和部署策略需要从多个维度进行考虑:

  1. 构建优化:通过 Vite 配置、代码分割、资源优化等手段提升构建效率和产物质量
  2. 性能监控:建立完善的性能监控体系,及时发现和解决性能问题
  3. 容器化部署:使用 Docker 实现标准化部署,提高部署效率和一致性
  4. CI/CD 流程:建立自动化的持续集成和部署流程,确保代码质量和部署安全
  5. 环境管理:合理配置多环境,确保不同环境的隔离和配置正确性

通过这些最佳实践,可以构建一个高效、稳定、可维护的 Vue 3 应用部署体系。

本站由 提供部署服务