Vue 3 性能优化实战指南:从理论到实践
Orion K Lv6

Vue 3 在性能方面相比 Vue 2 有了显著提升,但在实际开发中,我们仍需要掌握各种性能优化技巧来构建高性能的应用。本文将从 Vue 3 的性能改进原理出发,深入探讨实际项目中的性能优化策略和最佳实践。

Vue 3 性能提升的核心原理

1. 编译时优化

静态标记(PatchFlag)

Vue 3 在编译阶段会为动态节点添加静态标记,运行时只需要更新标记的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 编译前
<div>
<span>{{ message }}</span>
<p>静态文本</p>
</div>

// 编译后(简化)
function render() {
return createVNode('div', null, [
createVNode('span', null, message, 1 /* TEXT */),
createVNode('p', null, '静态文本')
])
}

静态提升(hoistStatic)

静态元素会被提升到渲染函数外部,避免重复创建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 优化前
function render() {
return createVNode('div', null, [
createVNode('h1', null, '标题'), // 每次渲染都会创建
createVNode('p', null, message)
])
}

// 优化后
const _hoisted_1 = createVNode('h1', null, '标题') // 提升到外部

function render() {
return createVNode('div', null, [
_hoisted_1, // 复用静态节点
createVNode('p', null, message)
])
}

2. 运行时优化

Proxy 响应式系统

Vue 3 使用 Proxy 替代 Object.defineProperty,支持更多数据类型的响应式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Vue 2 的限制
const data = {
items: []
}

// 这些操作在 Vue 2 中不是响应式的
data.items[0] = newItem // 索引赋值
data.items.length = 0 // 修改数组长度
data.newProperty = 'value' // 添加新属性

// Vue 3 中都是响应式的
const state = reactive({
items: [],
map: new Map(),
set: new Set()
})

// 所有操作都是响应式的
state.items[0] = newItem
state.items.length = 0
state.newProperty = 'value'
state.map.set('key', 'value')
state.set.add('item')

组件级性能优化

1. 合理使用 v-if 和 v-show

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<template>
<!-- 频繁切换使用 v-show -->
<div v-show="isVisible" class="modal">
模态框内容
</div>

<!-- 条件渲染使用 v-if -->
<div v-if="userRole === 'admin'" class="admin-panel">
管理员面板
</div>

<!-- 复杂组件的条件渲染 -->
<template v-if="shouldRenderChart">
<heavy-chart-component :data="chartData" />
</template>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps(['userRole', 'chartData'])

// 使用计算属性缓存复杂的条件判断
const shouldRenderChart = computed(() => {
return props.chartData &&
props.chartData.length > 0 &&
props.userRole === 'admin'
})
</script>

2. 使用 KeepAlive 缓存组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<template>
<div>
<!-- 缓存路由组件 -->
<router-view v-slot="{ Component }">
<keep-alive :include="cachedViews" :max="10">
<component :is="Component" :key="$route.fullPath" />
</keep-alive>
</router-view>

<!-- 缓存标签页组件 -->
<div class="tabs">
<button
v-for="tab in tabs"
:key="tab.id"
@click="activeTab = tab.id"
:class="{ active: activeTab === tab.id }"
>
{{ tab.name }}
</button>
</div>

<keep-alive>
<component :is="currentTabComponent" :data="tabData" />
</keep-alive>
</div>
</template>

<script setup>
import { ref, computed } from 'vue'
import TabA from './TabA.vue'
import TabB from './TabB.vue'
import TabC from './TabC.vue'

const activeTab = ref('a')
const cachedViews = ref(['ProductList', 'UserProfile'])

const tabs = [
{ id: 'a', name: '标签A', component: TabA },
{ id: 'b', name: '标签B', component: TabB },
{ id: 'c', name: '标签C', component: TabC }
]

const currentTabComponent = computed(() => {
return tabs.find(tab => tab.id === activeTab.value)?.component
})
</script>

