Vue.js 组件进阶

一、自定义指令

1.1 什么是自定义指令

自定义指令是 Vue 提供的一种机制,允许你直接对 DOM 元素进行底层操作。当需要对普通 DOM 元素进行底层操作时,自定义指令非常有用。

1.2 注册自定义指令

全局注册

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// main.js
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

// 全局注册指令
app.directive('focus', {
mounted(el) {
el.focus()
}
})

app.mount('#app')

局部注册

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script>
export default {
directives: {
focus: {
mounted(el) {
el.focus()
}
}
}
}
</script>

<template>
<input v-focus />
</template>

1.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
app.directive('my-directive', {
// 绑定元素的父组件挂载前调用
beforeMount(el, binding, vnode, prevVnode) {
console.log('beforeMount')
},

// 绑定元素的父组件挂载时调用
mounted(el, binding, vnode, prevVnode) {
console.log('mounted')
},

// 父组件更新前调用
beforeUpdate(el, binding, vnode, prevVnode) {
console.log('beforeUpdate')
},

// 父组件更新后调用
updated(el, binding, vnode, prevVnode) {
console.log('updated')
},

// 父组件卸载前调用
beforeUnmount(el, binding, vnode, prevVnode) {
console.log('beforeUnmount')
},

// 父组件卸载后调用
unmounted(el, binding, vnode, prevVnode) {
console.log('unmounted')
}
})

1.4 指令参数

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
<template>
<!-- 简单使用 -->
<div v-color="'red'">红色</div>

<!-- 传递参数 -->
<div v-color:background="'blue'">蓝色背景</div>

<!-- 传递多个值 -->
<div v-color="{ color: 'green', opacity: 0.5 }">绿色半透明</div>

<!-- 使用修饰符 -->
<div v-color.delay="'yellow'">延迟显示黄色</div>
</template>

<script>
export default {
directives: {
color: {
mounted(el, binding) {
// binding.value: 传递的值
// binding.arg: 参数(如 'background')
// binding.modifiers: 修饰符对象(如 { delay: true })

if (binding.modifiers.delay) {
setTimeout(() => {
applyColor()
}, 1000)
} else {
applyColor()
}

function applyColor() {
if (binding.arg === 'background') {
el.style.backgroundColor = binding.value
} else if (typeof binding.value === 'object') {
el.style.color = binding.value.color
el.style.opacity = binding.value.opacity
} else {
el.style.color = binding.value
}
}
},

updated(el, binding) {
// 值更新时重新应用
el.style.color = binding.value
}
}
}
}
</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
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
// directives/index.js

// 自动聚焦指令
export const vFocus = {
mounted(el) {
el.focus()
}
}

// 防抖指令
export const vDebounce = {
mounted(el, binding) {
const [event, callback] = binding.value
let timer = null

el.addEventListener(event, () => {
clearTimeout(timer)
timer = setTimeout(() => {
callback()
}, 500)
})
}
}

// 点击外部指令
export const vClickOutside = {
mounted(el, binding) {
el._clickOutside = (event) => {
if (!(el === event.target || el.contains(event.target))) {
binding.value(event)
}
}
document.addEventListener('click', el._clickOutside)
},

unmounted(el) {
document.removeEventListener('click', el._clickOutside)
}
}

// 权限指令
export const vPermission = {
mounted(el, binding) {
const { value, arg } = binding
const permissions = JSON.parse(localStorage.getItem('permissions') || '[]')

if (!permissions.includes(value)) {
if (arg === 'disable') {
el.disabled = true
el.classList.add('disabled')
} else {
el.remove()
}
}
}
}

// 懒加载指令
export const vLazy = {
mounted(el, binding) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
el.src = binding.value
observer.unobserve(el)
}
})
})

observer.observe(el)
}
}

// 复制指令
export const vCopy = {
mounted(el, binding) {
el._copyValue = binding.value

el.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(el._copyValue)
console.log('复制成功')
} catch (err) {
console.error('复制失败:', err)
}
})
},

updated(el, binding) {
el._copyValue = binding.value
}
}

