在 Vue 项目中进行性能优化,需要从构建体积、渲染效率、网络加载、内存管理等多个维度系统性地入手。下面按模块拆解常见优化方案,每个方案都配代码实例、适用场景、注意事项及真实踩坑记录。
一、路由与组件懒加载(代码分割)
方案说明
将不同路由对应的组件拆分成独立 chunk,仅在访问时才加载,减小首屏 JS 体积。
代码示例
javascript
// router/index.js
const Home = () => import(/* webpackChunkName: "home" */ '@/views/Home.vue')
const About = () => import(/* webpackChunkName: "about" */ '@/views/About.vue')
const routes = [
{ path: '/', component: Home },
{ path: '/about', component: About }
]
使用场景
- SPA 中所有路由级别页面。
- 大型组件(如富文本编辑器、图表库)也应用动态引入。
注意事项
- 使用命名 chunk(webpackChunkName)方便定位缓存问题。
- 若组件加载失败,应有错误边界处理(errorCaptured 或
<Suspense>)。 - 避免拆分过细导致 HTTP 请求过多(通常按路由划分即可)。
遇到的坑
坑: 未配置 publicPath 或部署路径错误,动态 chunk 会 404。
解决: 确认 vue.config.js 中 publicPath 与实际部署目录一致。
坑: 低版本浏览器不支持 import(),需要 Babel 插件 @babel/plugin-syntax-dynamic-import。
二、合理使用 v-if 与 v-show,及 keep-alive 缓存
方案说明
v-if:条件性渲染,切换开销大,适合运行时条件很少改变的场景。v-show:初始渲染开销大,但切换仅改 CSSdisplay,适合频繁切换。<keep-alive>:缓存不活动的组件实例,避免重复渲染。
代码示例
html
<!-- 频繁切换的 Tab 使用 v-show 并配合 keep-alive -->
<keep-alive>
<component :is="currentTab" v-show="activeTab === 'tab1'"></component>
</keep-alive>
使用场景
- 表单多步骤向导(keep-alive 保留已填数据)。
- Tab 切换内容不丢失状态。
注意事项
keep-alive会占用内存,避免缓存过多大型组件,可配合max属性。- 使用
activated/deactivated生命周期代替mounted/destroyed处理数据刷新。
遇到的坑
坑: 使用 keep-alive 但列表页数据不刷新,因为 created 不再执行。
解决: 在 activated 钩子中请求数据,并设置条件判断是否需要刷新。
坑: keep-alive 包裹的组件过多导致内存泄漏,某些第三方库事件监听未及时清理。
解决: 在 deactivated 中手动解绑,或使用 beforeRouteLeave 做清理。
三、列表渲染优化(虚拟滚动)
方案说明
当需要渲染成千上万条数据时,全量 DOM 节点会严重卡顿。使用虚拟滚动库(如 vue-virtual-scroller)仅渲染可视区域内的节点。
代码示例
html
<template>
<RecycleScroller
class="scroller"
:items="list"
:item-size="50"
key-field="id"
v-slot="{ item }"
>
<div class="item">{{ item.name }}</div>
</RecycleScroller>
</template>
<script setup>
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
</script>
使用场景
- 聊天记录、日志列表、无限滚动商品列表。
- 长表格、大型树结构。
注意事项
- 项目需稳定高度或可使用
dynamic模式,但性能稍差。 - 虚拟滚动内不宜使用复杂的交互或过渡动画,易引起计算错误。
遇到的坑
坑: 动态高度元素(如图片)在虚拟滚动中错位或闪烁。
解决: 使用 DynamicScroller 并配合 DynamicScrollerItem,确保图片加载后更新高度。
坑: 虚拟滚动库可能不兼容某些 Vue Devtools 检查,调试困难。
解决: 开发时临时关闭虚拟滚动(用条件渲染),或单独抽离列表组件。
四、计算属性与 v-memo 减少重复计算/渲染
方案说明
- 利用
computed缓存派生数据,避免模板中直接编写复杂逻辑。 - Vue3 的
v-memo可以缓存子树,当依赖不变时跳过虚拟 DOM 生成。
代码示例
html
<template>
<div v-for="item in list" :key="item.id" v-memo="[item.id, item.selected]">
<span>{{ expensiveFormat(item) }}</span>
</div>
</template>
使用场景
- 大列表中某个状态(如选中)频繁切换,其他项无需重新渲染。
- 重复使用昂贵计算结果的地方。
注意事项
v-memo依赖数组必须明确,否则可能导致不更新。- 过度使用
v-memo可能产生隐藏 bug,建议仅在性能瓶颈处使用。
遇到的坑
坑: v-memo 用于带有 v-for 的组件时,如果内部嵌套了 slot 且 slot 数据变化,但依赖项不变,可能导致视图不更新。
解决: 仔细分析依赖,将真正影响子树输出的响应式数据都加入依赖数组。
五、避免 v-if 与 v-for 同级使用
方案说明
Vue 会将 v-if 优先级置于 v-for 之上(Vue3 中 v-if 优先级更高),可能导致遍历前就判断条件,逻辑错误且性能低下。应使用计算属性预先过滤列表。
代码示例
html
<!-- 错误 -->
<div v-for="user in users" v-if="user.active" :key="user.id">
<!-- 正确 -->
<template v-for="user in activeUsers" :key="user.id">
<div>{{ user.name }}</div>
</template>
<script setup>
import { computed } from 'vue'
const activeUsers = computed(() => users.value.filter(u => u.active))
</script>
场景 & 注意
- 任何需要过滤列表再渲染的情况。
- 过滤逻辑应放在计算属性中以保持缓存。
坑点
坑: 直接在 v-for 里面嵌套计算属性调用函数,仍然会造成每次渲染都执行过滤。
解决: 务必使用 computed 生成新数组。
六、图片与资源优化
1. 图片懒加载(Intersection Observer)
html
<img v-lazy="imageSrc" />
使用 vue-lazyload 插件,或自定义指令。
2. 响应式图片(WebP + srcset)
html
<picture>
<source srcset="img.webp" type="image/webp" />
<img src="img.jpg" loading="lazy" />
</picture>
3. 精灵图/ SVG 图标雪碧图
减少 HTTP 请求,使用 svg-sprite-loader。
使用场景
- 电商列表页、图片密集页面。
注意事项
- 懒加载需设置占位图防止布局抖动。
- 动态 WebP 生成服务(如 CDN 转换)需考虑回退。
坑
坑: 懒加载指令在元素 display:none 或 v-if 重新显示时可能不会触发检测。
解决: 在激活时手动调用指令的 update 或重新绑定。
七、第三方库按需引入与 Tree Shaking
方案说明
确保使用支持 Tree Shaking 的库,并按需导入,避免引入整个库。
代码示例
javascript
// 错误:导入整个 lodash
import _ from 'lodash'
// 正确:按需导入
import debounce from 'lodash/debounce'
// 针对 Element Plus 使用 unplugin-vue-components 自动按需
import { ElButton } from 'element-plus'
配置 Babel/Webpack/Vite
vite-plugin-components(Vite) 或babel-plugin-import(Webpack) 实现组件按需加载。
坑
坑: 某些库如 moment.js 无法 Tree Shaking,且 locale 文件大。
解决: 迁移到 day.js 或 date-fns,并使用 IgnorePlugin 排除多余语言包。
八、Webpack/Vite 构建层优化
1. 压缩与去除注释
javascript
// vite.config.js
build: {
minify: 'terser',
terserOptions: {
compress: { drop_console: true, drop_debugger: true }
}
}
2. 拆包策略(Vendor Splitting)
Vite 基于 Rollup:
javascript
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue', 'vue-router', 'pinia'],
ui: ['element-plus']
}
}
}
}
Webpack 使用 splitChunks。
3. 压缩图片与资源(插件)
vite-plugin-imagemin
4. Gzip/Brotli 压缩
构建生成 .gz 文件,由 Nginx 直接提供静态预压缩资源。
使用场景
- 所有生产构建项目。
坑
坑: 拆包过细导致 HTTP/2 并发请求过多,反而变慢。
解决: 根据业务合并,通常 vendor + 公共工具 + 业务页面三类即可。
坑: drop_console 可能在有意的日志关键逻辑中误删。
解决: 保留 console.warn/error,或在代码中使用自定义 logger 并用环境变量控制。
九、事件销毁与内存泄漏防范
方案说明
组件销毁时必须清除定时器、全局事件绑定、Observer 等,防止内存泄漏和后台持续执行。
代码示例
javascript
export default {
mounted() {
this.scrollHandler = () => { /* heavy task */ }
window.addEventListener('scroll', this.scrollHandler)
this.timer = setInterval(this.refresh, 5000)
},
beforeDestroy() {
window.removeEventListener('scroll', this.scrollHandler)
clearInterval(this.timer)
}
}
Vue3 可使用 onBeforeUnmount。
场景
- 使用全局事件总线(mitt)、WebSocket、ECharts 实例、setInterval。
坑
坑: 使用了 keep-alive 的组件,beforeDestroy 不会触发,需用 deactivated 清理临时资源。
坑: 引入第三方图表库时,实例未销毁导致切换路由后 DOM 不存在,控制台报错且内存增加。
十、预加载与预取策略
方案说明
利用 <link rel="prefetch/preload"> 加速后续页面或关键资源加载。
代码示例
手动在 index.html 添加:
html
<link rel="prefetch" href="/css/chunk-vendors.css" as="style" />
Webpack 可在魔法注释中实现:
javascript
const Profile = () => import(/* webpackPrefetch: true */ '@/views/Profile')
使用场景
- 预估用户下一步操作(如登录后一定去首页),提前加载首屏下页资源。
- 关键字体、CSS 使用 preload。
注意事项
prefetch会占用带宽,移动端慎用。preload不宣载过多资源,否则阻塞主文档。
坑
坑: webpackPrefetch 在某些路由权限判断下,未登录用户会预取需认证的页面,白白浪费流量。
解决: 在路由守卫中判断权限后再动态插入 link 标签。
十一、服务端渲染(SSR)与静态生成(SSG)
方案说明
使用 Nuxt.js 或 Vite SSR 将首屏 HTML 直接服务端渲染,降低白屏时间,利于 SEO。
场景
- 内容型网站、电商详情页、博客。
- 对首屏速度有极致要求的项目。
注意事项
- 需要 Node 服务器部署,增加运维成本。
- 注意第三方库是否支持服务端(如
window访问受限),需做环境判断。 - 避免内存泄漏:服务端请求上下文需独立,不能使用单例状态。
坑
坑: 未正确处理客户端激活(hydration),导致 DOM 不匹配错误。
解决: 确保服务端与客户端渲染的数据一致,使用 <ClientOnly> 包裹纯客户端组件。
十二、合理使用 shallowRef 与 shallowReactive
方案说明
当数据结构非常大且嵌套深,但只需表层响应式时,用浅响应式 API 避免深度遍历代理,减少初始化开销和内存占用。
代码示例
javascript
import { shallowRef } from 'vue'
const largeList = shallowRef(new Array(10000).fill().map((_,i) => ({ id: i })))
// 只有当替换整个数组引用时才触发更新
function update() {
largeList.value = [...largeList.value, newItem]
}
场景
- 持有巨型数据的变量,但视图只依赖顶层引用变化。
- 与不可变数据模式结合使用。
坑
坑: 不小心修改内部属性不会触发更新,调试困难。团队需严格遵守数据不可变规范。
总结:性能优化需遵循"测量 → 定位瓶颈 → 实施 → 再测量"的闭环。不应提前过度优化,优先解决网络、JS 体积、渲染阻塞等影响首屏体验的明显问题。基于 Lighthouse 和 Vue Devtools 性能面板持续监控。