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 组件
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
组件内部使用
React的useId()钩子来生成一个唯一 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 钩子
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> 组件
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} />
}- 这两个组件都使用了
useSlotPropsHook,从SlotContext中分别获取'label'和'input'插槽所需的属性。 - 用户可以在使用
<Label>和<Input>时传递自己的属性,但这些属性会和由TextField注入的属性合并,从而实现自动关联。
2.4 使用场景:在 App 组件中的应用
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>,无需手动传递htmlFor或id,因为它们会自动从上下文中获取。 - 灵活的 API 用户既可以由顶层组件自动生成 ID,也可以选择传入自定义的 ID。这样既保证了正确的关联又提供了灵活性。
3. 总结与优势
这种模式将组件间的协作逻辑隐藏在封装内部,给用户提供了一个极其简洁和灵活的 API。其主要优势包括:
解耦布局与逻辑
顶层组件(如
TextField)负责管理共享状态(例如生成的 ID 和关联 props),子组件(如<Label>和<Input>)只需专注于如何渲染自己。这样在组件内部结构变化时,对外暴露的 API 可以不变。降低用户使用难度
用户不用关心细节(例如手动管理
htmlFor与id之间的连接),只需要按照约定的方式组合使用组件即可。灵活性和可扩展性
通过插槽模式和上下文传递,可以轻松地扩展更多插槽或者修改现有插槽的行为,而不会破坏现有 API。对于构建复杂 UI 库来说,这种模式特别适合。
提高开发效率
集中管理共同的逻辑,可以减少重复编码,同时确保一致性,避免因手动配置错误而导致的 bug。
这种设计模式在现代前端框架(尤其是 React)中非常流行,很多 UI 库(例如 Chakra UI、Radix UI 等)都采用了类似的思路来解决组件组合中的共享逻辑问题。通过这种方式,你可以构建出具有高度灵活性和可定制性的组件库,同时保持良好的用户体验和代码维护性。