// 长按指令
export const vLongPress = {
mounted(el, binding) {
const callback = binding.value
let timer = null

const start = () => {
timer = setTimeout(() => {
callback()
}, 800)
}

const end = () => {
clearTimeout(timer)
}

el.addEventListener('touchstart', start)
el.addEventListener('touchend', end)
el.addEventListener('touchmove', end)

el._cleanup = () => {
el.removeEventListener('touchstart', start)
el.removeEventListener('touchend', end)
el.removeEventListener('touchmove', end)
}
},

unmounted(el) {
el._cleanup()
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import * as directives from './directives'

const app = createApp(App)

// 批量注册指令
Object.entries(directives).forEach(([name, directive]) => {
app.directive(name.replace('v', '').toLowerCase(), directive)
})

app.mount('#app')

二、Teleport 传送门

2.1 什么是 Teleport

Teleport 是 Vue 3 新增的内置组件,允许你将组件的一部分模板”传送”到该组件的 DOM 结构外层的位置。这对于模态框、通知等需要全局定位的组件非常有用。

2.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
<template>
<div class="app">
<h1>应用内容</h1>

<!-- 使用 Teleport 将模态框传送到 body -->
<Teleport to="body">
<div v-if="showModal" class="modal">
<h2>模态框标题</h2>
<p>模态框内容</p>
<button @click="showModal = false">关闭</button>
</div>
</Teleport>

<button @click="showModal = true">打开模态框</button>
</div>
</template>

<script>
export default {
data() {
return {
showModal: false
}
}
}
</script>

<style>
.modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
z-index: 1000;
}
</style>

2.3 Teleport 目标选择器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>
<!-- 传送到 body -->
<Teleport to="body">
<div>传送到 body</div>
</Teleport>

<!-- 传送到指定 ID -->
<Teleport to="#modals">
<div>传送到 #modals</div>
</Teleport>

<!-- 传送到指定 class -->
<Teleport to=".notification-container">
<div>传送到指定 class</div>
</Teleport>
</template>

2.4 条件渲染与禁用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
<!-- disabled 属性可以禁用 Teleport -->
<Teleport to="body" :disabled="!isMounted">
<div>内容</div>
</Teleport>
</template>

<script>
export default {
data() {
return {
isMounted: false
}
},
mounted() {
this.isMounted = true
}
}
</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
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
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
<!-- Modal.vue -->
<template>
<Teleport to="body">
<transition name="modal">
<div v-if="modelValue" class="modal-overlay" @click="handleOverlayClick">
<div class="modal-container" :style="{ width, maxWidth }">
<!-- 头部 -->
<div class="modal-header" v-if="title || $slots.header">
<slot name="header">
<h3>{{ title }}</h3>
</slot>
<button class="modal-close" @click="close">×</button>
</div>

<!-- 内容 -->
<div class="modal-body">
<slot></slot>
</div>

<!-- 底部 -->
<div class="modal-footer" v-if="$slots.footer">
<slot name="footer"></slot>
</div>
</div>
</div>
</transition>
</Teleport>
</template>

<script>
export default {
name: 'Modal',
props: {
modelValue: {
type: Boolean,
default: false
},
title: {
type: String,
default: ''
},
width: {
type: String,
default: 'auto'
},
maxWidth: {
type: String,
default: '600px'
},
closeOnClickOverlay: {
type: Boolean,
default: true
}
},

emits: ['update:modelValue'],

methods: {
close() {
this.$emit('update:modelValue', false)
},

handleOverlayClick() {
if (this.closeOnClickOverlay) {
this.close()
}
}
},

watch: {
modelValue(newValue) {
if (newValue) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
}
},

beforeUnmount() {
document.body.style.overflow = ''
}
}
</script>

<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}

.modal-container {
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
max-height: 80vh;
overflow-y: auto;
}

.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #e0e0e0;
}

.modal-body {
padding: 20px;
}

.modal-footer {
padding: 16px 20px;
border-top: 1px solid #e0e0e0;
display: flex;
justify-content: flex-end;
gap: 10px;
}

.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #999;
}

.modal-close:hover {
color: #333;
}

/* 过渡动画 */
.modal-enter-active, .modal-leave-active {
transition: opacity 0.3s;
}

.modal-enter-from, .modal-leave-to {
opacity: 0;
}
</style>

三、Suspense

3.1 什么是 Suspense

Suspense 是 Vue 3 新增的内置组件,用于协调异步依赖的加载状态。它可以在等待异步组件时显示加载状态。

3.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
<template>
<Suspense>
<!-- 异步组件 -->
<template #default>
<AsyncComponent />
</template>

<!-- 加载状态 -->
<template #fallback>
<div class="loading">加载中...</div>
</template>
</Suspense>
</template>

<script>
import { defineAsyncComponent } from 'vue'

export default {
components: {
AsyncComponent: defineAsyncComponent(() =>
import('./components/AsyncComponent.vue')
)
}
}
</script>

3.3 异步 setup

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- AsyncComponent.vue -->
<template>
<div>
<h2>{{ data.title }}</h2>
<p>{{ data.content }}</p>
</div>
</template>

