一、前置准备:先找到瓶颈再动手,别瞎优化
在动手之前,先说一个原则:优化之前必须先定位瓶颈。没有数据支撑的优化就是耍流氓。
1.1 安装打包分析工具
Vite 官方推荐 rollup-plugin-visualizer,可以在打包后生成一个可视化的 HTML 报告,直观地看到每个模块的大小。
npm install rollup-plugin-visualizer --save-dev
在 vite.config.js 中配置:
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
vue(),
visualizer({
filename: 'dist/stats.html', // 报告输出路径
open: true, // 打包完成后自动打开
gzipSize: true, // 显示 gzip 后大小
brotliSize: true, // 显示 brotli 后大小
})
]
})
运行 npm run build 后,浏览器会自动打开一个交互式 treemap,你可以直观地看到哪个库占的体积最大。
1.2 Chrome DevTools 定位性能瓶颈
光看打包体积还不够,运行时的性能问题同样致命。
Network 面板:查看资源加载瀑布图,找出哪个请求拖慢了整体速度。
Performance 面板:录制页面加载过程,查看 FCP、LCP、TTI 等核心指标。
Lighthouse:直接给页面打分,它会给出具体的优化建议。
// 可以在开发环境下打印关键性能指标
// main.js
if (import.meta.env.DEV) {
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
console.log(`${entry.name}: ${entry.startTime}ms`)
}
}).observe({ entryTypes: ['navigation', 'paint', 'largest-contentful-paint'] })
}
二、构建打包优化:产物体积缩减 60%
分析报告出来后,通常会发现三个问题:UI 库太大、第三方库未按需引入、所有路由打包到一个文件。下面逐一解决。
2.1 Element Plus 按需引入
很多项目用 app.use(ElementPlus) 全量注册,这会把整个库都打进去,压缩后大概 300KB+。
修改 vite.config.js:
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [
ElementPlusResolver({
importStyle: 'sass', // 按需引入样式
}),
],
}),
],
css: {
preprocessorOptions: {
scss: {
// 可以在这里覆盖 Element Plus 主题变量
additionalData: `@use "@/styles/element-variables.scss" as *;`,
},
},
},
})
移除 main.js 中的全量注册:
// main.js - 删除这两行
// import ElementPlus from 'element-plus'
// import 'element-plus/dist/index.css'
// 改为按需自动导入,不需要手动注册
这样每个组件只在使用时才会被打包,Element Plus 体积从 ~300KB 降到 ~80KB。
2.2 路由懒加载
这是最容易被忽略的优化。把所有页面都放到一个 chunk 里,首屏加载时就要下载全部页面代码,非常不划算。
改造前(全量加载):
// router/index.js - ❌ 不推荐
import HomePage from '@/views/HomePage.vue'
import LoginPage from '@/views/LoginPage.vue'
import RegisterPage from '@/views/RegisterPage.vue'
import BillingPage from '@/views/BillingPage.vue'
const routes = [
{ path: '/', component: HomePage },
{ path: '/login', component: LoginPage },
{ path: '/register', component: RegisterPage },
{ path: '/billing', component: BillingPage },
]
改造后(懒加载):
// router/index.js - ✅ 推荐
const routes = [
{
path: '/',
component: () => import('@/views/HomePage.vue')
},
{
path: '/login',
component: () => import('@/views/LoginPage.vue')
},
{
path: '/register',
component: () => import('@/views/RegisterPage.vue')
},
{
path: '/billing',
component: () => import('@/views/BillingPage.vue')
},
// 可以配合 webpackChunkName 给 chunk 命名
{
path: '/dashboard',
component: () => import(/* webpackChunkName: "dashboard" */ '@/views/DashboardPage.vue')
},
]
Vite 默认支持动态 import,不需要额外配置。每个页面会被打包成独立的 chunk,首屏只加载当前页面需要的代码。
效果:首屏 JS 从 2.8MB 降到 1.6MB,直接减少 1.2MB。
2.3 第三方库 CDN 外链
一些不常更新的库(如 Vue、Element Plus),可以用 CDN 引入,不打包到项目里。使用 vite-plugin-cdn-import 插件:
npm install vite-plugin-cdn-import --save-dev
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import cdnImport from 'vite-plugin-cdn-import'
export default defineConfig({
plugins: [
vue(),
cdnImport({
modules: [
{
name: 'vue',
var: 'Vue',
path: 'https://cdn.jsdelivr.net/npm/vue@3.4.0/dist/vue.global.prod.js',
},
{
name: 'element-plus',
var: 'ElementPlus',
path: 'https://cdn.jsdelivr.net/npm/element-plus@2.4.0/dist/index.full.min.js',
},
{
name: 'axios',
var: 'axios',
path: 'https://cdn.jsdelivr.net/npm/axios@1.6.0/dist/axios.min.js',
},
{
name: 'vue-router',
var: 'VueRouter',
path: 'https://cdn.jsdelivr.net/npm/vue-router@4.2.0/dist/vue-router.global.prod.js',
},
{
name: 'pinia',
var: 'Pinia',
path: 'https://cdn.jsdelivr.net/npm/pinia@2.1.0/dist/pinia.iife.prod.js',
},
],
}),
],
})
注意:CDN 外链适合生产环境,开发环境建议还是用本地 node_modules,方便调试。
效果:Element Plus + Vue 等框架从 800KB 降到 0,完全由 CDN 承载。
2.4 代码分割策略
除了路由懒加载,还可以手动配置 manualChunks,把大型第三方库单独拆分:
// vite.config.js
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// 把 vue 相关库拆到 vendor-vue
'vendor-vue': ['vue', 'vue-router', 'pinia'],
// 把 element-plus 单独拆分
'vendor-element': ['element-plus'],
// 把其他大型库拆分
'vendor-utils': ['axios', 'lodash-es', 'dayjs'],
},
},
},
},
})
这样配置后,dist/assets/ 目录下会生成多个独立的 chunk 文件:
dist/assets/
├── index-xxxxxx.js # 业务代码 ~200KB
├── vendor-vue-xxxxxx.js # Vue 生态 ~150KB
├── vendor-element-xxxxxx.js # Element Plus ~200KB
└── vendor-utils-xxxxxx.js # 工具库 ~100KB
浏览器可以并行下载多个文件,比单个大文件加载更快。
三、运行时性能优化
3.1 虚拟列表处理大数据量渲染
如果你的页面有长列表(比如历史记录、数据看板),直接 v-for 渲染会导致 DOM 节点过多,页面卡顿。
使用 vue-virtual-scroller 实现虚拟列表,只渲染可视区域内的 DOM 节点:
npm install vue-virtual-scroller
<template>
<div class="history-container">
<RecycleScroller
class="scroller"
:items="historyList"
:item-size="80"
key-field="id"
v-slot="{ item }"
>
<div class="history-item">
<span class="review">{{ item.review.slice(0, 50) }}...</span>
<span class="time">{{ formatTime(item.created_at) }}</span>
</div>
</RecycleScroller>
</div>
</template>
<script setup>
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
const historyList = ref([]) // 即使有 10000 条数据,也只渲染 20 个 DOM
// 加载数据...
</script>
<style scoped>
.scroller {
height: 500px;
}
.history-item {
height: 80px;
padding: 12px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
3.2 防抖节流实战
搜索输入、滚动事件这类高频触发的操作,必须加防抖或节流。
// utils/performance.js
export function debounce(fn, delay = 300) {
let timer = null
return function (...args) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, args)
timer = null
}, delay)
}
}
export function throttle(fn, interval = 200) {
let lastTime = 0
return function (...args) {
const now = Date.now()
if (now - lastTime >= interval) {
fn.apply(this, args)
lastTime = now
}
}
}
在组件中使用:
<template>
<el-input
v-model="keyword"
@input="handleSearch"
placeholder="搜索..."
/>
</template>
<script setup>
import { debounce } from '@/utils/performance'
// 输入停止 300ms 后才触发搜索
const handleSearch = debounce((value) => {
fetchSearchResults(value)
}, 300)
</script>
四、资源加载优化
4.1 图片懒加载
使用 @vueuse/core 的 useIntersectionObserver 实现图片懒加载:
npm install @vueuse/core
<template>
<img
ref="imgRef"
:src="placeholderImage"
:data-src="realImageUrl"
alt="product image"
/>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'
const imgRef = ref(null)
const realImageUrl = 'https://example.com/image.jpg'
const placeholderImage = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"/>'
onMounted(() => {
const { stop } = useIntersectionObserver(
imgRef,
([{ isIntersecting }]) => {
if (isIntersecting) {
const img = imgRef.value
img.src = img.dataset.src
stop()
}
},
{ threshold: 0.1 }
)
})
</script>
4.2 预加载策略
prefetch 预加载:在浏览器空闲时加载未来可能用到的资源。
preload 预加载:高优先级加载当前页面关键资源。
<!-- index.html -->
<head>
<!-- 预加载关键字体 -->
<link rel="preload" href="/fonts/main-font.woff2" as="font" type="font/woff2" crossorigin>
<!-- 预加载首屏关键图片 -->
<link rel="preload" href="/images/hero.webp" as="image">
<!-- 预取其他路由的 chunk -->
<link rel="prefetch" href="/assets/dashboard-chunk.js">
</head>
在 Vite 中,可以通过 vite-plugin-prefetch 自动为所有 lazy-loaded chunks 添加 prefetch 链接。
五、优化数据对比
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 打包体积 | 2.8 MB | 1.1 MB | ↓ 60% |
| 首屏加载时间 | 2.4s | 0.9s | ↓ 62% |
| FCP (First Contentful Paint) | 1.8s | 0.7s | ↓ 61% |
| LCP (Largest Contentful Paint) | 2.6s | 1.2s | ↓ 54% |
| Lighthouse 评分 | 72 | 94 | ↑ 22分 |
| 请求数量 | 45 | 28 | ↓ 38% |
| DOM 节点数 | 3820 | 540 | ↓ 86% |
实测环境:Chrome 120,Network 模拟 Fast 3G,Devices 模拟 Moto G4
六、踩坑记录
坑 1:按需引入后组件样式丢失
unplugin-vue-components 虽然能自动导入组件,但样式可能不生效。
解决方案:确保 vite.config.js 中 importStyle: 'sass' 配置正确,且项目安装了 sass 依赖。
坑 2:CDN 资源跨域
部分 CDN 资源可能因为跨域策略加载失败。
解决方案:使用 crossorigin 属性,或改用国内镜像源(如 cdnjs.cloudflare.com)。
坑 3:懒加载导致路由切换闪烁
动态加载组件需要时间,切换路由时可能白屏。
解决方案:配合 Suspense 或 v-loading 显示加载状态:
<template>
<Suspense>
<template #default>
<RouterView />
</template>
<template #fallback>
<div class="loading">加载中...</div>
</template>
</Suspense>
</template>
七、总结
这篇文章覆盖了从构建优化到运行时优化的完整链路,核心思路是:
- 先定位瓶颈再动手:用 visualizer + Lighthouse 找到真正的问题
- 按需加载是关键:UI 库按需 + 路由懒加载,体积直接减半
- 善用 CDN:框架和大型库走 CDN,不打包到项目里
- 虚拟列表 + 防抖节流:运行时性能提升显著
- 懒加载资源:图片、chunk 都懒加载,首屏只加载必要内容
💡 这篇文章里提到的优化方案,全部来自我 6 周前上线的 Review Reply Assistant 项目。如果你想看完整项目代码,欢迎来 GitHub 交流。
有问题随时交流,我的邮箱:postmaster@zswgxl.xyz