Skip to content
返回设计中心

Web 应用可复用组件模式

复合组件、渲染属性、无头 hooks 和多态模式,构建灵活的 UI 库。

组件模式进阶

为什么

硬编码的组件 API 迫使使用者采用特定标记结构。灵活模式让下游团队自定义渲染、行为和样式,无需 fork 库。

复合组件模式被 Radix UI、Headless UI 和 shadcn/ui 采用 — 这是 2024 年采用最多的三个无头库。相比扁平组件 API,它们减少 70% 的属性逐层传递。

何时使用

构建共享 UI 库、设计数据密集型组件(表格、表单)或使用者需要深度自定义但保留内置行为时使用这些模式。叶子组件(Button、Input)跳过复合模式 — 它们增加间接层却无收益。

用于 3 个以上相关部分的组件如 Tabs、Select、Menu。

如何做

1. 复合组件 — 父组件持有状态,子组件通过 context 读取 <Select><Select.Trigger/><Select.Menu><Select.Item/></Select.Menu></Select> 2. 渲染属性 — 传递接收内部状态的函数 <DataTable renderCell={(row, col) => <Badge>{row[col]}</Badge>} /> 3. 无头 Hook — 从 UI 提取逻辑,返回状态 + 辅助方法 const { open, toggle, close } = useDisclosure({ defaultOpen: false }) 4. 多态

示例

// 使用 context 的复合组件模式 const TabsContext = createContext<{ active: string; setActive: (id: string) => void } | null>(null); function Tabs({ children, defaultTab }: { children: React.ReactNode; defaultTab: string }) { const [active, setActive] = useState(defaultTab); return <TabsContext.Provider value={{ active, setActive }}>{children}</TabsContext.Provider>; } Tabs.List = function TabsList({ children }: { children: React.ReactNode }) { return <div role="tablist" className="flex gap-2">{children}</div>; }; Tabs.Tab = function TabsTab({ id, children }: { id: string; children: React.ReactNode }) { const ctx = useContext(TabsContext)!; return ( <button role="tab" aria-selected={ctx.active === id} onClick={() => ctx.setActive(id)}> {children} </button> ); }; Tabs.Panel = function TabsPanel({ id, children }: { id: string; children: React.ReactNode }) { const ctx = useContext(TabsContext)!; return ctx.active === id ? <div role="tabpanel">{children}</div> : null; }; // 用法 — 灵活组合,无需属性逐层传递 <Tabs defaultTab="overview"> <Tabs.List> <Tabs.Tab id="overview">概览</Tabs.Tab> <Tabs.Tab id="settings">设置</Tabs.Tab> </Tabs.List> <Tabs.Panel id="overview">内容区</Tabs.Panel> <Tabs.Panel id="settings">设置区</Tabs.Panel> </Tabs>

常见问题

  • ⚠Context 默认值为 null — 使用者忘记检查,导致运行时崩溃
  • ⚠多态 `as` 破坏 TypeScript 推断 — ref 类型和属性联合变为 any
  • ⚠复合子组件过多 — API 表面变得难以记忆
  • ⚠无头 hooks 返回不稳定函数引用 — 导致不必要的重渲染

最佳实践

  • ✓context 为 null 时抛出描述性错误 — 'Tabs.Tab 必须在 <Tabs> 内使用'
  • ✓使用 useCallback/useMemo 稳定无头 hook 的返回值
  • ✓复合 API 限制在 5 个子组件内 — 超过则切换到配置对象模式
  • ✓多态 `as` 使用 Radix UI 的 as 属性模式以获得正确 TypeScript 推断

常见问题解答

复合组件和渲染属性何时各用哪个?

静态组合用复合组件(Tabs.Tab、Tabs.Panel);使用者需按内部状态渲染每项时用渲染属性(DataTable renderCell)。

为什么复合组件报 'cannot read property of null'?

context 默认值为 null 且子组件用在父组件外。改抛描述性错误('Tabs.Tab 必须在 <Tabs> 内使用')而非 null 默认。

什么是无头 hook,为何使用?

从 UI 提取逻辑(useDisclosure 返回 open/toggle/close),可自由样式。需跨不同视觉处理复用同一行为时使用。

如何让多态 `as` 属性类型安全?

用 Radix UI 的 `as` 属性模式——推断元素属性并合并。避免朴素 `as: keyof JSX.IntrinsicElements`,它会丢失属性类型。

复合组件何时变得过于复杂?

超过 5 个子组件时 API 难以记忆。改用配置对象模式:<Tabs config={{...}} /> 配插槽。