<script>
export default {
async setup() {
// 异步获取数据
const response = await fetch('/api/data')
const data = await response.json()

return {
data
}
}
}
</script>

3.4 错误处理

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
<template>
<Suspense @pending="onPending" @resolve="onResolve" @reject="onReject">
<template #default>
<AsyncComponent />
</template>

<template #fallback>
<div class="loading">加载中...</div>
</template>
</Suspense>
</template>

<script>
import { onErrorCaptured, ref } from 'vue'
import AsyncComponent from './AsyncComponent.vue'

export default {
components: { AsyncComponent },

setup() {
const error = ref(null)

onErrorCaptured((e) => {
error.value = e
return false // 阻止错误继续传播
})

const onPending = () => {
console.log('加载中...')
}

const onResolve = () => {
console.log('加载完成')
}

const onReject = (error) => {
console.error('加载失败:', error)
}

return {
error,
onPending,
onResolve,
onReject
}
}
}
</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
<!-- ImageLoader.vue -->
<template>
<div class="image-loader">
<Suspense>
<template #default>
<AsyncImage :src="src" :alt="alt" />
</template>

<template #fallback>
<div class="image-placeholder">
<div class="spinner"></div>
<p>图片加载中...</p>
</div>
</template>
</Suspense>
</div>
</template>

<script>
import { defineAsyncComponent } from 'vue'

const AsyncImage = defineAsyncComponent(() =>
import('./AsyncImage.vue')
)

export default {
name: 'ImageLoader',
components: { AsyncImage },
props: {
src: String,
alt: String
}
}
</script>

<style scoped>
.image-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
background: #f5f5f5;
border-radius: 8px;
}

.spinner {
width: 40px;
height: 40px;
border: 3px solid #f3f3f3;
border-top: 3px solid #1890ff;
border-radius: 50%;
animation: spin 1s linear infinite;
}

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

四、异步组件

4.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
<script>
import { defineAsyncComponent } from 'vue'

// 简单定义
const AsyncComponent = defineAsyncComponent(() =>
import('./components/MyComponent.vue')
)

// 高级配置
const AsyncComponentWithOptions = defineAsyncComponent({
// 加载函数
loader: () => import('./components/MyComponent.vue'),

// 加载异步组件时使用的组件
loadingComponent: LoadingComponent,

// 加载失败时使用的组件
errorComponent: ErrorComponent,

// 在显示 loadingComponent 之前的延迟 | 默认值:200ms
delay: 200,

// 如果提供了 timeout,并且加载组件超时,
// 将显示错误组件 | 默认值:Infinity
timeout: 3000,

// 定义组件是否可挂起 | 默认值:true
suspensible: false,

// 错误处理函数
onError(error, retry, fail, attempts) {
if (error.message.includes('Failed to fetch')) {
retry()
} else {
fail()
}
}
})

export default {
components: {
AsyncComponent,
AsyncComponentWithOptions
}
}
</script>

4.2 配合路由懒加载

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

const routes = [
{
path: '/',
name: 'Home',
component: () => import(/* webpackChunkName: "home" */ '../views/Home.vue')
},
{
path: '/about',
name: 'About',
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
}
]

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

export default router

4.3 预加载异步组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<script>
import { defineAsyncComponent } from 'vue'

const AsyncComponent = defineAsyncComponent(() =>
import('./components/HeavyComponent.vue')
)

export default {
components: { AsyncComponent },

methods: {
preloadComponent() {
// 预加载组件
import('./components/HeavyComponent.vue')
}
},

mounted() {
// 鼠标悬停时预加载
this.$el.addEventListener('mouseenter', this.preloadComponent)
}
}
</script>

实践练习4: 创建一个智能异步组件加载器

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
// composables/useAsyncComponent.js
import { defineAsyncComponent, ref } from 'vue'

export function useAsyncComponent(loader, options = {}) {
const loading = ref(false)
const error = ref(null)
const loaded = ref(false)

const defaultOptions = {
loadingComponent: () => import('@/components/Loading.vue'),
errorComponent: () => import('@/components/Error.vue'),
delay: 200,
timeout: 10000
}

const finalOptions = { ...defaultOptions, ...options }

const asyncComponent = defineAsyncComponent({
loader: async () => {
loading.value = true
error.value = null

try {
const component = await loader()
loaded.value = true
return component
} catch (e) {
error.value = e
throw e
} finally {
loading.value = false
}
},

...finalOptions,

onError(e) {
console.error('Component load error:', e)
if (options.onError) {
options.onError(e)
}
}
})

return {
component: asyncComponent,
loading,
error,
loaded
}
}
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
<!-- SmartComponent.vue -->
<template>
<div>
<button @click="loadComponent">加载组件</button>

