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={{...}} /> 配插槽。