组件 API 设计:少传布尔值,多用组合

0阅读9分钟

从一个让我头疼的 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 种可能的状态组合。其中绝大多数是无意义的甚至矛盾的——primaryoutline 同时为 true 是什么效果?isLoadingtruehighlighted 还有意义吗?这些问题在写组件的时候不会暴露,等到调用方踩坑时才显现。

以下是我在实际项目中总结的几个信号:

信号说明例子
布尔属性超过 3 个组合数已经超过 8,调用方开始记不住&lt;Card showIcon showBadge showTag collapsible hoverable&gt;
总是一起出现3 个布尔值经常同时为 true 或同时为 false,说明它们属于同一概念withHeaderwithFooterwithActions
需要注释才能看懂调用方必须写注释说明每个布尔值的含义&#123;/* 显示分割线但不显示操作区 */&#125;
条件分支超过 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 的 primarysecondary——用一个 variant 属性比拆成 PrimaryButtonSecondaryButton 更合理。关键判断依据是:这个变化点是结构性的还是表面性的

决策流程

设计组件 API 时,我通常按下面的流程来判断用哪种方式:

流程图画布 · 115%
Mermaid 流程图加载中...

流程的核心逻辑:先数 props 数量判断是否真的需要重构,再根据变化点类型选择不同的处理方式。结构性的变化用复合组件,样式性的变化用 variant,行为性的配置合并成对象。最后用 TypeScript 类型系统兜底,让不合法的 prop 组合在编译阶段就报错。

案例一:Card 组件从布尔属性到复合组件

场景

项目里有个 Card 组件,最初只有 titlechildren,后来陆续加了 showHeadershowFootershowActionscollapsiblecompact

翻车

调用方开始写出这样的代码:

// ❌ 布尔属性堆叠,调用方无法直观理解组件结构
<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>
  )
}

问题很明显:showFootershowActions 耦合在一起(showActionstrueshowFooterfalse 时,操作区不显示),调用方很难预测行为。

修复

用复合组件模式重构,把 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 组件,需要支持 primaryoutlineghostdanger 四种样式变体,以及 smmdlg 三种尺寸。

翻车

用布尔属性表达:

// ❌ 四种样式 × 三种尺寸 = 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 组件,有 showCloseshowMaskclosablemaskClosableshowFooterfooterAligncentered 这些属性。

翻车

// ❌ 7 个布尔值,closable 和 showClose 语义重叠
<Modal
  open={visible}
  showClose
  showMask
  closable
  maskClosable={false}
  showFooter
  footerAlign="right"
  centered
  onClose={handleClose}
>
  <p>确认删除这条记录?</p>
</Modal>

closableshowClose 表达的是同一个意思,maskClosableshowMask 又有一层隐含依赖(不显示遮罩时 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 为例,一个按钮要么是 &lt;button&gt; 元素(接收 onClick),要么是 &lt;a&gt; 链接(接收 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 验证:未使用的子组件不会出现在最终产物中

参考资料

  1. React 官方文档.「Composition vs Inheritance」. https://legacy.reactjs.org/docs/composition-vs-inheritance.html — React 设计原则中关于组合优于继承的阐述.

  2. Kent C. Dodds.「Compound Components with React Hooks」. https://kentcdodds.com/blog/compound-components-with-react-hooks — 用 React Context 和 Hooks 实现复合组件模式的经典教程.

  3. Kyle Shevlin.「Multiple Boolean Props are a Code Smell」. https://kyleshevlin.com/multiple-boolean-props-are-a-code-smell/ — 多个布尔属性是代码异味的论证与替代方案.

  4. 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/ — 「布尔陷阱」概念的系统阐述.

  5. Patterns.dev.「Compound Pattern」. https://www.patterns.dev/react/compound-pattern/ — 复合组件模式的系统性讲解与交互示例.

  6. Andrew Branch.「Expressive React Component APIs with Discriminated Unions」. https://blog.andrewbran.ch/expressive-react-component-apis-with-discriminated-unions/ — TypeScript 核心团队成员关于用判别联合设计组件 API 的文章.

  7. Alex Kondov.「Building Low Level Components the Radix Way」. https://alexkondov.com/building-low-level-components-the-radix-way/ — 分析 Radix UI 如何设计可扩展的底层组件.

评论 0

0 / 1000