组件 API 设计:少传布尔值,多用组合
从一个让我头疼的 Card 组件说起
上个月我在重构项目里的一个 Card 组件。它最初只有一个 title 和一个 children,简洁好用。半年后,需求加了一个又一个,组件变成了这样:
<Card
title="用户信息"
showIcon
withBadge
isLoading={false}
highlighted
dense
collapsible
defaultExpanded={false}
showActions
showDivider
bordered
hoverable
compact
>
{/* ... */}
</Card>13 个布尔值。每次加一个新特性,我都要在组件内部多加一组条件判断,code review 时也看不出这个组件到底长什么样。我花了两天时间把它拆成组合式 API,拆完之后组件文件从 480 行缩到 120 行,调用方的代码反而更好读了。
这篇文章聊的就是这件事——组件的布尔属性是怎么一步步失控的,以及用组合模式设计组件 API 的几条实操经验。
布尔属性的组合爆炸
布尔属性刚出现时人畜无害。一个 primary,一个 compact,语义清楚,用起来也方便。问题出在第二个、第三个、第四个布尔属性加进来之后。
5 个布尔属性意味着 2⁵ = 32 种可能的状态组合。其中绝大多数是无意义的甚至矛盾的——primary 和 outline 同时为 true 是什么效果?isLoading 为 true 时 highlighted 还有意义吗?这些问题在写组件的时候不会暴露,等到调用方踩坑时才显现。
以下是我在实际项目中总结的几个信号:
| 信号 | 说明 | 例子 |
|---|---|---|
| 布尔属性超过 3 个 | 组合数已经超过 8,调用方开始记不住 | <Card showIcon showBadge showTag collapsible hoverable> |
| 总是一起出现 | 3 个布尔值经常同时为 true 或同时为 false,说明它们属于同一概念 | withHeader、withFooter、withActions |
| 需要注释才能看懂 | 调用方必须写注释说明每个布尔值的含义 | {/* 显示分割线但不显示操作区 */} |
| 条件分支超过 3 层 | 组件内部出现 a && b && !c ? ... : ... 这种嵌套条件 | 维护者需要脑内跑真值表 |
这些信号背后的根本问题不是「布尔值不好」,而是组件承担了太多职责。SpiceFactory 在 2019 年的文章里把这类问题称为「布尔陷阱」(Boolean Trap)——用少量布尔值的组合去表达本该是独立概念的变化点。
React 官方文档在设计原则中明确提到:「React 团队更偏好组合(composition)而非配置(configuration)。」框架本身就是用嵌套的元素来描述结构,而不是用一个深层嵌套的 props 对象。
Kyle Shevlin 也提过一条实用的判断标准:当一个组件的 prop 超过 8 个,或者需要阅读文档才能知道哪几个布尔值可以一起传,这个 API 就已经过度设计了。
组合模式的核心思路
在动手拆组件之前,先分清组件里的稳定结构和变化点。
稳定结构是组件不变的部分——Card 的容器、Button 的点击区域、Modal 的遮罩层。这些写死在组件里,不需要调用方控制。
变化点是调用方可能需要定制的部分。变化点又分三种:
- 内容变化:调用方想放不同的内容 → 用
children - 区域变化:组件有多个可定制区域 → 用命名插槽或子组件
- 样式变化:只是颜色、大小不同 → 用少量
variant属性
区分完变化点,再看用哪种组合方式表达。
| 模式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
children | 内容变化,结构单一 | 最简单、最灵活 | 无法控制内部结构 |
| 命名插槽 (named slots) | 多个独立区域 | 结构清晰、区域可独立控制 | 需要定义多个 prop |
| 复合组件 (Compound) | 结构复杂、子组件需共享状态 | 最灵活、扩展性最好 | 初始设计成本略高 |
三种模式不是互斥的。一个设计良好的复合组件,子组件本身往往也用 children 来接收内容。
配置式 vs 组合式:调用方视角
同样是表达一个「带图标的用户卡片」,两种 API 的写法差异很大:
// ❌ 配置式:调用方需要记住每个 prop 的含义和组合规则
<UserCard
name="张三"
showAvatar
avatarSize="md"
showStatus
statusPosition="top-right"
showBadge
badgeColor="green"
/>
// ✅ 组合式:调用方直接描述想要的结构
<UserCard>
<UserCard.Avatar src="..." status="online" />
<UserCard.Name>张三</UserCard.Name>
<UserCard.Badge color="green">在线</UserCard.Badge>
</UserCard>组合式的 JSX 结构直接反映了渲染结果,不需要查文档就能看懂。
两种设计思路的深层对比
| 维度 | 配置式 (布尔属性堆叠) | 组合式 (子组件组合) |
|---|---|---|
| 调用方学习成本 | 需要记住哪些 prop 能组合 | 看 JSX 结构就知道怎么用 |
| 类型安全 | 互斥 prop 难以用 TypeScript 约束 | 可用联合类型精确约束 |
| 可维护性 | 改一个 prop 可能影响多个条件分支 | 改一个子组件不影响其他 |
| 灵活性 | 受限于预设的 prop 组合 | 调用方自由组合 |
| Tree Shaking | 整个组件一起打包 | 未使用的子组件可被摇掉 |
| 扩展成本 | 每加一个特性需改核心组件 | 加一个子组件即可 |
| 可测试性 | 需覆盖所有布尔值组合 | 子组件独立测试 |
这张表不是要完全否定配置式。当变化点确实只是简单的样式差异——比如 Button 的 primary 和 secondary——用一个 variant 属性比拆成 PrimaryButton 和 SecondaryButton 更合理。关键判断依据是:这个变化点是结构性的还是表面性的。
决策流程
设计组件 API 时,我通常按下面的流程来判断用哪种方式:
流程的核心逻辑:先数 props 数量判断是否真的需要重构,再根据变化点类型选择不同的处理方式。结构性的变化用复合组件,样式性的变化用 variant,行为性的配置合并成对象。最后用 TypeScript 类型系统兜底,让不合法的 prop 组合在编译阶段就报错。
案例一:Card 组件从布尔属性到复合组件
场景
项目里有个 Card 组件,最初只有 title 和 children,后来陆续加了 showHeader、showFooter、showActions、collapsible、compact。
翻车
调用方开始写出这样的代码:
// ❌ 布尔属性堆叠,调用方无法直观理解组件结构
<Card
title="用户信息"
showHeader
showFooter={false}
showActions
collapsible
defaultExpanded
compact={isMobile}
>
<UserInfo user={user} />
<div className="card-footer-actions">
<Button>编辑</Button>
<Button>删除</Button>
</div>
</Card>组件内部的条件分支变成了这样:
// ❌ 组件内部:条件分支森林
function Card({
title,
showHeader = true,
showFooter = true,
showActions = false,
collapsible = false,
defaultExpanded = true,
compact = false,
children,
}) {
const [expanded, setExpanded] = useState(defaultExpanded)
return (
<div className={cn('card', compact && 'card-compact')}>
{showHeader && (
<div className="card-header">
<span>{title}</span>
{collapsible && (
<button onClick={() => setExpanded(!expanded)}>
{expanded ? '收起' : '展开'}
</button>
)}
</div>
)}
{(!collapsible || expanded) && (
<div className="card-body">{children}</div>
)}
{showFooter && showActions && (
<div className="card-actions">
{/* 这里耦合了 footer 和 actions 两个概念 */}
</div>
)}
</div>
)
}问题很明显:showFooter 和 showActions 耦合在一起(showActions 为 true 但 showFooter 为 false 时,操作区不显示),调用方很难预测行为。
修复
用复合组件模式重构,把 Card 拆成 Card、Card.Header、Card.Body、Card.Actions 三个子组件:
// ✅ 复合组件:每个区域独立控制,结构一目了然
function Card({ children }: { children: React.ReactNode }) {
return <div className="card">{children}</div>
}
function CardHeader({
title,
children,
}: {
title?: string
children?: React.ReactNode
}) {
return (
<div className="card-header">
{title && <span>{title}</span>}
{children}
</div>
)
}
function CardBody({ children }: { children: React.ReactNode }) {
return <div className="card-body">{children}</div>
}
function CardActions({ children }: { children: React.ReactNode }) {
return <div className="card-actions">{children}</div>
}
Card.Header = CardHeader
Card.Body = CardBody
Card.Actions = CardActions调用方直接组合需要的部分:
// ✅ 调用方:结构和视觉层级完全一致
<Card>
<Card.Header title="用户信息" />
<Card.Body>
<UserInfo user={user} />
</Card.Body>
<Card.Actions>
<Button>编辑</Button>
<Button>删除</Button>
</Card.Actions>
</Card>子组件状态共享
复合组件的一个常见顾虑是子组件之间怎么共享状态。比如 Card 需要支持折叠,Card.Header 里的按钮要能控制 Card.Body 的显隐。
用 React Context 解决,而不是 prop 层层透传:
// ✅ 通过 Context 共享状态,避免 prop 透传
const CardContext = createContext<{
expanded: boolean
toggle: () => void
} | null>(null)
function Card({ children, defaultExpanded = true }: CardProps) {
const [expanded, setExpanded] = useState(defaultExpanded)
const toggle = useCallback(() => setExpanded((v) => !v), [])
return (
<CardContext.Provider value={{ expanded, toggle }}>
<div className="card">{children}</div>
</CardContext.Provider>
)
}
function CardHeader({ title, children }: CardHeaderProps) {
const { toggle } = useCardContext()
return (
<div className="card-header">
<span>{title}</span>
{children}
<button onClick={toggle}>切换</button>
</div>
)
}
function CardBody({ children }: { children: React.ReactNode }) {
const { expanded } = useCardContext()
if (!expanded) return null
return <div className="card-body">{children}</div>
}这样 Card 和 Card.Header 之间即使隔了好几层组件,状态也能直接通信。Radix UI 的 Primitives 库就是这个思路——Dialog、DropdownMenu、Accordion 每个组件都用 Context 把状态暴露给子组件,调用方不需要手动传递任何状态 prop。
案例二:Button 组件从布尔属性到 variant
场景
一个 Button 组件,需要支持 primary、outline、ghost、danger 四种样式变体,以及 sm、md、lg 三种尺寸。
翻车
用布尔属性表达:
// ❌ 四种样式 × 三种尺寸 = 12 个 prop
<Button primary outline sm>
提交
</Button>
// primary 和 outline 同时为 true 是什么效果?
// outline 和 ghost 同时为 true 呢?4 个布尔属性就有 16 种组合,大量无意义。
修复
用一个 variant 属性替代所有样式布尔值,用一个 size 属性替代尺寸布尔值:
// ✅ 一个 variant 替代多个布尔属性
<Button variant="primary-outline" size="sm">
提交
</Button>
// 或者用更细粒度的变体命名
<Button variant="primary" size="sm">
提交
</Button>
<Button variant="outline" size="sm">
取消
</Button>
<Button variant="ghost" size="md">
更多
</Button>内部实现也变简单了——用一个映射表替代条件分支:
// ✅ 映射表替代条件分支
const variantStyles: Record<string, string> = {
primary: 'bg-blue-600 text-white',
secondary: 'bg-gray-200 text-gray-800',
outline: 'border border-gray-300 bg-transparent',
ghost: 'bg-transparent hover:bg-gray-100',
danger: 'bg-red-600 text-white',
'primary-outline': 'border border-blue-600 text-blue-600 bg-transparent',
}
function Button({
variant = 'primary',
size = 'md',
children,
...props
}: ButtonProps) {
return (
<button className={cn('btn', variantStyles[variant], sizeStyles[size])} {...props}>
{children}
</button>
)
}案例三:Modal 组件从散落配置到子组件
场景
一个 Modal 组件,有 showClose、showMask、closable、maskClosable、showFooter、footerAlign、centered 这些属性。
翻车
// ❌ 7 个布尔值,closable 和 showClose 语义重叠
<Modal
open={visible}
showClose
showMask
closable
maskClosable={false}
showFooter
footerAlign="right"
centered
onClose={handleClose}
>
<p>确认删除这条记录?</p>
</Modal>closable 和 showClose 表达的是同一个意思,maskClosable 和 showMask 又有一层隐含依赖(不显示遮罩时 maskClosable 就没意义了)。这些依赖关系在类型系统里表达不出来,全靠调用方自己理解。
修复
把行为相关的属性按职责分组,把结构相关的部分拆成子组件:
// ✅ 按职责分组 + 子组件化
<Modal open={visible} onClose={handleClose}>
<Modal.Header closable>确认操作</Modal.Header>
<Modal.Body>
<p>确认删除这条记录?</p>
</Modal.Body>
<Modal.Footer align="right">
<Button variant="ghost" onClick={handleClose}>
取消
</Button>
<Button variant="danger" onClick={handleConfirm}>
删除
</Button>
</Modal.Footer>
</Modal>closable 跟着 Header 走(因为关闭按钮在标题栏),maskClosable 合并到 mask 配置里:
// ✅ 行为配置合并为对象
<Modal
open={visible}
onClose={handleClose}
mask={{ closable: false, blur: true }}
position="center"
>
{/* ... */}
</Modal>mask 对象内部的属性语义自洽——它们都是关于遮罩层的行为。把相关的属性放在一起,比散落在顶层 prop 里更好理解。
用 TypeScript 联合类型约束互斥 props
组合模式和 TypeScript 的类型系统配合,可以在编译阶段就拦截不合法的 prop 组合。
以 Button 为例,一个按钮要么是 <button> 元素(接收 onClick),要么是 <a> 链接(接收 href),不能同时是两者:
// ❌ 无法约束互斥关系:href 和 onClick 可以同时传
interface ButtonProps {
variant?: 'primary' | 'ghost'
href?: string
onClick?: () => void
children: React.ReactNode
}
// ✅ 联合类型:让非法状态不可表示
type ButtonProps =
| {
as?: 'button'
variant?: 'primary' | 'ghost'
onClick?: () => void
children: React.ReactNode
}
| {
as: 'a'
variant?: 'primary' | 'ghost'
href: string
children: React.ReactNode
}
// TypeScript 会在这里报错:
// Property 'onClick' does not exist on type '{ as: "a"; href: string; ... }'
<Button as="a" href="/submit" onClick={handleClick}>
提交
</Button>Andrew Branch(TypeScript 核心团队成员)在博客里专门讲过这个技巧:用判别联合(Discriminated Union)让组件 API 的合法用法在类型层面自文档化。调用方不需要记文档——传了 as="a" 之后,IDE 只会提示 href,不会提示 onClick。
同样的思路可以用在 Modal 上:
type ModalProps =
| {
variant: 'confirm'
onConfirm: () => void
onCancel: () => void
confirmText?: string
cancelText?: string
children: React.ReactNode
}
| {
variant: 'info'
onClose: () => void
children: React.ReactNode
}
// ✅ confirm 模式必须传 onConfirm
<Modal variant="confirm" onConfirm={handleDelete} onCancel={handleCancel}>
确认删除?
</Modal>
// ✅ info 模式只需要 onClose
<Modal variant="info" onClose={handleClose}>
操作成功
</Modal>
// ❌ 编译报错:info 模式不需要 onConfirm
<Modal variant="info" onConfirm={handleDelete}>
操作成功
</Modal>variant 属性就是判别字段——TypeScript 根据它的值自动推断出其余 prop 的类型。这比在运行时做校验既安全又高效。
组件 API 设计检查清单
在实际项目中,我每次设计或重构组件 API 前都会过一遍这个清单:
阶段一:识别变化点
- 列出组件所有 props,标记哪些是稳定结构、哪些是变化点
- 变化点是内容(用
children)、区域(用子组件/插槽)还是样式(用variant) - 检查是否有「总是一起出现」或「总是互斥」的 prop 组合
阶段二:控制复杂度
- 组件 props 数量不超过 5 个;超过则考虑拆分或组合
- 互斥 prop 用联合类型或
variant替代 - 3 个以上同类布尔属性合并为一个
variant或配置对象 - 检查 prop 之间是否有隐含依赖(比如
maskClosable依赖showMask)
阶段三:选择组合模式
- 结构变化超过 2 个区域时,优先用复合组件
- 子组件间共享状态用 React Context,不用 prop 透传
- 基础组件不包含业务逻辑,业务组件不直接操作样式细节
阶段四:类型安全与验证
- TypeScript 类型能精确表达所有合法 prop 组合
- 不写需要在运行时检查的「prop A 和 prop B 不能同时传」逻辑
- 复合子组件可以独立使用,不强依赖父组件的包裹
阶段五:文档与可用性
- 组件有 3 种以上使用示例覆盖常见场景
- 调用方不需要读源码就能理解 API(看类型定义和 IDE 提示即可)
- Tree Shaking 验证:未使用的子组件不会出现在最终产物中
参考资料
-
React 官方文档.「Composition vs Inheritance」. https://legacy.reactjs.org/docs/composition-vs-inheritance.html — React 设计原则中关于组合优于继承的阐述.
-
Kent C. Dodds.「Compound Components with React Hooks」. https://kentcdodds.com/blog/compound-components-with-react-hooks — 用 React Context 和 Hooks 实现复合组件模式的经典教程.
-
Kyle Shevlin.「Multiple Boolean Props are a Code Smell」. https://kyleshevlin.com/multiple-boolean-props-are-a-code-smell/ — 多个布尔属性是代码异味的论证与替代方案.
-
SpiceFactory.「How to Avoid the Boolean Trap When Designing React Components」. https://spicefactory.co/blog/how-to-avoid-the-boolean-trap-when-designing-react-components/ — 「布尔陷阱」概念的系统阐述.
-
Patterns.dev.「Compound Pattern」. https://www.patterns.dev/react/compound-pattern/ — 复合组件模式的系统性讲解与交互示例.
-
Andrew Branch.「Expressive React Component APIs with Discriminated Unions」. https://blog.andrewbran.ch/expressive-react-component-apis-with-discriminated-unions/ — TypeScript 核心团队成员关于用判别联合设计组件 API 的文章.
-
Alex Kondov.「Building Low Level Components the Radix Way」. https://alexkondov.com/building-low-level-components-the-radix-way/ — 分析 Radix UI 如何设计可扩展的底层组件.