组件设计模式:Vue 3 下的 Compound Component 与 Headless UI
前言
在前端组件设计中,我们经常面临一个核心矛盾:组件需要提供足够的灵活性以适应不同场景,同时又需要保持足够的封装性以降低使用复杂度。 Compound Component(复合组件)模式和 Headless UI(无 UI 组件)模式是解决这一矛盾的两种经典设计模式。
一、问题:Props 驱动组件的局限
传统 props 驱动的 Tabs 组件会导致 "props 爆炸"——每增加一个功能就要加一个 prop,tab 标签只能是字符串无法自定义内容,难以扩展。
二、Compound Component 模式
2.1 核心思想
将一个复杂组件拆分为多个协作的子组件,通过 provide/inject 共享状态。
2.2 完整实现
// TabsContext.ts
export interface TabsContext {
activeTab: Ref<string | number>
tabs: Ref<Array<{ id: string | number; disabled?: boolean }>>
registerTab: (id: string | number, disabled?: boolean) => void
unregisterTab: (id: string | number) => void
selectTab: (id: string | number) => void
orientation: Ref<'horizontal' | 'vertical'>
}
export const TabsKey: InjectionKey<TabsContext> = Symbol('Tabs')
<!-- Tabs.vue (父组件) -->
<script setup lang="ts">
import { ref, provide, computed } from 'vue'
import { TabsKey, type TabsContext } from './TabsContext'
const props = withDefaults(defineProps<{
modelValue?: string | number
defaultTab?: string | number
orientation?: 'horizontal' | 'vertical'
}>(), { orientation: 'horizontal' })
const emit = defineEmits<{ 'update:modelValue': [value: string | number]; 'change': [value: string | number] }>()
const tabs = ref<Array<{ id: string | number; disabled?: boolean }>>([])
const internalActive = ref<string | number>(props.modelValue ?? props.defaultTab ?? '')
const activeTab = computed({
get: () => props.modelValue ?? internalActive.value,
set: (val) => { internalActive.value = val; emit('update:modelValue', val); emit('change', val) },
})
function registerTab(id: string | number, disabled?: boolean) {
if (!tabs.value.find(t => t.id === id)) {
tabs.value.push({ id, disabled })
if (!activeTab.value && !disabled) activeTab.value = id
}
}
function unregisterTab(id: string | number) { tabs.value = tabs.value.filter(t => t.id !== id) }
function selectTab(id: string | number) {
const tab = tabs.value.find(t => t.id === id)
if (tab?.disabled) return
activeTab.value = id
}
provide(TabsKey, { activeTab, tabs, registerTab, unregisterTab, selectTab, orientation: computed(() => props.orientation) })
</script>
<template><div :class="['tabs', `tabs--${orientation}`]"><slot /></div></template>
<!-- TabsTab.vue -->
<script setup lang="ts">
import { inject, computed, onMounted, onBeforeUnmount } from 'vue'
import { TabsKey } from './TabsContext'
const props = defineProps<{ id: string | number; disabled?: boolean }>()
const context = inject(TabsKey)!
const isActive = computed(() => context.activeTab.value === props.id)
onMounted(() => context.registerTab(props.id, props.disabled))
onBeforeUnmount(() => context.unregisterTab(props.id))
function handleKeydown(e: KeyboardEvent) {
const tabs = context.tabs.value.filter(t => !t.disabled)
const currentIndex = tabs.findIndex(t => t.id === props.id)
let nextIndex: number | null = null
if (e.key === 'ArrowRight') nextIndex = (currentIndex + 1) % tabs.length
if (e.key === 'ArrowLeft') nextIndex = (currentIndex - 1 + tabs.length) % tabs.length
if (e.key === 'Home') nextIndex = 0
if (e.key === 'End') nextIndex = tabs.length - 1
if (nextIndex !== null) { e.preventDefault(); context.selectTab(tabs[nextIndex].id) }
}
</script>
<template>
<button role="tab" :aria-selected="isActive" :tabindex="isActive ? 0 : -1"
:class="['tabs-tab', { 'tabs-tab--active': isActive }]" :disabled="disabled"
@click="context.selectTab(id)" @keydown="handleKeydown">
<slot :active="isActive" :disabled="disabled" />
</button>
</template>
2.3 使用方式
<template>
<Tabs v-model="activeTab" orientation="horizontal">
<TabsList>
<TabsTab id="overview">概览</TabsTab>
<TabsTab id="settings">设置</TabsTab>
<TabsTab id="analytics" disabled>分析</TabsTab>
</TabsList>
<TabsPanels>
<TabsPanel id="overview"><h2>项目概览</h2><p>这里是项目概况信息...</p></TabsPanel>
<TabsPanel id="settings"><h2>设置</h2><p>在这里配置项目设置...</p></TabsPanel>
</TabsPanels>
</Tabs>
</template>
2.4 优势
| 优势 | 说明 |
|---|---|
| 灵活组合 | 使用者自由组合子组件 |
| 插槽自由度 | 每个 tab 可以是任意 Vue 模板 |
| 关注点分离 | 每个子组件只关心自己的渲染逻辑 |
| 可扩展性 | 新增功能加新子组件,不改现有组件 |
| 类型安全 | 通过 InjectionKey 提供完整类型推导 |
三、Headless UI 模式
3.1 核心思想
Headless UI 将逻辑与UI 完全分离。组件只提供状态管理和行为逻辑,UI 渲染完全交给使用者通过渲染函数或作用域插槽控制。
3.2 Composable 方案
export function useTabs(options: UseTabsOptions = {}) {
const { defaultTab = '', orientation = 'horizontal', onChange } = options
const tabs = ref<Array<{ id: string | number; disabled?: boolean }>>([])
const activeTab = ref<string | number>(defaultTab)
function selectTab(id: string | number) {
const tab = tabs.value.find(t => t.id === id)
if (tab?.disabled) return
activeTab.value = id
onChange?.(id)
}
provide(TabsKey, { activeTab: computed(() => activeTab.value), tabs, ... })
return { activeTab: computed(() => activeTab.value), tabs, selectTab, isActive }
}
3.3 Render Function 方案
export const HeadlessTabs = defineComponent({
props: { defaultTab: { type: [String, Number], default: '' } },
setup(props, { slots }) {
const api = useTabs({ defaultTab: props.defaultTab })
return () => slots.default?.(api)
},
})
3.4 使用 Headless 组件
<HeadlessTabs default-tab="personal">
<template #default="{ tabs: tabList, activeTab, selectTab, isActive }">
<div class="my-custom-tabs">
<div class="tab-header">
<button v-for="tab in tabList" :key="tab.id"
@click="selectTab(tab.id)" :disabled="tab.disabled"
:class="['my-tab', { 'my-tab--active': isActive(tab.id) }]">
{{ tab.label }}
</button>
</div>
<div class="tab-content">
<div v-show="isActive('personal')"><h3>个人信息</h3></div>
<div v-show="isActive('security')"><h3>安全设置</h3></div>
</div>
</div>
</template>
</HeadlessTabs>
四、模式对比
| 维度 | Compound Component | Headless UI |
|---|---|---|
| 逻辑位置 | 父组件 + provide/inject | Composable 或 Render Function |
| UI 控制 | 预设 UI + 插槽定制 | 完全由使用者控制 |
| 灵活性 | 中高 | 极高 |
| 开发效率 | 高 | 中 |
| 一致性 | 较好 | 取决于使用者 |
| 适用场景 | UI 组件库、业务组件 | 高度定制化需求 |
| 类型安全 | 好 | 极好 |
五、最佳实践
| 实践 | 说明 |
|---|---|
| InjectionKey 类型化 | 始终使用 InjectionKey<T> 提供完整类型 |
| 防御性 inject | 检查 inject 返回值,提供有意义的错误信息 |
| Composable 优先 | 逻辑提取为 composable,组件仅做包装 |
| 作用域插槽传 API | 通过插槽作用域暴露完整 API 对象 |
| 清理副作用 | 在 onBeforeUnmount 中清理事件监听 |
| 可访问性内置 | 逻辑中集成 ARIA 属性和键盘导航 |
| 只读状态 | 对外暴露的状态使用 readonly 包裹 |
| Teleport 用于弹出层 | 下拉、模态框等使用 Teleport 避免层级问题 |
结语
Compound Component 适合需要一定 UI 约束但保留组合灵活性的场景,是组件库设计的首选。Headless UI 则是追求极致灵活性的方案,将逻辑与 UI 完全解耦。
一个成熟的组件库可以同时提供:Headless 核心供高级用户定制,Compound 组件作为开箱即用的封装,而传统 props 组件作为简单场景的快捷方式。这种多层次的设计才是真正面向不同用户需求的组件架构。
Comments | 0条评论