3. 优化大列表渲染

虚拟滚动实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
<template>
<div class="virtual-list" @scroll="handleScroll" ref="containerRef">
<div class="virtual-list-phantom" :style="{ height: totalHeight + 'px' }"></div>
<div class="virtual-list-content" :style="{ transform: `translateY(${offsetY}px)` }">
<div
v-for="item in visibleItems"
:key="item.id"
class="virtual-list-item"
:style="{ height: itemHeight + 'px' }"
>
<slot :item="item" :index="item.index">
{{ item.text }}
</slot>
</div>
</div>
</div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'

const props = defineProps({
items: {
type: Array,
required: true
},
itemHeight: {
type: Number,
default: 50
},
containerHeight: {
type: Number,
default: 400
}
})

const containerRef = ref(null)
const scrollTop = ref(0)

// 计算总高度
const totalHeight = computed(() => props.items.length * props.itemHeight)

// 计算可见区域的起始和结束索引
const startIndex = computed(() => {
return Math.floor(scrollTop.value / props.itemHeight)
})

const endIndex = computed(() => {
const visibleCount = Math.ceil(props.containerHeight / props.itemHeight)
return Math.min(startIndex.value + visibleCount + 1, props.items.length)
})

// 计算可见项目
const visibleItems = computed(() => {
return props.items.slice(startIndex.value, endIndex.value).map((item, index) => ({
...item,
index: startIndex.value + index
}))
})

// 计算偏移量
const offsetY = computed(() => startIndex.value * props.itemHeight)

// 滚动事件处理
function handleScroll(event) {
scrollTop.value = event.target.scrollTop
}

// 防抖优化
let ticking = false
function optimizedHandleScroll(event) {
if (!ticking) {
requestAnimationFrame(() => {
handleScroll(event)
ticking = false
})
ticking = true
}
}

onMounted(() => {
if (containerRef.value) {
containerRef.value.addEventListener('scroll', optimizedHandleScroll, { passive: true })
}
})

onUnmounted(() => {
if (containerRef.value) {
containerRef.value.removeEventListener('scroll', optimizedHandleScroll)
}
})
</script>

<style scoped>
.virtual-list {
height: 400px;
overflow-y: auto;
position: relative;
}

.virtual-list-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}

.virtual-list-content {
left: 0;
right: 0;
top: 0;
position: absolute;
}

.virtual-list-item {
padding: 10px;
border-bottom: 1px solid #eee;
box-sizing: border-box;
}
</style>

使用虚拟滚动组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<template>
<div>
<VirtualList
:items="largeDataset"
:item-height="60"
:container-height="500"
>
<template #default="{ item }">
<div class="user-item">
<img : src="item.avatar" :alt="item.name" class="avatar">
<div class="user-info">
<h3>{{ item.name }}</h3>
<p>{{ item.email }}</p>
</div>
</div>
</template>
</VirtualList>
</div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import VirtualList from './VirtualList.vue'

const largeDataset = ref([])

