Skip to content

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-scroller
vue
<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-lazy
vue
<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: 虚拟滚动的原理是什么?

答案

原理

  1. 只渲染可见区域的元素
  2. 根据滚动位置动态计算显示范围
  3. 使用 transform 定位元素

优势

  • DOM 数量固定(如 20 个)
  • 无论列表多长,性能稳定
  • 内存占用小

3: 如何优化大列表渲染?

答案

方法

  1. 虚拟滚动:只渲染可见元素
  2. v-memo:缓存列表项
  3. 分页加载:减少初始渲染量
  4. 懒加载:滚动时加载
  5. Web Worker:后台处理数据

4: shallowRef 和 ref 有什么区别?

答案

ref

  • 深层响应式
  • 嵌套对象也是响应式的

shallowRef

  • 浅层响应式
  • 只追踪 .value 的变化
  • 嵌套对象不是响应式的

使用场景

  • 大对象优化
  • 只关心整体替换

5: 如何避免内存泄漏?

答案

常见原因

  1. 未清理定时器
  2. 未移除事件监听
  3. 闭包引用
  4. 全局变量

解决方案

typescript
onUnmounted(() => {
  clearInterval(timer)
  window.removeEventListener('resize', handler)
  largeData.value = null
})

6: computed 和 watch 哪个性能更好?

答案

computed 更好

  • 有缓存机制
  • 依赖不变不重新计算
  • 适合派生数据

watch

  • 无缓存
  • 每次都执行
  • 适合副作用

建议:优先使用 computed。


7: 如何优化首屏加载?

答案

方法

  1. 路由懒加载:按需加载页面
  2. 代码分割:拆分 vendor
  3. Tree-shaking:移除无用代码
  4. 图片优化:懒加载、压缩
  5. SSR/预渲染:服务端渲染
  6. CDN:加速资源加载
  7. Gzip/Brotli:压缩传输

8: KeepAlive 的实现原理是什么?

答案

原理

  1. 缓存组件实例(VNode)
  2. 切换时不销毁,只是隐藏
  3. 使用 LRU 算法管理缓存

生命周期

  • onActivated:激活时
  • onDeactivated:缓存时

9: 如何优化事件处理?

答案

方法

  1. 事件委托:减少监听器数量
  2. 防抖/节流:减少执行频率
  3. passive 监听:提升滚动性能
typescript
// 防抖
const debouncedFn = useDebounceFn(fn, 500)

// 节流
const throttledFn = useThrottleFn(fn, 200)

// passive
window.addEventListener('scroll', handler, { passive: true })

10: 如何监控 Vue 应用性能?

答案

工具

  1. Vue DevTools:组件性能分析
  2. Performance API:测量渲染时间
  3. Lighthouse:整体性能评分
  4. Web Vitals:核心指标

关键指标

  • FCP(首次内容绘制)
  • LCP(最大内容绘制)
  • TTI(可交互时间)
  • TBT(总阻塞时间)