在构建组件库时,“复合组件”是一种非常出色的设计模式。它赋予组件使用者更高的灵活性,避免了将所有变体塞进一个臃肿的单体 API。同时,它也能让组件间的层级关系在 JSX 结构中得到清晰体现。
当然,复合组件并非万能钥匙。在某些场景下,直接使用 props 可能是更合适的选择。
一个不太好的例子
我们经常看到用复合组件来实现下拉选择框(Select)的例子,其结构类似于原生的 HTML <select> 元素。
import { Select, Option } from '@/components/select'
function ThemeSwitcher({ value, onChange }) {
return (
<Select value={value} onChange={onChange}>
<Option value="system">🤖</Option>
<Option value="light">☀️</Option>
<Option value="dark">🌑</Option>
</Select>
)
}
然而,我认为这个例子并不能很好地展现复合组件的优势,主要有两个原因。
1. 固定布局
复合组件的核心优势在于允许使用者灵活排列子组件的布局。但对于 Select 组件来说,这种灵活性几乎没有用武之地——选项通常被固定在菜单中并按顺序显示。
因此,有人希望在类型层面限制子组件类型,例如只允许 Option 被传入 Select。这在目前的 TypeScript 中难以实现(相关 issue 自 2018 年开放),并且我认为这种限制本身也没有必要。如果你的目标是严格限制子组件类型,那么复合组件可能根本就不是合适的抽象方式。
2. 动态内容
复合组件更适合“内容相对固定”的场景。上面的例子虽然写死了三个选项,但在真实项目中,选项数据往往来自 API 调用,是动态的。同时,多数设计规范会建议:当选项少于 5 个时,应避免使用下拉框以减少用户的点击和认知负担。
以 Adverity 的实践为例,起初我们也采用了复合组件实现 Select,但在多数场景下,我们不得不编写重复的映射代码:
import { Select, Option } from '@/components/select'
function UserSelect({ value, onChange }) {
const userQuery = useSuspenseQuery(userOptions)
return (
<Select value={value} onChange={onChange}>
{userQuery.data.map((option) => (
<Option value={option.value}>{option.label}</Option>
))}
</Select>
)
}
这种模式非常繁琐。我们后来改为使用 props 的形式:
import { Select } from '@/components/select'
function UserSelect({ value, onChange }) {
const userQuery = useSuspenseQuery(userOptions)
return (
<Select
value={value}
onChange={onChange}
options={userQuery.data}
/>
)
}
这不仅消除了重复的映射代码,还带来了更好的类型安全性,因为我们不再需要去“限制”子组件类型。我们可以轻松地将 Select 实现为一个泛型组件,确保 value、onChange 和 options 的类型完全匹配:
type SelectValue = string | number
type SelectOption<T> = { value: T; label: string }
type SelectProps<T extends SelectValue> = {
value: T
onChange: (value: T) => void
options: ReadonlyArray<SelectOption<T>>
}
插槽(Slots)模式
另一个案例是模态对话框组件。对于这种结构固定、顺序重要的组件,我们不希望用户拥有完全的自由去任意排列子组件(例如,把 DialogFooter 放在 DialogHeader 上方)。
在这种情况下,使用插槽(slots) 通常是更好的抽象方式:
function ModalDialog({ header, body, footer }) {
return (
<DialogRoot>
<DialogBackdrop />
<DialogContent>
<DialogHeader>{header}</DialogHeader>
<DialogBody>{body}</DialogBody>
<DialogFooter>{footer}</DialogFooter>
</DialogContent>
</DialogRoot>
)
}
// 使用示例:
<ModalDialog header="Hello" body="World" />
这种方式在保持灵活性的同时(可在固定位置注入任意 React 组件),避免了在每一个使用处重复编写相同的模板代码。
综上所述,我们可以总结出两个判断何时“不”适合使用复合组件的指标:
- 布局是固定的
- 内容是动态的
如果同时满足这两点,复合组件可能就不是最佳选择。
那么,复合组件真正适用的场景是什么?又该如何确保它们的类型安全呢?这需要我们在 前端框架/工程化 的实践中不断探索和总结。
一个更好的例子
复合组件更适合那些子组件布局可灵活调整且内容基本固定的场景,例如 <ButtonGroup>、<TabBar> 或 <RadioGroup>。
import { RadioGroup, RadioGroupItem } from '@/components/radio'
import { Flex } from '@/components/layout'
function ThemeSwitcher({ value, onChange }) {
return (
<RadioGroup value={value} onChange={onChange}>
<Flex direction={['row', 'column']} gap="sm">
<RadioGroupItem value="system">🤖</RadioGroupItem>
<RadioGroupItem value="light">☀️</RadioGroupItem>
<RadioGroupItem value="dark">🌑</RadioGroupItem>
</Flex>
</RadioGroup>
)
}
与 Select 的关键区别在于:我们明确希望用户能够灵活组合子组件,可能自定义布局、添加辅助文本等。即使选项是动态的,编写一个循环渲染也并非难事。
接下来就需要考虑类型安全了。例如,ThemeSwitcher 的 value 很可能不是任意字符串,而是特定的字面量类型:
type ThemeValue = 'system' | 'light' | 'dark'
我们可以让 RadioGroup 成为泛型组件,使 value 和 onChange 都使用 ThemeValue 类型。但问题来了——如何确保 RadioGroupItem 的 value 也能被静态检查呢?
类型安全的挑战
当然,我们也可以让 RadioGroupItem 成为泛型组件。但这样做的痛点在于:子组件不会自动继承父组件的泛型参数。这意味着,即使 RadioGroup 正确推断出了类型,我们仍需在每个子项上手动标注类型参数:
import { RadioGroup, RadioGroupItem } from '@/components/radio'
import { Flex } from '@/components/layout'
type ThemeValue = 'system' | 'light' | 'dark'
function ThemeSwitcher({ value, onChange }) {
return (
<RadioGroup value={value} onChange={onChange}>
<Flex direction={['row', 'column']} gap="sm">
<RadioGroupItem<ThemeValue> value="system">🤖</RadioGroupItem>
<RadioGroupItem<ThemeValue> value="light">☀️</RadioGroupItem>
<RadioGroupItem<ThemeValue> value="dark">🌑</RadioGroupItem>
</Flex>
</RadioGroup>
)
}
这种写法显然不够理想——手动标注既繁琐又容易遗漏。理想的设计是类型能够自动推断。对于复合组件而言,实现这一目标的最佳方式并非直接暴露这些组件,而是向用户提供一个“创建”它们的入口。
组件工厂模式(Component Factory Pattern)
这个模式的核心思想很简单:我们无法完全消除类型标注,但可以将其隐藏起来,让用户只需在一个地方显式指定类型。
具体做法是:我们不直接导出 RadioGroup 和 RadioGroupItem,而是导出一个名为 createRadioGroup 的函数。该函数接收一个泛型参数,然后返回一组类型已绑定好的组件。
import { RadioGroup, RadioGroupItem } from './internal/radio'
export const createRadioGroup = <T extends GroupValue = never>(): {
RadioGroup: (props: RadioGroupProps<T>) => JSX.Element
RadioGroupItem: (props: Item<T>) => JSX.Element
} => ({ RadioGroup, RadioGroupItem })
在运行时,这个函数只是简单地返回组件对象。但在类型层面,它将 RadioGroup 和 RadioGroupItem 的泛型参数绑定在一起。由于默认泛型为 never,用户必须显式传入类型参数才能正常使用。
使用示例如下:
import { createRadioGroup } from '@/components/radio'
import { Flex } from '@/components/layout'
type ThemeValue = 'system' | 'light' | 'dark'
const Theme = createRadioGroup<ThemeValue>()
function ThemeSwitcher({ value, onChange }) {
return (
<Theme.RadioGroup value={value} onChange={onChange}>
<Flex direction={['row', 'column']} gap="sm">
<Theme.RadioGroupItem value="system">🤖</Theme.RadioGroupItem>
<Theme.RadioGroupItem value="light">☀️</Theme.RadioGroupItem>
<Theme.RadioGroupItem value="dark">🌑</Theme.RadioGroupItem>
</Flex>
</Theme.RadioGroup>
)
}
当然,这种方式并非绝对安全——理论上你仍然可以创建另一个不同类型的 RadioGroup,并将其子项错误地传递给 Theme.RadioGroup。但这种错误的可能性已经大大降低了。
总体而言,“组件工厂模式”在保留复合组件灵活性的同时,显著增强了类型安全性。唯一的代价是:使用者不能直接导入组件,而是需要通过一个工厂函数来创建类型化的组件实例。
这个权衡通常是值得的,它能让设计系统中的复合组件真正实现灵活与安全的兼得。类似的设计思路和最佳实践,也常被收录于像 云栈社区 这样的开发者知识库中,供大家交流学习。