Vue 3 组件开发最佳实践:可复用组件设计模式
Vue 3 组件开发最佳实践:可复用组件设计模式
前言
组件化是现代前端开发的核心思想之一,而在 Vue 3 中,借助 Composition API 和更完善的响应式系统,我们能够设计出更加灵活、可复用的组件。本文将深入探讨 Vue 3 组件开发的最佳实践,介绍多种可复用组件的设计模式,帮助开发者构建高质量的组件库。
组件设计基本原则
1. 单一职责原则
每个组件应该只负责一个明确的功能,避免功能过于复杂。
2. 开放封闭原则
组件对扩展开放,对修改封闭,通过合理的接口设计支持定制化。
3. 可组合性
组件应该易于与其他组件组合使用,形成更复杂的 UI 结构。
基础组件设计模式
1. Props 透传模式
<!-- BaseButton.vue -->
<template>
<button
:class="buttonClasses"
v-bind="$attrs"
@click="handleClick"
>
<slot />
</button>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
variant: {
type: String,
default: 'primary',
validator: (value) => ['primary', 'secondary', 'danger', 'ghost'].includes(value)
},
size: {
type: String,
default: 'medium',
validator: (value) => ['small', 'medium', 'large'].includes(value)
},
block: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['click'])
const buttonClasses = computed(() => [
'btn',
`btn--${props.variant}`,
`btn--${props.size}`,
{
'btn--block': props.block,
'btn--disabled': props.disabled
}
])
const handleClick = (event) => {
if (!props.disabled) {
emit('click', event)
}
}
// 允许父组件访问子组件实例
defineExpose({
focus: () => {
// 实现焦点管理
}
})
</script>
<style scoped>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s ease;
text-decoration: none;
}
.btn--primary {
background-color: #42b883;
color: white;
}
.btn--secondary {
background-color: #6c757d;
color: white;
}
.btn--danger {
background-color: #dc3545;
color: white;
}
.btn--ghost {
background-color: transparent;
color: #42b883;
border: 1px solid #42b883;
}
.btn--small {
padding: 4px 8px;
font-size: 12px;
}
.btn--medium {
padding: 8px 16px;
font-size: 14px;
}
.btn--large {
padding: 12px 24px;
font-size: 16px;
}
.btn--block {
display: flex;
width: 100%;
}
.btn--disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn:hover:not(.btn--disabled) {
opacity: 0.8;
transform: translateY(-1px);
}
</style>
2. 插槽分发模式
<!-- Card.vue -->
<template>
<div class="card" :class="cardClasses">
<!-- 默认插槽 -->
<div v-if="$slots.header || title" class="card__header">
<slot name="header">
<h3 class="card__title">{{ title }}</h3>
</slot>
</div>
<!-- 内容插槽 -->
<div class="card__body">
<slot />
</div>
<!-- 底部插槽 -->
<div v-if="$slots.footer" class="card__footer">
<slot name="footer" />
</div>
<!-- 操作区域插槽 -->
<div v-if="$slots.actions" class="card__actions">
<slot name="actions" />
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
title: {
type: String,
default: ''
},
bordered: {
type: Boolean,
default: true
},
shadow: {
type: Boolean,
default: false
},
hoverable: {
type: Boolean,
default: false
}
})
const cardClasses = computed(() => ({
'card--bordered': props.bordered,
'card--shadow': props.shadow,
'card--hoverable': props.hoverable
}))
</script>
<style scoped>
.card {
background: #fff;
border-radius: 8px;
}
.card--bordered {
border: 1px solid #e5e5e5;
}
.card--shadow {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.card--hoverable:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.card__header {
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
}
.card__title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #333;
}
.card__body {
padding: 24px;
}
.card__footer {
padding: 16px 24px;
border-top: 1px solid #f0f0f0;
}
.card__actions {
padding: 16px 24px;
text-align: right;
}
</style>
使用示例:
<template>
<Card title="用户信息" bordered hoverable>
<template #header>
<div class="custom-header">
<h3>用户详情</h3>
<BaseButton size="small" variant="ghost">编辑</BaseButton>
</div>
</template>
<p>这里是卡片内容</p>
<template #footer>
<div class="card-footer">
<span>创建时间: 2023-01-01</span>
</div>
</template>
<template #actions>
<BaseButton variant="primary">保存</BaseButton>
<BaseButton variant="ghost">取消</BaseButton>
</template>
</Card>
</template>
高级组件设计模式
1. Renderless 组件模式
Renderless 组件专注于逻辑处理,不包含任何模板,通过作用域插槽传递数据和方法:
<!-- FetchData.vue -->
<template>
<slot
:loading="loading"
:data="data"
:error="error"
:refetch="fetchData"
/>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const props = defineProps({
url: {
type: String,
required: true
},
immediate: {
type: Boolean,
default: true
}
})
const loading = ref(false)
const data = ref(null)
const error = ref(null)
const fetchData = async () => {
loading.value = true
error.value = null
try {
const response = await fetch(props.url)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
data.value = await response.json()
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
onMounted(() => {
if (props.immediate) {
fetchData()
}
})
defineExpose({
fetchData
})
</script>
使用示例:
<template>
<FetchData url="/api/users" v-slot="{ loading, data, error, refetch }">
<div class="user-list">
<div v-if="loading">加载中...</div>
<div v-else-if="error">错误: {{ error }}</div>
<template v-else>
<div v-for="user in data" :key="user.id" class="user-item">
{{ user.name }}
</div>
<button @click="refetch">刷新</button>
</template>
</div>
</FetchData>
</template>
2. Compound Components 模式
复合组件模式允许相关组件协同工作,共享状态和配置:
<!-- Tabs.vue -->
<template>
<div class="tabs">
<div class="tabs__nav" role="tablist">
<slot name="nav" :active-key="activeKey" :change-tab="changeTab" />
</div>
<div class="tabs__content">
<slot :active-key="activeKey" />
</div>
</div>
</template>
<script setup>
import { ref, provide } from 'vue'
const props = defineProps({
modelValue: {
type: [String, Number],
default: ''
}
})
const emit = defineEmits(['update:modelValue'])
const activeKey = ref(props.modelValue)
const changeTab = (key) => {
activeKey.value = key
emit('update:modelValue', key)
}
// 提供给子组件使用的上下文
provide('tabs-context', {
activeKey,
changeTab
})
</script>
<style scoped>
.tabs {
border: 1px solid #e5e5e5;
border-radius: 8px;
overflow: hidden;
}
.tabs__nav {
display: flex;
background-color: #f8f9fa;
border-bottom: 1px solid #e5e5e5;
}
.tabs__content {
padding: 24px;
}
</style>
<!-- TabNav.vue -->
<template>
<div class="tab-nav">
<slot />
</div>
</template>
<style scoped>
.tab-nav {
display: flex;
}
</style>
<!-- TabNavItem.vue -->
<template>
<button
:class="classes"
:aria-selected="isActive"
@click="handleClick"
>
<slot />
</button>
</template>
<script setup>
import { inject, computed } from 'vue'
const props = defineProps({
tabKey: {
type: [String, Number],
required: true
},
disabled: {
type: Boolean,
default: false
}
})
const tabsContext = inject('tabs-context')
const isActive = computed(() => tabsContext.activeKey.value === props.tabKey)
const classes = computed(() => [
'tab-nav-item',
{
'tab-nav-item--active': isActive.value,
'tab-nav-item--disabled': props.disabled
}
])
const handleClick = () => {
if (!props.disabled) {
tabsContext.changeTab(props.tabKey)
}
}
</script>
<style scoped>
.tab-nav-item {
padding: 12px 24px;
border: none;
background: transparent;
cursor: pointer;
font-size: 14px;
color: #666;
transition: all 0.2s ease;
}
.tab-nav-item:hover:not(.tab-nav-item--disabled) {
color: #42b883;
background-color: rgba(66, 184, 131, 0.1);
}
.tab-nav-item--active {
color: #42b883;
font-weight: 600;
background-color: #fff;
border-bottom: 2px solid #42b883;
}
.tab-nav-item--disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
<!-- TabPanel.vue -->
<template>
<div v-show="isActive" class="tab-panel" role="tabpanel">
<slot />
</div>
</template>
<script setup>
import { inject, computed } from 'vue'
const props = defineProps({
tabKey: {
type: [String, Number],
required: true
}
})
const tabsContext = inject('tabs-context')
const isActive = computed(() => tabsContext.activeKey.value === props.tabKey)
</script>
<style scoped>
.tab-panel {
outline: none;
}
</style>
使用示例:
<template>
<Tabs v-model="activeTab">
<template #nav="{ activeKey, changeTab }">
<TabNavItem tab-key="profile">个人信息</TabNavItem>
<TabNavItem tab-key="settings">设置</TabNavItem>
<TabNavItem tab-key="security" disabled>安全</TabNavItem>
</template>
<TabPanel tab-key="profile">
<p>这是个人信息面板</p>
</TabPanel>
<TabPanel tab-key="settings">
<p>这是设置面板</p>
</TabPanel>
<TabPanel tab-key="security">
<p>这是安全面板</p>
</TabPanel>
</Tabs>
</template>
<script setup>
import { ref } from 'vue'
const activeTab = ref('profile')
</script>
3. Higher-Order Component (HOC) 模式
虽然 Vue 更推荐使用 Composition API,但在某些场景下 HOC 仍然有用:
// withLoading.js
import { h, ref, onMounted } from 'vue'
export function withLoading(WrappedComponent, loadingMessage = '加载中...') {
return {
name: `WithLoading(${WrappedComponent.name || 'Component'})`,
inheritAttrs: false,
props: WrappedComponent.props,
emits: WrappedComponent.emits,
setup(props, { attrs, slots, emit }) {
const isLoading = ref(true)
onMounted(() => {
// 模拟异步操作
setTimeout(() => {
isLoading.value = false
}, 1000)
})
return () => {
if (isLoading.value) {
return h('div', { class: 'loading-wrapper' }, loadingMessage)
}
return h(WrappedComponent, {
...props,
...attrs,
on: Object.keys(emit).reduce((acc, key) => {
acc[key] = (...args) => emit(key, ...args)
return acc
}, {})
}, slots)
}
}
}
}
4. State Reducer 模式
借鉴 React 的理念,通过 reducer 函数管理复杂状态:
<!-- Toggle.vue -->
<template>
<div class="toggle">
<slot
:on="on"
:toggle="toggle"
:set-on="setOn"
:set-off="setOff"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
reducer: {
type: Function,
default: null
}
})
const emit = defineEmits(['update:modelValue'])
const internalOn = ref(props.modelValue)
const getState = () => ({
on: internalOn.value
})
const dispatch = (action) => {
const changes = props.reducer
? props.reducer(getState(), action)
: defaultReducer(getState(), action)
if (changes.on !== undefined) {
internalOn.value = changes.on
emit('update:modelValue', changes.on)
}
}
const defaultReducer = (state, action) => {
switch (action.type) {
case 'toggle':
return { on: !state.on }
case 'setOn':
return { on: true }
case 'setOff':
return { on: false }
default:
throw new Error(`Unknown action type: ${action.type}`)
}
}
const toggle = () => dispatch({ type: 'toggle' })
const setOn = () => dispatch({ type: 'setOn' })
const setOff = () => dispatch({ type: 'setOff' })
defineExpose({
toggle,
setOn,
setOff
})
</script>
使用示例:
<template>
<Toggle :reducer="toggleReducer" v-slot="{ on, toggle, setOn, setOff }">
<div class="toggle-demo">
<p>状态: {{ on ? '开启' : '关闭' }}</p>
<BaseButton @click="toggle">切换</BaseButton>
<BaseButton @click="setOn">开启</BaseButton>
<BaseButton @click="setOff">关闭</BaseButton>
</div>
</Toggle>
</template>
<script setup>
const toggleReducer = (state, action) => {
switch (action.type) {
case 'toggle':
// 添加日志记录
console.log('Toggle state changed:', !state.on)
return { on: !state.on }
case 'setOn':
return { on: true }
case 'setOff':
return { on: false }
default:
return state
}
}
</script>
组件通信最佳实践
1. Provide/Inject 模式
// theme.js
import { ref, readonly, computed } from 'vue'
const themeSymbol = Symbol('theme')
export function createThemeStore() {
const currentTheme = ref('light')
const themes = {
light: {
primary: '#42b883',
background: '#ffffff',
text: '#333333'
},
dark: {
primary: '#42b883',
background: '#1a1a1a',
text: '#ffffff'
}
}
const toggleTheme = () => {
currentTheme.value = currentTheme.value === 'light' ? 'dark' : 'light'
}
const themeConfig = computed(() => themes[currentTheme.value])
return {
currentTheme: readonly(currentTheme),
themeConfig,
toggleTheme
}
}
export function provideTheme(themeStore) {
provide(themeSymbol, themeStore)
}
export function useTheme() {
const themeStore = inject(themeSymbol)
if (!themeStore) {
throw new Error('useTheme must be used within provideTheme')
}
return themeStore
}
2. Event Bus 替代方案
使用 mitt 库替代传统的事件总线:
// eventBus.js
import mitt from 'mitt'
export const eventBus = mitt()
// 在组件中使用
// eventBus.emit('user-login', userInfo)
// eventBus.on('user-login', handler)
性能优化策略
1. 组件懒加载
// router/index.js
const routes = [
{
path: '/heavy-component',
component: () => import('@/components/HeavyComponent.vue')
}
]
// 组件内部懒加载
const HeavyChart = defineAsyncComponent(() =>
import('@/components/charts/HeavyChart.vue')
)
2. 虚拟滚动
<!-- VirtualList.vue -->
<template>
<div
ref="containerRef"
class="virtual-list"
@scroll="handleScroll"
>
<div :style="{ height: totalHeight + 'px' }" class="virtual-list__spacer">
<div
:style="{ transform: `translateY(${offsetY}px)` }"
class="virtual-list__content"
>
<div
v-for="item in visibleItems"
:key="item.id"
:style="{ height: itemHeight + 'px' }"
class="virtual-list__item"
>
<slot :item="item" />
</div>
</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
},
bufferSize: {
type: Number,
default: 5
}
})
const containerRef = ref(null)
const scrollTop = ref(0)
const totalHeight = computed(() => props.items.length * props.itemHeight)
const startIndex = computed(() => {
return Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.bufferSize)
})
const endIndex = computed(() => {
const containerHeight = containerRef.value?.clientHeight || 0
return Math.min(
props.items.length - 1,
Math.floor((scrollTop.value + containerHeight) / props.itemHeight) + props.bufferSize
)
})
const visibleItems = computed(() => {
return props.items.slice(startIndex.value, endIndex.value + 1)
})
const offsetY = computed(() => {
return startIndex.value * props.itemHeight
})
const handleScroll = () => {
scrollTop.value = containerRef.value.scrollTop
}
onMounted(() => {
// 初始化滚动监听
})
onUnmounted(() => {
// 清理资源
})
</script>
<style scoped>
.virtual-list {
height: 400px;
overflow-y: auto;
border: 1px solid #e5e5e5;
}
.virtual-list__spacer {
position: relative;
}
.virtual-list__content {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.virtual-list__item {
display: flex;
align-items: center;
padding: 0 16px;
border-bottom: 1px solid #f0f0f0;
}
</style>
测试友好的组件设计
1. 明确的 Props 定义
// Button.test.js
import { mount } from '@vue/test-utils'
import BaseButton from '@/components/BaseButton.vue'
describe('BaseButton', () => {
test('renders slot content', () => {
const wrapper = mount(BaseButton, {
slots: {
default: 'Click me'
}
})
expect(wrapper.text()).toContain('Click me')
})
test('emits click event when clicked', async () => {
const wrapper = mount(BaseButton)
await wrapper.trigger('click')
expect(wrapper.emitted()).toHaveProperty('click')
})
test('applies correct CSS classes based on props', () => {
const wrapper = mount(BaseButton, {
props: {
variant: 'primary',
size: 'large'
}
})
expect(wrapper.classes()).toContain('btn--primary')
expect(wrapper.classes()).toContain('btn--large')
})
})
2. 可访问性考虑
<!-- AccessibleModal.vue -->
<template>
<teleport to="body">
<div
v-if="visible"
ref="modalRef"
role="dialog"
aria-modal="true"
:aria-labelledby="titleId"
:aria-describedby="descriptionId"
class="modal"
@keydown.esc="close"
>
<div class="modal__overlay" @click="close"></div>
<div class="modal__content" ref="contentRef">
<div class="modal__header">
<h2 :id="titleId" class="modal__title">{{ title }}</h2>
<button
type="button"
class="modal__close"
@click="close"
aria-label="关闭对话框"
>
×
</button>
</div>
<div :id="descriptionId" class="modal__body">
<slot />
</div>
<div v-if="$slots.footer" class="modal__footer">
<slot name="footer" />
</div>
</div>
</div>
</teleport>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
title: {
type: String,
required: true
}
})
const emit = defineEmits(['update:visible', 'close'])
const modalRef = ref(null)
const contentRef = ref(null)
const titleId = `modal-title-${Math.random().toString(36).substr(2, 9)}`
const descriptionId = `modal-desc-${Math.random().toString(36).substr(2, 9)}`
const close = () => {
emit('update:visible', false)
emit('close')
}
watch(() => props.visible, async (newVal) => {
if (newVal) {
await nextTick()
// 自动聚焦到模态框
contentRef.value?.focus()
}
})
</script>
结语
Vue 3 组件开发的最佳实践涉及多个方面,从基础的 Props 和插槽使用,到高级的设计模式如 Renderless 组件和 Compound Components,每种模式都有其适用场景。关键是要根据具体需求选择合适的设计模式,并遵循以下原则:
- 保持组件简洁:每个组件专注于单一功能
- 提供良好的 API:清晰的 Props 定义和事件接口
- 重视可访问性:确保所有用户都能正常使用组件
- 考虑性能影响:特别是在处理大量数据或复杂交互时
- 便于测试:设计易于测试的组件接口
通过合理运用这些设计模式和最佳实践,我们可以构建出既灵活又可靠的组件库,为整个应用提供一致且高质量的用户体验。记住,好的组件设计不是一次性的任务,而是需要在实践中不断迭代和完善的过程。