// 模拟大量数据
onMounted(() => {
largeDataset.value = Array.from({ length: 10000 }, (_, index) => ({
id: index,
name: `用户 ${index}`,
email: `user${index}@example.com`,
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${index}`
}))
})
</script>

异步组件和代码分割

1. 路由级代码分割

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'

const routes = [
{
path: '/',
name: 'Home',
component: () => import('../views/Home.vue')
},
{
path: '/products',
name: 'Products',
component: () => import('../views/Products.vue')
},
{
path: '/admin',
name: 'Admin',
component: () => import('../views/Admin.vue'),
meta: { requiresAuth: true }
}
]

const router = createRouter({
history: createWebHistory(),
routes
})

export default router

2. 组件级懒加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<template>
<div>
<button @click="showChart = true" v-if="!showChart">
显示图表
</button>

<!-- 懒加载重型组件 -->
<Suspense v-if="showChart">
<template #default>
<AsyncChart :data="chartData" />
</template>
<template #fallback>
<div class="loading">加载图表中...</div>
</template>
</Suspense>

<!-- 条件懒加载 -->
<Suspense v-if="userRole === 'admin'">
<template #default>
<AdminPanel />
</template>
<template #fallback>
<div class="loading">加载管理面板中...</div>
</template>
</Suspense>
</div>
</template>

<script setup>
import { ref, defineAsyncComponent } from 'vue'

const showChart = ref(false)
const userRole = ref('user')

// 异步组件定义
const AsyncChart = defineAsyncComponent({
loader: () => import('./HeavyChart.vue'),
loadingComponent: () => import('./LoadingSpinner.vue'),
errorComponent: () => import('./ErrorMessage.vue'),
delay: 200,
timeout: 3000
})

const AdminPanel = defineAsyncComponent(() => import('./AdminPanel.vue'))
</script>

3. 图片懒加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
<template>
<div class="image-gallery">
<div
v-for="image in images"
:key="image.id"
class="image-container"
ref="imageRefs"
>
<img
v-if="image.loaded"
: src="image.src"
:alt="image.alt"
class="lazy-image"
@load="onImageLoad(image)"
@error="onImageError(image)"
>
<div v-else class="image-placeholder">
<div class="loading-spinner"></div>
</div>
</div>
</div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

const props = defineProps({
images: {
type: Array,
required: true
}
})

const imageRefs = ref([])
const images = ref(props.images.map(img => ({ ...img, loaded: false })))

let observer = null

// 图片加载成功
function onImageLoad(image) {
console.log(`图片加载成功: ${image.alt}`)
}

// 图片加载失败
function onImageError(image) {
console.error(`图片加载失败: ${image.alt}`)
image.src = '/placeholder-error.jpg' // 设置错误占位图
}

// 创建 Intersection Observer
function createObserver() {
observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const index = imageRefs.value.indexOf(entry.target)
if (index !== -1 && !images.value[index].loaded) {
images.value[index].loaded = true
observer.unobserve(entry.target)
}
}
})
},
{
rootMargin: '50px 0px', // 提前50px开始加载
threshold: 0.1
}
)
}

onMounted(() => {
createObserver()

// 观察所有图片容器
imageRefs.value.forEach(ref => {
if (ref) observer.observe(ref)
})
})

onUnmounted(() => {
if (observer) {
observer.disconnect()
}
})
</script>

<style scoped>
.image-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}

.image-container {
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
}

.lazy-image {
width: 100%;
height: 100%;
object-fit: cover;
}

.image-placeholder {
width: 100%;
height: 100%;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
}

.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid #e0e0e0;
border-top: 2px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
}

@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>

响应式数据优化

1. 合理使用 ref 和 reactive

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// ✅ 推荐:基础类型使用 ref
const count = ref(0)
const message = ref('Hello')
const isLoading = ref(false)

// ✅ 推荐:对象使用 reactive
const user = reactive({
name: 'John',
age: 30,
preferences: {
theme: 'dark',
language: 'zh-CN'
}
})

// ❌ 避免:大对象全部响应式
const largeData = reactive({
items: new Array(10000).fill(0).map((_, i) => ({ id: i, value: Math.random() }))
})

// ✅ 推荐:只对需要的部分设置响应式
const largeData = {
items: new Array(10000).fill(0).map((_, i) => ({ id: i, value: Math.random() }))
}
const filteredItems = ref([])
const currentPage = ref(1)

2. 使用 shallowRef 和 shallowReactive

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { shallowRef, shallowReactive, triggerRef } from 'vue'

// 大型不可变数据使用 shallowRef
const largeList = shallowRef([])

// 手动触发更新
function updateList(newList) {
largeList.value = newList
triggerRef(largeList) // 手动触发响应式更新
}

// 只需要第一层响应式的对象
const config = shallowReactive({
api: {
baseURL: 'https://api.example.com',
timeout: 5000
},
ui: {
theme: 'light',
locale: 'zh-CN'
}
})

// 修改第一层属性会触发更新
config.api = { baseURL: 'https://new-api.example.com', timeout: 3000 }

// 修改嵌套属性不会触发更新(需要手动处理)
// config.api.baseURL = 'new-url' // 不会触发更新

3. 使用 markRaw 标记非响应式数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { markRaw, reactive } from 'vue'

// 第三方库实例不需要响应式
const state = reactive({
chart: markRaw(new Chart()), // Chart.js 实例
map: markRaw(new Map()), // 大型 Map 对象
editor: markRaw(new Monaco.Editor()) // Monaco 编辑器实例
})

// 大型配置对象
const appConfig = reactive({
routes: markRaw([
// 大量路由配置
]),
constants: markRaw({
// 大量常量定义
})
})

计算属性和侦听器优化

1. 计算属性缓存优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import { computed, ref } from 'vue'

const items = ref([])
const searchQuery = ref('')
const sortBy = ref('name')
const filterCategory = ref('all')

// ✅ 推荐:分步计算,充分利用缓存
const filteredItems = computed(() => {
if (filterCategory.value === 'all') {
return items.value
}
return items.value.filter(item => item.category === filterCategory.value)
})

const searchedItems = computed(() => {
if (!searchQuery.value) {
return filteredItems.value
}
const query = searchQuery.value.toLowerCase()
return filteredItems.value.filter(item =>
item.name.toLowerCase().includes(query)
)
})

const sortedItems = computed(() => {
return [...searchedItems.value].sort((a, b) => {
const aValue = a[sortBy.value]
const bValue = b[sortBy.value]
return aValue > bValue ? 1 : -1
})
})

// ❌ 避免:一次性计算所有逻辑
const processedItems = computed(() => {
let result = items.value

// 过滤
if (filterCategory.value !== 'all') {
result = result.filter(item => item.category === filterCategory.value)
}

// 搜索
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
result = result.filter(item => item.name.toLowerCase().includes(query))
}

// 排序
result = [...result].sort((a, b) => {
const aValue = a[sortBy.value]
const bValue = b[sortBy.value]
return aValue > bValue ? 1 : -1
})

return result
})

2. 侦听器性能优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import { watch, watchEffect, ref, nextTick } from 'vue'

const searchQuery = ref('')
const searchResults = ref([])

// ✅ 使用防抖优化搜索
let searchTimer = null
watch(searchQuery, (newQuery) => {
clearTimeout(searchTimer)
searchTimer = setTimeout(async () => {
if (newQuery.trim()) {
searchResults.value = await searchAPI(newQuery)
} else {
searchResults.value = []
}
}, 300)
})

// ✅ 深度侦听优化
const userForm = ref({
name: '',
email: '',
preferences: {
theme: 'light',
notifications: true
}
})

// 只侦听特定属性
watch(() => userForm.value.name, (newName) => {
// 只在姓名变化时执行
})

watch(() => userForm.value.preferences.theme, (newTheme) => {
// 只在主题变化时执行
document.documentElement.setAttribute('data-theme', newTheme)
})

// ✅ 使用 watchEffect 自动收集依赖
watchEffect(() => {
// 自动侦听 searchQuery 和 filterType 的变化
if (searchQuery.value && filterType.value) {
performSearch(searchQuery.value, filterType.value)
}
})

// ✅ 停止不必要的侦听器
const stopWatcher = watch(someRef, callback)

// 在组件卸载或条件满足时停止
if (conditionMet) {
stopWatcher()
}

构建和打包优化

1. Vite 配置优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
plugins: [vue()],

// 构建优化
build: {
// 代码分割
rollupOptions: {
output: {
manualChunks: {
// 将 Vue 相关库打包到一个 chunk
vue: ['vue', 'vue-router', 'pinia'],
// 将 UI 库单独打包
ui: ['element-plus', '@element-plus/icons-vue'],
// 将工具库单独打包
utils: ['lodash-es', 'dayjs', 'axios']
}
}
},

// 压缩配置
minify: 'terser',
terserOptions: {
compress: {
drop_console: true, // 生产环境移除 console
drop_debugger: true // 生产环境移除 debugger
}
},

// 资源内联阈值
assetsInlineLimit: 4096
},

// 开发服务器优化
server: {
hmr: {
overlay: false // 关闭错误遮罩层
}
},

// 依赖预构建
optimizeDeps: {
include: [
'vue',
'vue-router',
'pinia',
'element-plus',
'lodash-es'
]
}
})

2. 组件库按需引入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 使用 unplugin-auto-import 和 unplugin-vue-components
// vite.config.js
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()],
imports: ['vue', 'vue-router', 'pinia']
}),
Components({
resolvers: [ElementPlusResolver()]
})
]
})

// 组件中直接使用,无需手动导入
// <template>
// <el-button @click="handleClick">按钮</el-button>
// </template>

性能监控和分析

1. 性能监控组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<template>
<div>
<div v-if="showPerformanceInfo" class="performance-info">
<p>组件渲染时间: {{ renderTime }}ms</p>
<p>更新次数: {{ updateCount }}</p>
</div>

<slot />
</div>
</template>

<script setup>
import { ref, onMounted, onUpdated, onBeforeUpdate } from 'vue'

const showPerformanceInfo = ref(process.env.NODE_ENV === 'development')
const renderTime = ref(0)
const updateCount = ref(0)
let startTime = 0

onBeforeUpdate(() => {
startTime = performance.now()
})

onUpdated(() => {
renderTime.value = Math.round(performance.now() - startTime)
updateCount.value++
})

onMounted(() => {
// 监控长任务
if ('PerformanceObserver' in window) {
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
if (entry.duration > 50) {
console.warn(`长任务检测: ${entry.duration}ms`, entry)
}
})
})

observer.observe({ entryTypes: ['longtask'] })
}
})
</script>

2. 内存泄漏检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// composables/useMemoryMonitor.js
import { onUnmounted, ref } from 'vue'

export function useMemoryMonitor(componentName) {
const memoryUsage = ref(0)
let intervalId = null

function checkMemory() {
if (performance.memory) {
memoryUsage.value = Math.round(performance.memory.usedJSHeapSize / 1024 / 1024)

if (memoryUsage.value > 100) { // 超过100MB警告
console.warn(`${componentName} 内存使用过高: ${memoryUsage.value}MB`)
}
}
}

// 定期检查内存使用
intervalId = setInterval(checkMemory, 5000)

onUnmounted(() => {
if (intervalId) {
clearInterval(intervalId)
}
console.log(`${componentName} 组件已卸载`)
})

return { memoryUsage }
}

总结

Vue 3 的性能优化是一个系统性工程,需要从编译时优化、运行时优化、组件设计、数据管理等多个维度进行考虑。关键要点包括:

  1. 充分利用 Vue 3 的编译时优化:静态标记、静态提升等特性
  2. 合理使用响应式 API:根据数据特性选择 ref、reactive、shallowRef 等
  3. 组件级优化:虚拟滚动、懒加载、KeepAlive 缓存
  4. 代码分割和异步加载:路由级和组件级的按需加载
  5. 构建优化:合理的打包策略和资源优化
  6. 性能监控:建立完善的性能监控体系

在实际项目中,应该根据具体场景选择合适的优化策略,避免过度优化。记住,”过早的优化是万恶之源”,先确保功能正确,再针对性能瓶颈进行优化。通过合理的性能优化,可以显著提升 Vue 3 应用的用户体验和运行效率。

本站由 提供部署服务