Skip to content

Slots

IMPORTANT

slots 允许你指定一个元素,该元素在组件的整体集合中承担特定角色。

在构建复杂的、由多个需要协同工作的组件组成的 UI 库时,一种非常灵活且便于使用的设计模式——组合组件模式(Compound Components Pattern)与插槽(Slot)模式的结合。

1. 背景与目的

在 UI 库中,通常会有一些组件由多个内部子组件构成,比如一个 TextField 组件可能包含 <Label><Input> 两个部分。如果每次都要求用户手动传递相关联的属性(例如 <label>htmlFor<input>id),不仅容易出错,还会增加使用成本。为了让 API 更加灵活和友好,我们可以通过一种隐藏内部耦合(但又能协同工作的方式)来实现:顶层组件负责生成需要共享的 props(例如自动生成的 id),内部子组件再从上下文中拿到相应的 props。

2. 代码详解

2.1 TextField 组件

tsx
export function TextField({
  id,
  children,
}: {
  id?: string
  children: React.ReactNode
}) {
  const generatedId = useId()
  id ??= generatedId

  // 创建 slots 对象,为 label 和 input 分别创建一组
  // label 将接收一个 htmlFor 属性;input 将接收一个 id 属性
  const slots = {
    label: { htmlFor: id },
    input: { id },
  }

  // 将 slots 对象包装在 SlotContext 中,通过 Provider 注入到子组件中
  return <SlotContext value={slots}>{children}</SlotContext>
}
  • 自动生成 ID

    组件内部使用 ReactuseId() 钩子来生成一个唯一 ID。如果用户没有传递 id,则使用该生成的值。

    这样做的好处是可以自动为组件内的 <label><input> 分配匹配的 ID,确保它们能够正常关联。

  • 创建 slots 对象

    创建了一个 slots 对象,其中定义了两个插槽:

    • label: 包含属性 { htmlFor: id }
    • input: 包含属性 { id } 这两个插槽分别对应着 <label><input>,目的就是为了确保它们彼此关联(当你点击 <label> 时,焦点将自动聚焦到对应的 <input>)。
  • 通过 Context 传递

    使用 SlotContext 组件,将 slots 对象注入到其子组件中。这种方式的好处是,无论 <Label><Input> 在组件树中的位置如何,都能访问到这些共享的属性。

2.2 SlotContext 与子组件中的 useSlotProps 钩子

tsx
type Slots = Record<string, Record<string, unknown>>
export const SlotContext = createContext<Slots | null>(null)

function useSlotProps<Props>(props: Props, slot: string): Props {
  const slots = use(SlotContext)
  if (!slots)
    throw new Error('must be used in TextField component')

  return { ...slots[slot], slot, ...props } as Props
}
  • SlotContext 使用 React Context 创建一个 SlotContext,它存储了所有插槽所需要的属性。
  • useSlotProps 这是一个自定义 Hook,负责从上下文中读取对应插槽的属性,并与组件本身传入的 props 进行合并。- 如果用户在调用组件时传递了额外的 props(比如额外的样式或事件),通过这种合并方式,这些 props 与上下文提供的 props 会一起生效。

2.3 <Label><Input> 组件

tsx
export function Label(props: React.ComponentProps<'label'>) {
  props = useSlotProps(props, 'label')
  return <label {...props} />
}

export function Input(props: React.ComponentProps<'input'>) {
  props = useSlotProps(props, 'input')
  return <input {...props} />
}
  • 这两个组件都使用了 useSlotProps Hook,从 SlotContext 中分别获取 'label''input' 插槽所需的属性。
  • 用户可以在使用 <Label><Input> 时传递自己的属性,但这些属性会和由 TextField 注入的属性合并,从而实现自动关联。

2.4 使用场景:在 App 组件中的应用

tsx
export function App() {
  const partyModeId = useId()
  return (
    <div>
      <div>
        <Toggle>
          <label htmlFor={partyModeId}>Party mode</label>
          <ToggleButton id={partyModeId} />
          <ToggleOn>Let's party 🥳</ToggleOn>
          <ToggleOff>Sad town 😭</ToggleOff>
        </Toggle>
      </div>
      <hr />
      <div>
        {/* 可以传入自定义 id 测试 */}
        <TextField>
          {/* 可以传入额外的 props 进行合并 */}
          <Label>Venue</Label>
          <Input />
        </TextField>
      </div>
    </div>
  )
}
  • TextField 内的组合<TextField> 的子组件中使用 <Label><Input>,无需手动传递 htmlForid,因为它们会自动从上下文中获取。
  • 灵活的 API 用户既可以由顶层组件自动生成 ID,也可以选择传入自定义的 ID。这样既保证了正确的关联又提供了灵活性。

3. 总结与优势

这种模式将组件间的协作逻辑隐藏在封装内部,给用户提供了一个极其简洁和灵活的 API。其主要优势包括:

  • 解耦布局与逻辑

    顶层组件(如 TextField)负责管理共享状态(例如生成的 ID 和关联 props),子组件(如 <Label><Input>)只需专注于如何渲染自己。这样在组件内部结构变化时,对外暴露的 API 可以不变。

  • 降低用户使用难度

    用户不用关心细节(例如手动管理 htmlForid 之间的连接),只需要按照约定的方式组合使用组件即可。

  • 灵活性和可扩展性

    通过插槽模式和上下文传递,可以轻松地扩展更多插槽或者修改现有插槽的行为,而不会破坏现有 API。对于构建复杂 UI 库来说,这种模式特别适合。

  • 提高开发效率

    集中管理共同的逻辑,可以减少重复编码,同时确保一致性,避免因手动配置错误而导致的 bug。

这种设计模式在现代前端框架(尤其是 React)中非常流行,很多 UI 库(例如 Chakra UI、Radix UI 等)都采用了类似的思路来解决组件组合中的共享逻辑问题。通过这种方式,你可以构建出具有高度灵活性和可定制性的组件库,同时保持良好的用户体验和代码维护性。

内容基于 MIT 许可 | 保持节奏 · 持续积累