Vue 3 组件开发最佳实践:可复用组件设计模式

Vue 3 组件开发最佳实践:可复用组件设计模式

前言

组件化是现代前端开发的核心思想之一,而在 Vue 3 中,借助 Composition API 和更完善的响应式系统,我们能够设计出更加灵活、可复用的组件。本文将深入探讨 Vue 3 组件开发的最佳实践,介绍多种可复用组件的设计模式,帮助开发者构建高质量的组件库。

组件设计基本原则

1. 单一职责原则

每个组件应该只负责一个明确的功能,避免功能过于复杂。

2. 开放封闭原则

组件对扩展开放,对修改封闭,通过合理的接口设计支持定制化。

3. 可组合性

组件应该易于与其他组件组合使用,形成更复杂的 UI 结构。

基础组件设计模式

1. Props 透传模式

vue 复制代码
<!-- 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. 插槽分发模式

vue 复制代码
<!-- 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>

使用示例:

vue 复制代码
<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 组件专注于逻辑处理,不包含任何模板,通过作用域插槽传递数据和方法:

vue 复制代码
<!-- 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>

使用示例:

vue 复制代码
<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 模式

复合组件模式允许相关组件协同工作,共享状态和配置:

vue 复制代码
<!-- 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>
vue 复制代码
<!-- TabNav.vue -->
<template>
  <div class="tab-nav">
    <slot />
  </div>
</template>

<style scoped>
.tab-nav {
  display: flex;
}
</style>
vue 复制代码
<!-- 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>
vue 复制代码
<!-- 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>

使用示例:

vue 复制代码
<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 仍然有用:

javascript 复制代码
// 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 函数管理复杂状态:

vue 复制代码
<!-- 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>

使用示例:

vue 复制代码
<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 模式

javascript 复制代码
// 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 库替代传统的事件总线:

javascript 复制代码
// eventBus.js
import mitt from 'mitt'

export const eventBus = mitt()

// 在组件中使用
// eventBus.emit('user-login', userInfo)
// eventBus.on('user-login', handler)

性能优化策略

1. 组件懒加载

javascript 复制代码
// router/index.js
const routes = [
  {
    path: '/heavy-component',
    component: () => import('@/components/HeavyComponent.vue')
  }
]

// 组件内部懒加载
const HeavyChart = defineAsyncComponent(() => 
  import('@/components/charts/HeavyChart.vue')
)

2. 虚拟滚动

vue 复制代码
<!-- 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 定义

javascript 复制代码
// 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. 可访问性考虑

vue 复制代码
<!-- 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,每种模式都有其适用场景。关键是要根据具体需求选择合适的设计模式,并遵循以下原则:

  1. 保持组件简洁:每个组件专注于单一功能
  2. 提供良好的 API:清晰的 Props 定义和事件接口
  3. 重视可访问性:确保所有用户都能正常使用组件
  4. 考虑性能影响:特别是在处理大量数据或复杂交互时
  5. 便于测试:设计易于测试的组件接口

通过合理运用这些设计模式和最佳实践,我们可以构建出既灵活又可靠的组件库,为整个应用提供一致且高质量的用户体验。记住,好的组件设计不是一次性的任务,而是需要在实践中不断迭代和完善的过程。

相关推荐
叫我詹躲躲2 小时前
Vue 3 动画效果实现:Transition和TransitionGroup详解
javascript·vue.js
叫我詹躲躲2 小时前
别再用mixin了!Vue3自定义Hooks让逻辑复用爽到飞起
javascript·vue.js
珑墨3 小时前
【迭代器】js 迭代器与可迭代对象终极详解
前端·javascript·vue.js
粉末的沉淀3 小时前
tauri:关闭窗口后最小化到托盘
前端·javascript·vue.js
南山安4 小时前
Vue学习:ref响应式数据、v-指令、computed
javascript·vue.js·面试
小胖霞4 小时前
企业级全栈 RBAC 实战 (11):菜单管理与无限层级树形表格
vue.js·前端框架·前端工程化
鲸落落丶4 小时前
Vue Router路由
前端·javascript·vue.js
米方4 小时前
ElementPlus 穿梭框支持批量穿梭
前端·javascript·vue.js
InkHeart4 小时前
uni-app开发路上的坑
前端·vue.js