【翻译】构建类型安全的复合组件

原文链接:tkdodo.eu/blog/buildi...

作者:Dominik

我认为在构建组件库时,复合组件是一种非常优秀的设计模式。它们赋予使用者灵活的组件组合方式,无需将所有变体强行塞进单一且属性繁重的API中。此外,它们还能在标记中明确组件间的关联关系。

这并非意味着复合组件永远适用。有时,使用props才是更优的选择。

一个反面示例

关于复合组件,我们常会看到一个选项列表的示例,这很可能是因为HTML中也是如此实现的。

TypeScript 复制代码
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. 固定布局

复合组件的优势在于允许用户自由布局children。但对于Selects而言,这种灵活性往往并非必需。options需呈现在菜单中,且要求逐项显示。这正是许多人希望在类型层面限制children传递方式的原因------例如仅允许将Option类型传递给Select组件。

这不仅在当前版本中无法实现(相关问题自2018年就已提交),更是不可取 的做法。我知道你期待本文能揭示复合组件实现类型安全的技巧------我确实会说明,但这与children无关。我的观点是:若想强制children类型,复合组件本身就是错误的抽象方式。

2. 动态内容

当内容以静态为主时,复合组件表现尤为出色。上例中三个硬编码选项恰好符合此类场景,对吧?

但实际应用中,Selects很少仅包含三个选项------内容通常来自API调用的动态结果集。此外,多数设计指南都建议选项少于五个时不应使用下拉菜单,因为将少量选项隐藏在下拉框中会增加不必要的点击次数和认知负荷。

事实上,在Adverity项目初期,我们曾采用复合组件实现Select,但随后发现多数场景都需要编写如下映射代码:

TypeScript 复制代码
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 而不是children元素的 Select

TypeScript 复制代码
import { Select } from '@/components/select'

function UserSelect({ value, onChange }) {
  const userQuery = useSuspenseQuery(userOptions)

  return (
    <Select
      value={value}
      onChange={onChange}
      options={userQuery.data}
    />
  )
}

这不仅让我们摆脱了无处不在的繁琐映射工作,还实现了我们期望的类型安全------因为无需限制任何children类型。此外,Select 组件可轻松泛型化,确保 valueonChangeoptions 均采用统一类型:

TypeScript 复制代码
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

ModalDialog 组件是另一个我们不希望用户完全掌控复合组件的示例。毕竟,将 DialogFooter 渲染在 DialogHeader 上方毫无意义。我们也不希望有人意外遗漏 DialogBackdrop,或在 DialogBodyDialogFooter 之间设置不同间距。在强调一致性和顺序的场景中,slot通常是更优的抽象方案:

TypeScript 复制代码
function ModalDialog({ header, body, footer }) {
  return (
    <DialogRoot>
      <DialogBackdrop />
      <DialogContent>
        <DialogHeader>{header}</DialogHeader>
        <DialogBody>{body}</DialogBody>
        <DialogFooter>{footer}</DialogFooter>
      </DialogContent>
    </DialogRoot>
  )
}

// usage:

<ModalDialog header="Hello" body="World" />

它们仍保留某种灵活性,允许将任意 React 组件注入特定位置,同时确保用户无需在各处复制粘贴冗余代码。将这些Dialog原语纳入设计系统固然很棒,但我不会将其直接暴露给用户。


因此存在两个指标------固定布局 和主要动态内容------使我质疑是否真的需要复合组件。那么何时才是真正合适的场景?这又与类型安全有何关联?

更佳示例

在以固定元素为主的动态布局子元素中,<ButtonGroup><TabBar><RadioGroup> 都是能充分受益的典型用例:

TypeScript 复制代码
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的主要区别在于,我们明确希望children不属于RadioGroupItem类型。能够按需布局这些子项(甚至添加辅助文本)至关重要。当然,某些场景下RadioGroup也需要动态选项,但这种情况下创建循环(如先前所示)并非不可行。

类型安全问题依然存在,因为传递给ThemeSwitchervalue不应仅是字符串------它很可能是字符串常量:

TypeScript 复制代码
type ThemeValue = 'system' | 'light' | 'dark'

valueonChange 属性只需像之前展示的那样将 RadioGroup 设为泛型即可与 ThemeValue 关联,但 RadioGroupItem 呢?我们如何确保传递给每个 RadioGroupItem 的值都能被静态分析?

类型安全

当然,我们也可以将 RadioGroupItem 设计为泛型。但这种做法的问题在于,RadioGroup 的类型参数不会自动传递给子项,因为 JSX 子组件不会从父组件"继承"类型参数。因此即使 RadioGroup 类型定义完美且能推断出 <ThemeValue>,我们仍需为每个 RadioGroupItem 显式指定参数:

TypeScript 复制代码
import { RadioGroup, RadioGroupItem } from '@/components/radio'
import { Flex } from '@/components/layout'

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

type ThemeSwitcherProps = {
  value: ThemeValue
  onChange: (value: ThemeValue) => void
}

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>
  )
}

这不是一个理想的API,因为手动添加的类型注解很容易被遗忘。了解我的人都知道,我倾向于在可能的情况下让类型完全推断出来。对于复合组件而言,实现这一目标的最佳方式是避免直接暴露这些组件,而仅向用户提供调用方法。

组件工厂模式 不确定这个模式名称是否准确,但足以传达核心概念。本质上,我们无法完全消除手动类型注解,但可以尝试将其隐藏并显式化。不直接暴露RadioGroupRadioGroupItem,而是仅导出名为createRadioGroup的函数------该函数需带类型参数调用一次,随后返回静态类型的RadioGroup及其关联类型的RadioGroupItem

TypeScript 复制代码
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,用户必须传入参数才能对结果进行有效操作,从而实现如下用法:

TypeScript 复制代码
import { createRadioGroup } from '@/components/radio'
import { Flex } from '@/components/layout'

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

type ThemeSwitcherProps = {
  value: ThemeValue
  onChange: (value: ThemeValue) => void
}

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,但这种情况发生的可能性要小得多。

总而言之,这种方法既保留了复合组件的灵活性优势,又提供了强有力的类型保障。唯一的实际代价是用户无法直接导入组件,而需要通过函数调用创建组件家族的类型化实例。我认为这是值得的权衡,也是让设计系统用户获得最安全复合组件的最佳途径。

相关推荐
掘金安东尼1 小时前
向大家介绍《开发者博主联盟》🚀
前端·程序员·github
火车叼位1 小时前
div滚动条是否存在?用 v-scroll-detect 增加一个辅助class
前端
H_z_q24012 小时前
web前端(HTML)银行汇款单的制作
前端·html
小宇的天下2 小时前
Synopsys Technology File and Routing Rules Reference Manual (1)
java·服务器·前端
@PHARAOH2 小时前
WHAT - Vercel react-best-practices 系列(四)
前端·react.js·前端框架
今天也要晒太阳4732 小时前
对el-upload的上传文件显示名做长度限制
前端
Thomas游戏开发2 小时前
分享一个好玩的:一次提示词让AI同时开发双引擎框架
前端·javascript·后端
NEXT062 小时前
别再折磨自己了!放弃 Redux 后,我用 Zustand + TS 爽到起飞
前端·react.js
donecoding2 小时前
Sass 模块化革命:告别 @import,拥抱 @use 和 @forward
前端·css·代码规范