WSの小屋

组件设计模式: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条评论