Vue 性能优化实战
v-memo、虚拟滚动、懒加载、Tree-shaking、内存优化
一、组件级优化
1.1 v-memo(Vue 3.2+)
作用:缓存组件子树,跳过不必要的更新。
vue
<template>
<div v-for="item in list" :key="item.id" v-memo="[item.id, item.selected]">
<p>{{ item.name }}</p>
<p>{{ item.description }}</p>
<button @click="select(item)">Select</button>
</div>
</template>工作原理:
typescript
// 只有 item.id 或 item.selected 变化时才重新渲染
// 其他属性变化(如 item.name)不会触发更新使用场景:
vue
<!-- 大列表优化 -->
<template>
<div v-for="item in largeList" :key="item.id" v-memo="[item.id]">
<!-- 复杂的子树 -->
<ExpensiveComponent :data="item" />
</div>
</template>
<!-- 表格行优化 -->
<template>
<tr v-for="row in rows" :key="row.id" v-memo="[row.id, row.selected]">
<td v-for="cell in row.cells" :key="cell.id">{{ cell.value }}</td>
</tr>
</template>性能对比:
typescript
// 1000 行表格,更新一行
// 无 v-memo: ~100ms
// 有 v-memo: ~5ms(提升 20 倍)1.2 v-once
作用:只渲染一次,后续更新跳过。
vue
<template>
<!-- 静态内容 -->
<div v-once>
<h1>{{ title }}</h1>
<p>{{ description }}</p>
</div>
<!-- 动态内容 -->
<div>
<p>{{ count }}</p>
</div>
</template>使用场景:
- 静态内容(不会变化)
- 初始化后不再更新的数据
- 大量静态文本
1.3 KeepAlive
作用:缓存组件实例,避免重复创建。
vue
<template>
<KeepAlive :max="10">
<component :is="currentComponent" />
</KeepAlive>
</template>高级用法:
vue
<!-- 条件缓存 -->
<KeepAlive :include="['ComponentA', 'ComponentB']">
<RouterView />
</KeepAlive>
<!-- 排除缓存 -->
<KeepAlive :exclude="['ComponentC']">
<RouterView />
</KeepAlive>
<!-- 限制缓存数量 -->
<KeepAlive :max="5">
<RouterView />
</KeepAlive>生命周期钩子:
vue
<script setup>
import { onActivated, onDeactivated } from 'vue'
onActivated(() => {
console.log('组件被激活')
// 刷新数据
})
onDeactivated(() => {
console.log('组件被缓存')
// 清理定时器
})
</script>二、列表优化
2.1 虚拟滚动
原理:只渲染可见区域的元素,大幅减少 DOM 数量。
vue
<script setup>
import { ref, computed } from 'vue'
const items = ref(Array.from({ length: 10000 }, (_, i) => ({
id: i,
text: `Item ${i}`
})))
const containerHeight = 600
const itemHeight = 50
const visibleCount = Math.ceil(containerHeight / itemHeight)
const scrollTop = ref(0)
const startIndex = computed(() => Math.floor(scrollTop.value / itemHeight))
const endIndex = computed(() => startIndex.value + visibleCount + 1)
const visibleItems = computed(() => items.value.slice(startIndex.value, endIndex.value))
const offsetY = computed(() => startIndex.value * itemHeight)
function handleScroll(e) {
scrollTop.value = e.target.scrollTop
}
</script>
<template>
<div
class="virtual-list"
:style="{ height: containerHeight + 'px' }"
@scroll="handleScroll"
>
<div :style="{ height: items.length * itemHeight + 'px', position: 'relative' }">
<div
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div
v-for="item in visibleItems"
:key="item.id"
:style="{ height: itemHeight + 'px' }"
>
{{ item.text }}
</div>
</div>
</div>
</div>
</template>
<style scoped>
.virtual-list {
overflow-y: auto;
}
</style>使用第三方库:
bash
npm install vue-virtual-scrollervue
<script setup>
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
const items = ref(Array.from({ length: 10000 }, (_, i) => ({ id: i })))
</script>
<template>
<RecycleScroller
:items="items"
:item-size="50"
key-field="id"
v-slot="{ item }"
>
<div class="item">{{ item.id }}</div>
</RecycleScroller>
</template>2.2 分页加载
vue
<script setup>
import { ref, computed } from 'vue'
const allItems = ref(Array.from({ length: 10000 }, (_, i) => ({ id: i })))
const page = ref(1)
const pageSize = 50
const displayedItems = computed(() => {
const start = 0
const end = page.value * pageSize
return allItems.value.slice(start, end)
})
function loadMore() {
page.value++
}
</script>
<template>
<div>
<div v-for="item in displayedItems" :key="item.id">
{{ item.id }}
</div>
<button @click="loadMore">Load More</button>
</div>
</template>2.3 无限滚动
vue
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const items = ref([])
const loading = ref(false)
const page = ref(1)
async function loadMore() {
if (loading.value) return
loading.value = true
const newItems = await fetchItems(page.value)
items.value.push(...newItems)
page.value++
loading.value = false
}
function handleScroll() {
const { scrollTop, scrollHeight, clientHeight } = document.documentElement
if (scrollTop + clientHeight >= scrollHeight - 100) {
loadMore()
}
}
onMounted(() => {
window.addEventListener('scroll', handleScroll)
loadMore()
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
})
</script>
<template>
<div>
<div v-for="item in items" :key="item.id">
{{ item.text }}
</div>
<div v-if="loading">Loading...</div>
</div>
</template>三、异步组件与懒加载
3.1 路由懒加载
typescript
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: () => import('@/views/Home.vue')
},
{
path: '/about',
component: () => import('@/views/About.vue')
}
]
})分组打包:
typescript
const routes = [
{
path: '/admin',
component: () => import(/* webpackChunkName: "admin" */ '@/views/Admin.vue')
},
{
path: '/admin/users',
component: () => import(/* webpackChunkName: "admin" */ '@/views/AdminUsers.vue')
}
]3.2 异步组件
vue
<script setup>
import { defineAsyncComponent } from 'vue'
const AsyncComponent = defineAsyncComponent(() =>
import('./HeavyComponent.vue')
)
// 带加载状态
const AsyncComponentWithOptions = defineAsyncComponent({
loader: () => import('./HeavyComponent.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorComponent,
delay: 200,
timeout: 3000
})
</script>
<template>
<Suspense>
<AsyncComponent />
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</template>3.3 组件级代码分割
vue
<script setup>
import { defineAsyncComponent } from 'vue'
// 只在需要时加载
const HeavyChart = defineAsyncComponent(() =>
import('./HeavyChart.vue')
)
const showChart = ref(false)
</script>
<template>
<button @click="showChart = true">Show Chart</button>
<HeavyChart v-if="showChart" />
</template>四、响应式优化
4.1 shallowRef / shallowReactive
使用场景:大对象、只关心整体替换。
vue
<script setup>
import { shallowRef, triggerRef } from 'vue'
// 大对象,只追踪根级别
const bigData = shallowRef({
items: Array.from({ length: 10000 }, (_, i) => ({ id: i, value: i }))
})
function updateData() {
// 修改嵌套属性不会触发更新
bigData.value.items[0].value = 999 // ❌ 不会更新
// 整体替换会触发更新
bigData.value = { items: [...] } // ✅ 会更新
// 或手动触发
bigData.value.items[0].value = 999
triggerRef(bigData) // ✅ 手动触发更新
}
</script>4.2 markRaw
作用:标记对象为非响应式。
vue
<script setup>
import { markRaw, reactive } from 'vue'
const state = reactive({
// 第三方库实例,不需要响应式
chart: markRaw(new Chart()),
// 大型不可变数据
staticData: markRaw(largeDataset)
})
</script>4.3 computed 缓存
vue
<script setup>
import { ref, computed } from 'vue'
const list = ref([...])
// ❌ 每次都重新计算
const filtered = () => list.value.filter(item => item.active)
// ✅ 有缓存,依赖变化才重新计算
const filtered = computed(() => list.value.filter(item => item.active))
</script>五、事件优化
5.1 事件委托
vue
<template>
<!-- ❌ 每个按钮都绑定事件 -->
<button v-for="item in items" :key="item.id" @click="handleClick(item)">
{{ item.name }}
</button>
<!-- ✅ 事件委托 -->
<div @click="handleClick">
<button v-for="item in items" :key="item.id" :data-id="item.id">
{{ item.name }}
</button>
</div>
</template>
<script setup>
function handleClick(e) {
const id = e.target.dataset.id
if (id) {
// 处理点击
}
}
</script>5.2 防抖与节流
vue
<script setup>
import { ref } from 'vue'
import { useDebounceFn, useThrottleFn } from '@vueuse/core'
const searchText = ref('')
// 防抖
const debouncedSearch = useDebounceFn((text) => {
console.log('搜索:', text)
}, 500)
// 节流
const throttledScroll = useThrottleFn(() => {
console.log('滚动')
}, 200)
</script>
<template>
<input v-model="searchText" @input="debouncedSearch(searchText)" />
<div @scroll="throttledScroll">...</div>
</template>手动实现:
typescript
function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout> | null = null
return function(...args: Parameters<T>) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}
}
function throttle<T extends (...args: any[]) => any>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let lastTime = 0
return function(...args: Parameters<T>) {
const now = Date.now()
if (now - lastTime >= delay) {
fn(...args)
lastTime = now
}
}
}六、打包优化
6.1 Tree-shaking
typescript
// ❌ 全量导入
import Vue from 'vue'
// ✅ 按需导入
import { ref, computed, watch } from 'vue'
// ❌ 导入整个库
import _ from 'lodash'
// ✅ 导入单个函数
import debounce from 'lodash/debounce'6.2 代码分割
typescript
// vite.config.ts
export default {
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor': ['vue', 'vue-router', 'pinia'],
'ui': ['element-plus'],
'utils': ['lodash', 'dayjs']
}
}
}
}
}6.3 压缩优化
typescript
// vite.config.ts
export default {
build: {
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true
}
}
}
}七、图片优化
7.1 懒加载
vue
<script setup>
import { ref, onMounted } from 'vue'
const images = ref([])
onMounted(() => {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target
img.src = img.dataset.src
observer.unobserve(img)
}
})
})
document.querySelectorAll('img[data-src]').forEach(img => {
observer.observe(img)
})
})
</script>
<template>
<img v-for="src in images" :key="src" :data-src="src" src="placeholder.jpg" />
</template>使用 v-lazy:
bash
npm install vue3-lazyvue
<script setup>
import VueLazyload from 'vue3-lazy'
app.use(VueLazyload, {
loading: '/loading.gif',
error: '/error.png'
})
</script>
<template>
<img v-lazy="imageUrl" />
</template>7.2 响应式图片
vue
<template>
<picture>
<source media="(min-width: 1200px)" srcset="large.jpg">
<source media="(min-width: 768px)" srcset="medium.jpg">
<img src="small.jpg" alt="Responsive image">
</picture>
</template>八、内存优化
8.1 清理副作用
vue
<script setup>
import { onUnmounted } from 'vue'
const timer = setInterval(() => {
console.log('tick')
}, 1000)
// ✅ 组件卸载时清理
onUnmounted(() => {
clearInterval(timer)
})
</script>8.2 避免内存泄漏
vue
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const data = ref([])
function handleResize() {
// 处理逻辑
}
onMounted(() => {
window.addEventListener('resize', handleResize)
})
// ✅ 必须移除监听器
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
</script>8.3 大数据清理
vue
<script setup>
import { ref, onUnmounted } from 'vue'
const largeData = ref(new Array(100000).fill(0))
onUnmounted(() => {
// 清理大数据
largeData.value = null
})
</script>九、SSR 优化
9.1 预渲染
typescript
// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa'
export default {
plugins: [
VitePWA({
workbox: {
runtimeCaching: [
{
urlPattern: /^https:\/\/api\./,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 300
}
}
}
]
}
})
]
}9.2 流式渲染
typescript
// server.ts
import { renderToNodeStream } from 'vue/server-renderer'
app.get('*', async (req, res) => {
const stream = renderToNodeStream(app)
res.write('<!DOCTYPE html><html><head>...</head><body>')
stream.pipe(res, { end: false })
stream.on('end', () => {
res.end('</body></html>')
})
})十、性能监控
10.1 Performance API
typescript
// 测量组件渲染时间
performance.mark('component-start')
// 组件渲染
// ...
performance.mark('component-end')
performance.measure('component-render', 'component-start', 'component-end')
const measure = performance.getEntriesByName('component-render')[0]
console.log(`渲染耗时: ${measure.duration}ms`)10.2 Vue DevTools
vue
<script setup>
import { onMounted, onUpdated } from 'vue'
onMounted(() => {
console.time('mount')
})
onUpdated(() => {
console.timeEnd('mount')
})
</script>面试高频题
1: v-memo 和 v-once 有什么区别?
答案:
v-once:
- 只渲染一次,永不更新
- 适用于完全静态的内容
v-memo:
- 条件缓存,依赖变化时更新
- 适用于大列表优化
vue
<!-- v-once: 永不更新 -->
<div v-once>{{ staticContent }}</div>
<!-- v-memo: 条件更新 -->
<div v-memo="[dep1, dep2]">{{ content }}</div>2: 虚拟滚动的原理是什么?
答案:
原理:
- 只渲染可见区域的元素
- 根据滚动位置动态计算显示范围
- 使用 transform 定位元素
优势:
- DOM 数量固定(如 20 个)
- 无论列表多长,性能稳定
- 内存占用小
3: 如何优化大列表渲染?
答案:
方法:
- 虚拟滚动:只渲染可见元素
- v-memo:缓存列表项
- 分页加载:减少初始渲染量
- 懒加载:滚动时加载
- Web Worker:后台处理数据
4: shallowRef 和 ref 有什么区别?
答案:
ref:
- 深层响应式
- 嵌套对象也是响应式的
shallowRef:
- 浅层响应式
- 只追踪
.value的变化 - 嵌套对象不是响应式的
使用场景:
- 大对象优化
- 只关心整体替换
5: 如何避免内存泄漏?
答案:
常见原因:
- 未清理定时器
- 未移除事件监听
- 闭包引用
- 全局变量
解决方案:
typescript
onUnmounted(() => {
clearInterval(timer)
window.removeEventListener('resize', handler)
largeData.value = null
})6: computed 和 watch 哪个性能更好?
答案:
computed 更好:
- 有缓存机制
- 依赖不变不重新计算
- 适合派生数据
watch:
- 无缓存
- 每次都执行
- 适合副作用
建议:优先使用 computed。
7: 如何优化首屏加载?
答案:
方法:
- 路由懒加载:按需加载页面
- 代码分割:拆分 vendor
- Tree-shaking:移除无用代码
- 图片优化:懒加载、压缩
- SSR/预渲染:服务端渲染
- CDN:加速资源加载
- Gzip/Brotli:压缩传输
8: KeepAlive 的实现原理是什么?
答案:
原理:
- 缓存组件实例(VNode)
- 切换时不销毁,只是隐藏
- 使用 LRU 算法管理缓存
生命周期:
onActivated:激活时onDeactivated:缓存时
9: 如何优化事件处理?
答案:
方法:
- 事件委托:减少监听器数量
- 防抖/节流:减少执行频率
- passive 监听:提升滚动性能
typescript
// 防抖
const debouncedFn = useDebounceFn(fn, 500)
// 节流
const throttledFn = useThrottleFn(fn, 200)
// passive
window.addEventListener('scroll', handler, { passive: true })10: 如何监控 Vue 应用性能?
答案:
工具:
- Vue DevTools:组件性能分析
- Performance API:测量渲染时间
- Lighthouse:整体性能评分
- Web Vitals:核心指标
关键指标:
- FCP(首次内容绘制)
- LCP(最大内容绘制)
- TTI(可交互时间)
- TBT(总阻塞时间)