生产环境极致优化:拆包、图片压缩、Gzip/Brotli 完全指南
前言
当我们的应用从开发环境走向生产环境,真正的挑战才刚刚开始。用户不会关心我们的代码写得多么优雅,他们只关心页面加载快不快、交互流不流畅。一个未经优化的生产构建,可能让我们的用户在第一秒就流失。
为什么要优化生产构建?
一个真实的反面教材
我们先来看一个系统打包后的产物:
dist/
├── index.html 5KB
├── assets/index.abc123.js 2.8MB ← 一个文件包含了所有代码
├── assets/vendor.def456.js 1.2MB ← 第三方库
├── assets/style.ghi789.css 180KB
└── images/
├── logo.png 120KB ← 未压缩
├── banner.jpg 850KB ← 巨大
└── ...
当用户访问这个系统时:
- 下载 2.8MB + 1.2MB + 180KB + 970KB = 约 5MB
- 4G 网络下需要 2 秒;3G 网络会更慢
- 用户早跑了
构建优化的核心目标
| 优化维度 | 目标 | 收益 |
|---|---|---|
| 拆包优化 | 分离业务代码和第三方库 | 利用浏览器缓存,二次访问提速 |
| 图片压缩 | 减少图片体积 | 平均减少 60-80% 体积 |
| Gzip/Brotli | 压缩文本资源 | 减少 70-90% 传输体积 |
| 长期缓存 | 文件名哈希,内容变化才更新 | 最大化缓存利用率 |
优化能带来什么?
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 首屏 JS 体积 | 4.2 MB | 2.1 MB | 50% |
| 图片总体积 | 2.8 MB | 0.6 MB | 78% |
| 传输体积(Gzip后) | 3.2 MB | 0.8 MB | 75% |
| 首次加载时间 | 3.2 秒 | 1.1 秒 | 65% |
| 二次加载时间 | 2.1 秒 | 0.3 秒 | 85% |
先诊断,后开药 - 构建分析工具
为什么要先分析?
就像医生看病要先做检查一样,优化构建也要先找到问题在哪。在主观上,我们可能会觉得是不是某个依赖太大了?但实际上可能是另一个我们没想到的库!
使用 rollup-plugin-visualizer 分析
安装
npm install --save-dev rollup-plugin-visualizer
配置
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'
export default {
plugins: [
visualizer({
filename: 'dist/stats.html', // 输出文件
open: true, // 构建后自动打开
gzipSize: true, // 显示 gzip 后大小
brotliSize: true, // 显示 brotli 后大小
template: 'treemap' // 图表类型: treemap, sunburst, network
})
]
}
运行构建
npm run build
// 浏览器会自动打开一个酷炫的图表
// 一眼就能看出哪些文件最大
使用 vite-bundle-visualizer 分析
安装
npm install --save-dev vite-bundle-visualizer
运行分析
npx vite-bundle-visualizer
输出示例
┌───────────────────────┬─────────────┬──────────┬───────┐
│ Module │ Size │ Gzip │ Brotli│
├───────────────────────┼─────────────┼──────────┼───────┤
│ node_modules/ │ 2.3 MB │ 680 KB │ 520 KB│
│ vue/ │ 680 KB │ 210 KB │ 160 KB│
│ element-plus/ │ 890 KB │ 280 KB │ 210 KB│
│ echarts/ │ 520 KB │ 150 KB │ 115 KB│
│ lodash-es/ │ 210 KB │ 62 KB │ 48 KB │
│ src/ │ 1.8 MB │ 480 KB │ 360 KB│
└───────────────────────┴─────────────┴──────────┴───────┘
自定义分析脚本
// scripts/analyze.js
import fs from 'fs'
import path from 'path'
import { gzipSizeSync } from 'gzip-size'
import { brotliSizeSync } from 'brotli-size'
function analyzeDist() {
const distDir = path.resolve('./dist/assets')
const files = fs.readdirSync(distDir)
let totalSize = 0
let totalGzip = 0
let totalBrotli = 0
console.log('📦 构建产物分析\n')
files
.filter(f => f.endsWith('.js') || f.endsWith('.css'))
.forEach(file => {
const filePath = path.join(distDir, file)
const content = fs.readFileSync(filePath)
const size = content.length
const gzip = gzipSizeSync(content)
const brotli = brotliSizeSync(content)
totalSize += size
totalGzip += gzip
totalBrotli += brotli
console.log(`${file}:`)
console.log(` Raw: ${(size / 1024).toFixed(2)} KB`)
console.log(` Gzip: ${(gzip / 1024).toFixed(2)} KB (${(gzip/size*100).toFixed(0)}%)`)
console.log(` Brotli: ${(brotli / 1024).toFixed(2)} KB (${(brotli/size*100).toFixed(0)}%)\n`)
})
console.log('📊 总计:')
console.log(` Raw: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
console.log(` Gzip: ${(totalGzip / 1024 / 1024).toFixed(2)} MB`)
console.log(` Brotli: ${(totalBrotli / 1024 / 1024).toFixed(2)} MB`)
}
analyzeDist()
看懂分析结果
分析结果能告诉我们什么?
1. 找出最大的依赖
- echarts: 520KB → 考虑按需加载
- monaco-editor: 2.8MB → 考虑动态导入
2. 找出重复的依赖
- lodash 和 lodash-es 同时存在? → 统一用 lodash-es
- moment 和 dayjs 同时存在? → 用 dayjs 替代 moment
3. 找出可以拆分的点
- node_modules 打包在一起太大了 → 拆成多个 chunk
- 所有页面代码都在一个文件里 → 按路由拆分
拆包策略 - 把大象放进冰箱
为什么要拆包?
用一个比喻来解释
不拆包:把所有东西都塞进一个行李箱
├─ 想拿牙刷 → 要翻遍整个箱子
├─ 箱子破了 → 所有东西都掉出来
└─ 箱子太大 → 搬不动
拆包:分成多个小包
├─ 洗漱包:牙刷、牙膏、毛巾
├─ 衣物包:衣服、裤子、袜子
├─ 电子包:充电器、数据线
├─ 哪个包破了 → 只损失那部分
└─ 每个包都很轻 → 好搬
技术层面的好处
不拆包:
├─ 修改一行代码 → 整个大文件缓存失效
└─ 用户每次更新都要重新下载所有代码
拆包后:
├─ 第三方库独立 → 几乎不变,长期缓存
├─ 业务代码拆分 → 只下载修改的部分
└─ 多个小文件可以并行下载
基础拆包配置
// vite.config.ts
export default {
build: {
rollupOptions: {
output: {
// 最基本的拆包策略
manualChunks: {
// 将 Vue 全家桶打包在一起
'vendor-vue': ['vue', 'vue-router', 'pinia', 'vuex'],
// 将 UI 库打包在一起
'vendor-ui': ['element-plus', '@element-plus/icons-vue', 'ant-design-vue'],
// 将工具库打包在一起
'vendor-utils': ['lodash-es', 'dayjs', 'axios', 'date-fns'],
// 将图表库打包在一起
'vendor-charts': ['echarts', 'd3', 'chart.js']
}
}
}
}
}
智能拆包:根据依赖关系自动拆分
// vite.config.ts
export default {
build: {
rollupOptions: {
output: {
manualChunks(id: string) {
// node_modules 中的依赖
if (id.includes('node_modules')) {
// 按包名拆分
if (id.includes('vue')) {
return 'vendor-vue' // 所有 vue 相关
}
if (id.includes('element-plus') || id.includes('antd')) {
return 'vendor-ui' // UI 库
}
if (id.includes('echarts') || id.includes('d3')) {
return 'vendor-charts' // 图表库
}
if (id.includes('lodash') || id.includes('dayjs')) {
return 'vendor-utils' // 工具库
}
if (id.includes('monaco-editor')) {
return 'vendor-monaco' // 编辑器单独打包
}
// 其他依赖打包在一起
return 'vendor-other'
}
// 业务代码按页面拆分
if (id.includes('/src/views/')) {
const match = id.match(/\/src\/views\/([^\/]+)/)
if (match) {
return `page-${match[1]}` // 按页面拆分
}
}
// 公共组件按模块拆分
if (id.includes('/src/components/')) {
const match = id.match(/\/src\/components\/([^\/]+)/)
if (match) {
return `components-${match[1]}`
}
}
}
}
}
}
}
高级拆包:基于大小的自动拆分
// vite.config.ts
export default {
build: {
rollupOptions: {
output: {
manualChunks(id: string, { getModuleInfo }) {
// 如果模块大于 500KB,单独拆包
const moduleInfo = getModuleInfo(id)
if (moduleInfo && moduleInfo.code) {
const size = Buffer.byteLength(moduleInfo.code, 'utf8')
if (size > 500 * 1024) { // 500KB
const name = id.match(/[^/]+\.(js|ts|vue)$/)?.[0]
return `large-${name}` // 大文件单独打包
}
}
// 继续其他拆分逻辑
if (id.includes('node_modules')) {
if (id.includes('vue')) return 'vendor-vue'
if (id.includes('element-plus')) return 'vendor-ui'
}
}
}
}
}
}
异步 chunk 的命名优化
// vite.config.ts
export default {
build: {
rollupOptions: {
output: {
// 异步 chunk 命名
chunkFileNames: 'assets/chunks/[name]-[hash].js',
// 入口文件命名
entryFileNames: 'assets/[name]-[hash].js',
// 资源文件命名
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
manualChunks: {
// ... 拆包配置
}
}
}
}
}
// 输出结果:
// assets/index-abc123.js (入口)
// assets/chunks/vendor-vue-def456.js (Vue 相关)
// assets/chunks/page-dashboard-ghi789.js (页面)
// assets/images/logo-jkl012.png (图片)
拆包后的效果
| 拆包方式 | 文件数量 | 缓存利用率 | 适用场景 |
|---|---|---|---|
| 不拆包 | 1个 | 极低 | 小项目 |
| 按依赖拆分 | 5-10个 | 高 | 中大型项目 |
| 按页面拆分 | 10-50个 | 较高 | 多页面应用 |
| 按大小拆分 | 可变 | 中等 | 有大文件的项目 |
图片压缩 - 看不见的优化
为什么图片是优化重点?
我们先来看一个典型的页面资源分布:
const pageResources = {
js: '2.8MB (40%)',
css: '180KB (3%)',
images: '3.5MB (50%)', // 图片占了一半!
fonts: '500KB (7%)'
}
在页面中,图片通常占页面总体积的 50-70%,因此优化图片是最容易见效的!
vite-plugin-image-optimizer 配置
安装
npm install --save-dev vite-plugin-image-optimizer
配置
// vite.config.ts
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
export default {
plugins: [
ViteImageOptimizer({
// 配置文件类型和压缩参数
png: {
quality: 80, // PNG 质量 0-100
compressionLevel: 9, // 压缩级别 0-9
},
jpeg: {
quality: 75, // JPEG 质量
progressive: true, // 渐进式 JPEG
},
jpg: {
quality: 75,
},
webp: {
quality: 75, // WebP 质量
lossless: false, // 是否无损
},
avif: {
quality: 60, // AVIF 质量
lossless: false,
},
svg: {
// SVG 优化选项
plugins: [
{
name: 'preset-default',
params: {
overrides: {
removeViewBox: false, // 保留 viewBox
cleanupIds: false, // 保留 ID
},
},
},
],
},
tiff: {
quality: 70,
},
gif: {
optimizationLevel: 3, // 优化级别 1-3
},
})
]
}
不同图片类型的优化策略
// vite.config.ts
export default {
plugins: [
ViteImageOptimizer({
// 根据不同用途设置不同参数
// 1. 图标类:需要清晰,适当压缩
'src/assets/icons/**/*': {
png: { quality: 90 },
svg: { plugins: ['preset-default'] }
},
// 2. 背景图:可以牺牲一些质量换取体积
'src/assets/backgrounds/**/*': {
jpeg: { quality: 65 },
webp: { quality: 60 }
},
// 3. 产品图:平衡质量和体积
'src/assets/products/**/*': {
jpeg: { quality: 80 },
webp: { quality: 75 }
},
// 4. 用户上传:保持较好质量
'src/assets/uploads/**/*': {
jpeg: { quality: 85 },
png: { quality: 85 }
}
})
]
}
使用现代图片格式
配置
// vite.config.ts
export default {
plugins: [
ViteImageOptimizer({
// 生成 WebP 版本(浏览器支持更好)
webp: {
quality: 75
},
// 生成 AVIF 版本(压缩率更高)
avif: {
quality: 60
}
})
]
}
在组件中配合使用
<template>
<!-- picture 元素让浏览器选择最佳格式 -->
<picture>
<!-- 现代浏览器优先使用 AVIF -->
<source srcset="/image.avif" type="image/avif">
<!-- 其次使用 WebP -->
<source srcset="/image.webp" type="image/webp">
<!-- 降级到 JPEG -->
<img src="/image.jpg" alt="图片" loading="lazy">
</picture>
</template>
懒加载与图片优化结合
<template>
<img
v-lazy="optimizedImageUrl"
:data-srcset="`
${smallImage} 400w,
${mediumImage} 800w,
${largeImage} 1200w
`"
sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
loading="lazy"
:alt="alt"
>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps<{
imagePath: string,
alt?: string
}>()
// 根据视图宽度选择合适大小的图片
const optimizedImageUrl = computed(() => {
// 假设构建时生成了不同尺寸的图片
// logo-small.jpg, logo-medium.jpg, logo-large.jpg
const width = typeof window !== 'undefined' ? window.innerWidth : 1200
if (width < 600) {
return props.imagePath.replace(/\.(jpg|png)$/, '-small.$1')
}
if (width < 1200) {
return props.imagePath.replace(/\.(jpg|png)$/, '-medium.$1')
}
return props.imagePath.replace(/\.(jpg|png)$/, '-large.$1')
})
</script>
图片优化的效果
| 图片类型 | 优化前 | 优化后 | 节省 |
|---|---|---|---|
| PNG 图标 | 120KB | 35KB | 71% |
| JPG 产品图 | 850KB | 180KB | 79% |
| WebP 背景 | 650KB | 110KB | 83% |
| SVG 矢量 | 15KB | 8KB | 47% |
| 总体积 | 2.8MB | 0.6MB | 78% |
Gzip/Brotli 压缩 - 让传输更轻盈
什么是 Gzip/Brotli?
我们可以用快递来比喻,比如我们有一件很大的“羽绒服”要邮寄给浏览器:
- 原始文件:一件羽绒服(很大,但很轻)
- Gzip:真空压缩袋,把羽绒服压扁
- Brotli:更好的真空压缩袋,压得更扁
当浏览器收到压缩后的文件,它只需要打开压缩袋,羽绒服(文件)就可以恢复原状!
压缩算法的对比
| 算法 | 压缩率 | 压缩速度 | 解压速度 | 浏览器支持 |
|---|---|---|---|---|
| Gzip | 中等 | 快 | 快 | 所有浏览器 |
| Brotli | 高 | 慢 | 中等 | 现代浏览器 (92%) |
| Deflate | 低 | 极快 | 极快 | 所有浏览器 |
相同文件对比
- 原始 JS: 1000 KB
- Gzip: 280 KB (72% 减少)
- Brotli: 220 KB (78% 减少)
- Brotli 比 Gzip 再减少 21% 体积
使用 vite-plugin-compression 配置
安装
npm install --save-dev vite-plugin-compression
配置
// vite.config.ts
import compression from 'vite-plugin-compression'
export default {
plugins: [
// Gzip 压缩
compression({
algorithm: 'gzip',
ext: '.gz',
threshold: 10240, // 10KB 以上才压缩
deleteOriginFile: false, // 保留原文件
verbose: true, // 输出压缩信息
filter: /\.(js|css|html|svg)$/ // 只压缩文本文件
}),
// Brotli 压缩
compression({
algorithm: 'brotliCompress',
ext: '.br',
threshold: 10240,
deleteOriginFile: false,
verbose: true,
filter: /\.(js|css|html|svg)$/
})
]
}
// 构建结果:
// index.abc123.js
// index.abc123.js.gz (Gzip)
// index.abc123.js.br (Brotli)
智能压缩策略 - 多算法混合策略
// vite.config.ts
import compression from 'vite-plugin-compression'
export default {
plugins: [
// 对不同的资源使用不同的策略
// 1. HTML: 使用 Brotli(最高压缩率)
compression({
algorithm: 'brotliCompress',
ext: '.br',
filter: /\.html$/,
threshold: 1024
}),
// 2. JS/CSS: 同时生成 Gzip 和 Brotli
compression({
algorithm: 'gzip',
ext: '.gz',
filter: /\.(js|css)$/,
threshold: 10240
}),
compression({
algorithm: 'brotliCompress',
ext: '.br',
filter: /\.(js|css)$/,
threshold: 10240
}),
// 3. 大文件用 Brotli,小文件用 Gzip
compression({
algorithm: 'brotliCompress',
ext: '.br',
filter: /\.(js|css)$/,
threshold: 51200 // 50KB 以上用 Brotli
}),
compression({
algorithm: 'gzip',
ext: '.gz',
filter: /\.(js|css)$/,
threshold: 10240, // 10-50KB 用 Gzip
deleteOriginFile: true // 小文件可以删除原文件
})
]
}
Nginx 配置示例
# nginx.conf
server {
listen 80;
server_name example.com;
root /usr/share/nginx/html;
# 开启 Gzip
gzip on;
gzip_vary on;
gzip_min_length 10240;
gzip_types text/plain text/css text/xml text/javascript
application/javascript application/x-javascript
application/xml application/json;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
# Brotli 支持(需要编译 brotli 模块)
brotli on;
brotli_min_length 10240;
brotli_types text/plain text/css text/xml text/javascript
application/javascript application/x-javascript
application/xml application/json;
brotli_comp_level 6;
location / {
try_files $uri $uri/ /index.html;
# 尝试 Brotli,然后是 Gzip,最后是原始文件
location ~* \.(js|css)$ {
try_files $uri.br $uri.gz $uri =404;
# 根据 Accept-Encoding 设置正确的 Content-Encoding
if ($http_accept_encoding ~* br) {
add_header Content-Encoding br;
add_header Content-Type $content_type;
}
if ($http_accept_encoding ~* gzip) {
add_header Content-Encoding gzip;
add_header Content-Type $content_type;
}
# 长期缓存
expires 1y;
add_header Cache-Control "public, immutable";
add_header Vary Accept-Encoding;
}
# 图片缓存
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|avif)$ {
expires 30d;
add_header Cache-Control "public";
}
}
}
验证压缩效果
# 使用 curl 验证压缩
# 查看是否支持压缩
curl -H "Accept-Encoding: gzip, br" -I https://example.com/app.js
# 响应头应该包含
Content-Encoding: br
Content-Type: application/javascript
Content-Length: 220000
# 下载并解压验证
curl -H "Accept-Encoding: br" https://example.com/app.js | brotli -d
# 或者使用 httpie
http https://example.com/app.js Accept-Encoding:br
长期缓存策略:让缓存最大化
文件名哈希的原理
// 构建后的文件名
// index.[hash].js
// 哈希是基于文件内容生成的
// 内容不变 → 哈希不变 → 缓存有效
// 内容变化 → 哈希变化 → 重新下载
dist/
├── index.abc123.js // 哈希基于内容生成
├── index.def456.js // 内容变化,哈希变化
├── vendor-vue.123abc.js // 第三方库几乎不变
└── vendor-ui.456def.js // UI 库偶尔更新
配置文件名哈希
// vite.config.ts
export default {
build: {
rollupOptions: {
output: {
// 入口文件
entryFileNames: 'assets/[name].[hash].js',
// 异步 chunk
chunkFileNames: 'assets/chunks/[name].[hash].js',
// 资源文件
assetFileNames: 'assets/[ext]/[name].[hash].[ext]',
manualChunks: {
// 稳定的第三方库单独打包(几乎不变)
'vendor-stable': [
'vue',
'vue-router',
'pinia',
'vuex'
],
// 可能更新的 UI 库单独打包
'vendor-ui': [
'element-plus',
'@element-plus/icons-vue',
'ant-design-vue'
],
// 可能更新的工具库
'vendor-utils': [
'lodash-es',
'dayjs',
'axios'
]
}
}
},
// 生成 manifest.json
manifest: true
}
}
Nginx 缓存配置
# nginx.conf
server {
# 静态资源缓存配置
# JS/CSS 长期缓存(带 hash 的文件)
location ~* \.(js|css)$ {
# 匹配带 hash 的文件
if ($uri ~* "\.[a-f0-9]{8,20}\.(js|css)$") {
expires 1y;
add_header Cache-Control "public, immutable";
}
# 如果不带 hash,短时间缓存
expires 1h;
add_header Cache-Control "public";
# 尝试压缩版本
try_files $uri.br $uri.gz $uri =404;
add_header Vary Accept-Encoding;
}
# 图片等资源
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|avif)$ {
expires 30d;
add_header Cache-Control "public";
}
# 字体文件
location ~* \.(woff2?|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header Access-Control-Allow-Origin "*";
}
# HTML 文件不缓存
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-cache, must-revalidate";
}
}
Service Worker 缓存策略
// sw.js
const CACHE_NAME = 'v1'
const CACHE_URLS = [
'/',
'/index.html',
'/manifest.json'
]
// 安装时缓存核心资源
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => cache.addAll(CACHE_URLS))
)
})
// 缓存策略:缓存优先,网络回退
self.addEventListener('fetch', event => {
const url = new URL(event.request.url)
// 静态资源使用 Cache First 策略
if (url.pathname.match(/\.(js|css|png|jpg|webp)$/)) {
event.respondWith(
caches.match(event.request)
.then(response => {
// 缓存命中直接返回
if (response) return response
// 未命中则请求网络并缓存
return fetch(event.request).then(response => {
const clone = response.clone()
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, clone)
})
return response
})
})
)
}
// HTML 使用 Network First 策略
else if (url.pathname.endsWith('.html') || url.pathname === '/') {
event.respondWith(
fetch(event.request)
.then(response => {
const clone = response.clone()
caches.open(CACHE_NAME).then(cache => {
cache.put(event.request, clone)
})
return response
})
.catch(() => caches.match(event.request))
)
}
})
缓存命中率的提升
| 文件类型 | 更新频率 | 缓存策略 | 命中率 |
|---|---|---|---|
| vendor-vue.js | 几乎不变 | 永久缓存 | 99% |
| vendor-ui.js | 偶尔更新 | 永久缓存 | 92% |
| page-*.js | 经常更新 | 永久缓存 | 65% |
| 图片 | 很少更新 | 30天缓存 | 95% |
| 字体 | 从不更新 | 永久缓存 | 99% |
实战案例:一个中大型项目的构建优化
优化前的状态
// 项目信息
// - 页面数量:45 个
// - 组件数量:850 个
// - 第三方依赖:230 个
// - 图片数量:1200 张
// 构建产物
dist/ 总大小: 45 MB
├── js/ 28 MB
├── css/ 2.5 MB
├── images/ 14 MB
└── others/ 0.5 MB
// 性能指标
// - 构建时间:3 分 45 秒
// - 首屏体积:4.2 MB
// - 加载时间:3.2 秒
优化步骤
第一步:分析找出问题
# 运行分析
npx vite-bundle-visualizer
# 发现问题
echarts: 1.2MB ← 太大
monaco-editor: 2.8MB ← 巨大!
lodash-es: 210KB ← 还好
moment: 450KB ← 可以用 dayjs 替代
第二步:优化拆包
// vite.config.js
export default {
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
// 把 echarts 单独打包
if (id.includes('echarts')) {
return 'vendor-echarts'
}
// 把 monaco-editor 单独打包
if (id.includes('monaco-editor')) {
return 'vendor-monaco'
}
// 其他分组
if (id.includes('vue')) return 'vendor-vue'
if (id.includes('element-plus')) return 'vendor-ui'
if (id.includes('lodash') || id.includes('dayjs')) {
return 'vendor-utils'
}
return 'vendor-other'
}
// 按页面拆分
if (id.includes('/src/views/')) {
const match = id.match(/\/src\/views\/([^\/]+)/)
if (match) return `page-${match[1]}`
}
}
}
}
}
}
第三步:图片压缩
// vite.config.js
export default {
plugins: [
ViteImageOptimizer({
png: { quality: 75 },
jpeg: { quality: 70 },
webp: { quality: 70 },
avif: { quality: 60 }
})
]
}
第四步:开启压缩
// vite.config.js
export default {
plugins: [
compression({
algorithm: 'brotliCompress',
threshold: 10240
})
]
}
第五步:按需加载
// 大组件使用动态导入
const MonacoEditor = defineAsyncComponent(() =>
import('monaco-editor')
)
// 路由懒加载
const routes = [
{
path: '/dashboard',
component: () => import('./views/Dashboard.vue') // 按需加载
}
]
优化后的结果
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 构建时间 | 3 分 45 秒 | 2 分 20 秒 | 38% |
| 总大小 | 45 MB | 18 MB | 60% |
| 首屏 JS 体积 | 4.2 MB | 1.8 MB | 57% |
| 图片体积 | 14 MB | 3.5 MB | 75% |
| 传输体积 | 3.2 MB | 0.8 MB | 75% |
| 加载时间 | 3.2 秒 | 1.1 秒 | 65% |
常见问题与解决方案
问题一:拆包过多导致请求数爆炸
// ❌ 错误:拆得太细
manualChunks(id) {
// 每个依赖都单独打包
return id.match(/node_modules\/([^\/]+)/)?.[1]
}
// 结果:产生 200+ 个文件,HTTP/1.1 下性能差
// ✅ 正确:合理分组
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('vue')) return 'vendor-vue'
if (id.includes('lodash')) return 'vendor-utils'
if (id.includes('echarts')) return 'vendor-charts'
if (id.includes('monaco')) return 'vendor-monaco'
return 'vendor-other' // 其他合并
}
}
问题二:图片压缩后质量下降
// 解决方案:选择性压缩
ViteImageOptimizer({
// 图标保留较高品质
'src/assets/icons/**/*': {
png: { quality: 90 },
svg: { plugins: ['preset-default'] }
},
// 背景图可以接受较低品质
'src/assets/backgrounds/**/*': {
jpeg: { quality: 65 },
webp: { quality: 60 }
},
// 产品图需要平衡
'src/assets/products/**/*': {
jpeg: { quality: 80 },
webp: { quality: 75 }
}
})
// 或者使用图片 CDN 动态处理
<img src="https://cdn.example.com/image.jpg?x-oss-process=image/resize,w_400/quality,q_80">
问题三:Brotli 压缩太慢
// ✅ 解决方案:选择性使用 Brotli
compression({
algorithm: 'brotliCompress',
threshold: 50000, // 50KB 以上才用 Brotli
filter: /\.(js|css)$/
})
// 小文件继续用 Gzip
compression({
algorithm: 'gzip',
threshold: 10240, // 10-50KB 用 Gzip
filter: /\.(js|css)$/
})
问题四:CDN 不支持 Brotli
# ✅ 解决方案:同时生成 Gzip 和 Brotli
location /assets {
# 优先尝试 Brotli
try_files $uri.br $uri.gz $uri =404;
# 根据 Accept-Encoding 返回正确的 Content-Encoding
if ($http_accept_encoding ~* br) {
add_header Content-Encoding br;
}
if ($http_accept_encoding ~* gzip) {
add_header Content-Encoding gzip;
}
}
生产环境优化的最佳实践
优化检查清单
- 使用
visualizer分析构建产物 - 配置
manualChunks合理拆包 - 图片资源压缩优化
- 启用 Gzip/Brotli 压缩
- 配置长期缓存策略
- 设置性能预算
- 在 CI/CD 中集成检查
- 定期监控 Web Vitals
配置文件模板
// vite.config.ts - 生产环境优化完整配置
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { visualizer } from 'rollup-plugin-visualizer'
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
import compression from 'vite-plugin-compression'
export default defineConfig(({ mode }) => ({
plugins: [
vue(),
// 图片压缩
ViteImageOptimizer({
png: { quality: 75 },
jpeg: { quality: 70 },
webp: { quality: 70 },
avif: { quality: 60 }
}),
// Gzip 压缩
compression({
algorithm: 'gzip',
ext: '.gz',
threshold: 10240
}),
// Brotli 压缩
compression({
algorithm: 'brotliCompress',
ext: '.br',
threshold: 10240
}),
// 构建分析(只在需要时开启)
process.env.ANALYZE && visualizer({
open: true,
filename: 'dist/stats.html',
gzipSize: true,
brotliSize: true
})
].filter(Boolean),
build: {
target: 'es2015',
minify: 'terser',
terserOptions: {
compress: {
drop_console: mode === 'production',
drop_debugger: true
}
},
rollupOptions: {
output: {
entryFileNames: 'assets/[name].[hash].js',
chunkFileNames: 'assets/chunks/[name].[hash].js',
assetFileNames: 'assets/[ext]/[name].[hash].[ext]',
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('vue')) return 'vendor-vue'
if (id.includes('element-plus') || id.includes('antd')) {
return 'vendor-ui'
}
if (id.includes('echarts') || id.includes('d3')) {
return 'vendor-charts'
}
if (id.includes('lodash') || id.includes('dayjs')) {
return 'vendor-utils'
}
if (id.includes('monaco-editor')) {
return 'vendor-monaco'
}
return 'vendor-other'
}
if (id.includes('/src/views/')) {
const match = id.match(/\/src\/views\/([^\/]+)/)
if (match) return `page-${match[1]}`
}
}
}
},
chunkSizeWarningLimit: 500,
sourcemap: mode !== 'production',
manifest: true
}
}))
性能目标参考
| 指标 | 优秀 | 一般 | 差 |
|---|---|---|---|
| 首屏 JS 体积 | < 200KB | 200-500KB | > 500KB |
| 总构建体积 | < 2MB | 2-5MB | > 5MB |
| 图片体积占比 | < 30% | 30-50% | > 50% |
| 压缩率 | > 70% | 50-70% | < 50% |
| 缓存命中率 | > 80% | 50-80% | < 50% |
| FCP | < 1.5s | 1.5-2.5s | > 2.5s |
| LCP | < 2.5s | 2.5-4s | > 4s |
三个核心原则
- 测量优先:没有数据的优化是盲目的
- 渐进改进:每次只优化一个指标
- 用户优先:始终以用户体验为导向
结语
优化的终极目标是让用户感受不到加载的存在。当用户打开我们的应用时,内容瞬间呈现,交互立即响应,这就说明我们的优化成功了!
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!