Vue.js 组件进阶
一、自定义指令
1.1 什么是自定义指令
自定义指令是 Vue 提供的一种机制,允许你直接对 DOM 元素进行底层操作。当需要对普通 DOM 元素进行底层操作时,自定义指令非常有用。
1.2 注册自定义指令
全局注册
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| 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
|
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
| 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
| import { createRouter, createWebHistory } from 'vue-router'
const routes = [ { path: '/', name: 'Home', component: () => import( '../views/Home.vue') }, { path: '/about', name: 'About', component: () => import( '../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
| 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
| 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 的组件进阶特性提供了强大的能力:
- 自定义指令: 直接操作 DOM,实现底层交互
- Teleport: 突破 DOM 层级限制,实现全局定位
- Suspense: 统一处理异步加载状态
- 异步组件: 优化应用性能,实现按需加载
合理使用这些特性,可以构建出更灵活、高性能的 Vue 应用。
扩展阅读