Skip to content
medium
插槽上下文React

Day 02

Slots

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

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

背景与目的

在 UI 库中,常见的组件会由多个内部子组件构成,比如一个 TextField 组件可能包含 <Label><Input> 两个部分。如果每次都要求用户手动传递相关联的属性(例如 <label>htmlFor<input>id),不仅容易出错,还会增加使用成本。

为了让 API 更加灵活和友好,我们通过隐藏内部耦合(但又能协同工作)的方式来实现:顶层组件负责生成需要共享的 props(例如自动生成的 id),内部子组件再从上下文中拿到相应的 props 并进行合并。

任务

实现一个插槽上下文 SlotContext 以及一组基于插槽的组件,使其能够:

  • 接收一个 slots 对象,定义各个插槽名称与其默认 props(例如 label: { htmlFor }input: { id })。
  • 通过 React 的 Context API,将这些默认 props 传递给对应的子组件插槽。
  • 允许子组件通过自定义 Hook(本题为 useSlotProps)获取并合并默认 props 与用户传入的 props(用户传入优先)。
  • 当在上下文之外使用插槽组件时,给出明确的错误提示,避免静默失败。

组件 API

组件主要作用props
<TextField id?>建立插槽上下文,为 label/input 注入默认关联 propsid?: string(可选,默认自动生成)
<Label ...labelProps>自动从上下文获得 htmlFor,可传入 props 覆盖/追加任意 label 支持的属性
<Input ...inputProps>自动从上下文获得 id,支持标准 input 属性任意 input 支持的属性

错误边界:

场景错误提示
TextField 外部使用 Label / InputSlotContext must be used in TextField component

示例

预览: Day-02 slots

1. 基础用法

tsx
<TextField>
  <Label>用户名</Label>
  <Input placeholder="请输入用户名" />
</TextField>

2. 自定义 id

tsx
<TextField id="email">
  <Label>邮箱</Label>
  <Input type="email" placeholder="you@example.com" />
</TextField>

3. 不同状态

Invalid

tsx
<TextField>
  <Label>用户名</Label>
  <Input placeholder="invalid input" aria-invalid />
</TextField>

Disabled

tsx
<TextField>
  <Label>用户名</Label>
  <Input placeholder="请输入用户名" disabled />
</TextField>

Readonly

tsx
<TextField>
  <Label>用户名</Label>
  <Input placeholder="readonly input" readOnly value='Readonly' />
</TextField>

基础样式

Label

css
text-sm font-medium text-gray-700 dark:text-gray-200'

Input

css
'block w-full rounded-md border px-3 py-2 sm:text-sm leading-6',
'bg-white text-gray-900 placeholder:text-gray-400 shadow-sm',
'border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/50 focus:outline-none',
'disabled:opacity-60 disabled:cursor-not-allowed',
'transition-colors duration-200 ease-out',
'dark:bg-neutral-900 dark:text-gray-100 dark:placeholder:text-gray-500 dark:border-neutral-700',
'aria-[invalid=true]:border-red-500 aria-[invalid=true]:focus:border-red-500 aria-[invalid=true]:focus:ring-red-500/50',

测试代码

tsx
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { Input, Label, TextField } from './TextField'
import '@testing-library/jest-dom'

describe('textField · Slots 组件', () => {
  describe('基础关联行为', () => {
    it('在不传 id 时,Label 应自动关联到 Input(htmlFor === input.id)', () => {
      render(
        <TextField>
          <Label>用户名</Label>
          <Input placeholder="请输入用户名" />
        </TextField>,
      )

      const label = screen.getByText('用户名')
      const input = screen.getByRole('textbox')

      expect(input).toBeInTheDocument()
      expect(input).toHaveAttribute('placeholder', '请输入用户名')

      const htmlFor = label.getAttribute('for') || label.getAttribute('htmlFor')
      expect(htmlFor).toBeTruthy()
      expect(input).toHaveAttribute('id', htmlFor as string)
      // 通过可访问性查询验证 label 正确关联到了 input
      expect(screen.getByLabelText('用户名')).toBe(input)
    })

    it('当 TextField 传入自定义 id 时,应优先使用该 id', () => {
      render(
        <TextField id="email">
          <Label>邮箱</Label>
          <Input type="email" placeholder="you@example.com" />
        </TextField>,
      )

      const input = screen.getByRole('textbox')
      const label = screen.getByText('邮箱')
      const htmlFor = label.getAttribute('for') || label.getAttribute('htmlFor')

      expect(input).toHaveAttribute('id', 'email')
      expect(htmlFor).toBe('email')
      expect(screen.getByLabelText('邮箱')).toBe(input)
    })
  })

  describe('子组件 props 优先合并', () => {
    it('当子组件传入 id/htmlFor,应覆盖上下文中的默认值', () => {
      render(
        <TextField id="user">
          <Label htmlFor="custom">用户名</Label>
          <Input id="custom" placeholder="覆盖默认 id" />
        </TextField>,
      )

      const input = screen.getByRole('textbox')
      const label = screen.getByText('用户名')
      const htmlFor = label.getAttribute('for') || label.getAttribute('htmlFor')

      expect(input).toHaveAttribute('id', 'custom')
      expect(htmlFor).toBe('custom')
      expect(screen.getByLabelText('用户名')).toBe(input)
    })

    it('应透传其他原生属性(如 disabled、aria-invalid、className 等)', () => {
      render(
        <TextField>
          <Label className="custom-label">用户名</Label>
          <Input placeholder="invalid input" aria-invalid disabled className="custom-input" />
        </TextField>,
      )

      const input = screen.getByRole('textbox')
      const label = screen.getByText('用户名')
      expect(input).toBeDisabled()
      expect(input).toHaveAttribute('aria-invalid', 'true')
      expect(input.className).toContain('custom-input')
      expect(label.className).toContain('custom-label')
    })
  })

  describe('错误边界', () => {
    it('在 TextField 外部使用 Label 应报错', () => {
      // 期望实现:useSlotProps 在无上下文时抛出明确错误
      expect(() => render(<Label>用户名</Label>)).toThrow(
        'SlotContext must be used in TextField component',
      )
    })

    it('在 TextField 外部使用 Input 应报错', () => {
      expect(() => render(<Input />)).toThrow(
        'SlotContext must be used in TextField component',
      )
    })
  })
})

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