找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

578

积分

0

好友

77

主题
发表于 3 天前 | 查看: 13| 回复: 0

在构建组件库时,“复合组件”是一种非常出色的设计模式。它赋予组件使用者更高的灵活性,避免了将所有变体塞进一个臃肿的单体 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 实现为一个泛型组件,确保 valueonChangeoptions 的类型完全匹配:

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 组件),避免了在每一个使用处重复编写相同的模板代码。

综上所述,我们可以总结出两个判断何时“不”适合使用复合组件的指标:

  1. 布局是固定的
  2. 内容是动态的

如果同时满足这两点,复合组件可能就不是最佳选择。

那么,复合组件真正适用的场景是什么?又该如何确保它们的类型安全呢?这需要我们在 前端框架/工程化 的实践中不断探索和总结。

一个更好的例子

复合组件更适合那些子组件布局可灵活调整内容基本固定的场景,例如 <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 的关键区别在于:我们明确希望用户能够灵活组合子组件,可能自定义布局、添加辅助文本等。即使选项是动态的,编写一个循环渲染也并非难事。

接下来就需要考虑类型安全了。例如,ThemeSwitchervalue 很可能不是任意字符串,而是特定的字面量类型:

type ThemeValue = 'system' | 'light' | 'dark'

我们可以让 RadioGroup 成为泛型组件,使 valueonChange 都使用 ThemeValue 类型。但问题来了——如何确保 RadioGroupItemvalue 也能被静态检查呢?

类型安全的挑战

当然,我们也可以让 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)

这个模式的核心思想很简单:我们无法完全消除类型标注,但可以将其隐藏起来,让用户只需在一个地方显式指定类型。

具体做法是:我们不直接导出 RadioGroupRadioGroupItem,而是导出一个名为 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 })

在运行时,这个函数只是简单地返回组件对象。但在类型层面,它将 RadioGroupRadioGroupItem 的泛型参数绑定在一起。由于默认泛型为 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。但这种错误的可能性已经大大降低了。

总体而言,“组件工厂模式”在保留复合组件灵活性的同时,显著增强了类型安全性。唯一的代价是:使用者不能直接导入组件,而是需要通过一个工厂函数来创建类型化的组件实例。

这个权衡通常是值得的,它能让设计系统中的复合组件真正实现灵活与安全的兼得。类似的设计思路和最佳实践,也常被收录于像 云栈社区 这样的开发者知识库中,供大家交流学习。




上一篇:Spring Boot 3如何基于Spring Security 6实现API接口全链路安全防护?
下一篇:用30行代码手写React Router核心功能:轻量化路由方案详解
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-24 02:48 , Processed in 0.301336 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表