<Suspense v-if="shouldRender">
<template #default>
<component :is="component" />
</template>

<template #fallback>
<div class="loading">加载中...</div>
</template>
</Suspense>
</div>
</template>

<script setup>
import { ref } from 'vue'
import { useAsyncComponent } from '@/composables/useAsyncComponent'

const shouldRender = ref(false)

const { component, loading, error } = useAsyncComponent(
() => import('./HeavyComponent.vue'),
{
timeout: 5000,
onError: (e) => {
console.error('Failed to load component:', e)
}
}
)

const loadComponent = () => {
shouldRender.value = true
}
</script>

五、组合使用示例

5.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
58
59
60
61
// composables/useModal.js
import { createApp, h } from 'vue'
import Modal from '@/components/Modal.vue'

export function useModal() {
const show = (options) => {
return new Promise((resolve) => {
const container = document.createElement('div')
document.body.appendChild(container)

const app = createApp({
render() {
return h(Modal, {
modelValue: true,
title: options.title,
width: options.width,
onOk: () => {
close()
resolve(true)
},
onCancel: () => {
close()
resolve(false)
}
}, {
default: () => options.content
})
}
})

const close = () => {
app.unmount()
container.remove()
}

app.mount(container)
})
}

const confirm = (message, title = '确认') => {
return show({
title,
content: message,
width: '400px'
})
}

const alert = (message, title = '提示') => {
return show({
title,
content: message,
width: '400px'
})
}

return {
show,
confirm,
alert
}
}
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
<!-- App.vue -->
<template>
<div>
<button @click="showModal">显示模态框</button>
<button @click="showConfirm">确认对话框</button>
</div>
</template>

<script setup>
import { useModal } from '@/composables/useModal'

const { show, confirm } = useModal()

const showModal = () => {
show({
title: '用户信息',
content: '这是用户信息内容',
width: '500px'
})
}

const showConfirm = async () => {
const result = await confirm('确定要删除吗?', '删除确认')
if (result) {
console.log('用户点击了确认')
} else {
console.log('用户点击了取消')
}
}
</script>

六、性能优化技巧

6.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
<template>
<div>
<button @click="show = true">显示组件</button>

<!-- 使用 v-if 进行懒加载 -->
<HeavyComponent v-if="show" />
</div>
</template>

<script>
import { defineAsyncComponent } from 'vue'

export default {
components: {
HeavyComponent: defineAsyncComponent(() =>
import('./HeavyComponent.vue')
)
},

data() {
return {
show: false
}
}
}
</script>

6.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
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
<template>
<div class="virtual-list" @scroll="handleScroll">
<div class="scroll-content" :style="{ height: totalHeight + 'px' }">
<div
class="visible-items"
:style="{ transform: `translateY(${offset}px)` }"
>
<div
v-for="item in visibleItems"
:key="item.id"
class="item"
:style="{ height: itemHeight + 'px' }"
>
{{ item.content }}
</div>
</div>
</div>
</div>
</template>

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

export default {
props: {
items: Array,
itemHeight: { type: Number, default: 50 }
},

setup(props) {
const scrollTop = ref(0)
const containerHeight = 600

const totalHeight = computed(() => props.items.length * props.itemHeight)

const visibleCount = Math.ceil(containerHeight / props.itemHeight)

const startIndex = computed(() =>
Math.floor(scrollTop.value / props.itemHeight)
)

const endIndex = computed(() =>
Math.min(startIndex.value + visibleCount + 1, props.items.length)
)

const visibleItems = computed(() =>
props.items.slice(startIndex.value, endIndex.value)
)

const offset = computed(() => startIndex.value * props.itemHeight)

const handleScroll = (e) => {
scrollTop.value = e.target.scrollTop
}

return {
scrollTop,
totalHeight,
visibleItems,
offset,
handleScroll
}
}
}
</script>

<style scoped>
.virtual-list {
height: 600px;
overflow-y: auto;
border: 1px solid #ddd;
}

.scroll-content {
position: relative;
}

.visible-items {
position: absolute;
width: 100%;
}

.item {
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
padding: 0 20px;
}
</style>

总结

Vue 3 的组件进阶特性提供了强大的能力:

  1. 自定义指令: 直接操作 DOM,实现底层交互
  2. Teleport: 突破 DOM 层级限制,实现全局定位
  3. Suspense: 统一处理异步加载状态
  4. 异步组件: 优化应用性能,实现按需加载

合理使用这些特性,可以构建出更灵活、高性能的 Vue 应用。

扩展阅读