在 React 中构建可复用 UI:Compound Components、Render Props 与 API 设计
Source: Dev.to
你是否曾经构建过一个最初干净的 React 组件——却在几天后变成拥有无数 props、相互冲突的布尔值,以及没人信任的 README?
每新增一个布尔值,可能的 UI 状态数量就会翻倍。当你拥有六个布尔值时,就需要支持 64 种 UI 变体——其中大多数都没有文档记录。
可复用的 UI 并不是写更少的代码,而是设计能够 经受变化 的 API。
大多数 React 组件之所以出问题,并不是因为 bug;而是它们的 API 在真实业务需求面前崩溃。
本文面向构建共享组件、设计系统或复杂 UI 基元的开发者。
我们将通过以下内容逐步讲解 如何以及为何 使用可复用 UI:
- 复合组件 (Compound Components)
- 渲染属性 (Render Props)
- 真实的手风琴(Accordion)实现
- 权衡、替代方案以及改进方法
核心问题:基于 Props 的 API 难以扩展
大多数组件都是这样开始的:
const [activeItems, setActiveItems] = useState([]);
const toggleItem = (id: string) => { /* … */ };
起初感觉还好,随后需求不断增长:
- 不同的布局
- 自定义标题
- 条件化行为
- 设计系统约束
突然之间:Props 爆炸
Accordion
├─ activeItems
├─ allowMultiple
├─ showIcon
├─ animated
├─ collapsible
├─ headerLayout
└─ onToggle
↓
相互依赖的逻辑
分散在各个使用方
单个 Prop 看似无害,但它们一起会产生隐藏的耦合和未文档化的行为。
- 逻辑往往分散在 组件内部,而 使用方 必须自行管理状态,使组件变成难以调试的“黑盒”。
- 小改动就会导致全部崩溃。
根本原因: 你把 布局和行为 编码进了 Props。
我们将在后面把它重构为复合组件。
心智模型转变
旧模型(属性驱动)
“我配置一个组件。”
- 组件决定布局。
- 受限于预定义的属性。
- 随着每个新功能,复杂度都会增加。
新模型(组合驱动)
“我从各个部件组装行为。”
- 使用者决定布局。
- 通过包装和重新排序元素实现高度灵活性。
- 复杂度保持平坦;各部件相互独立。
与其告诉组件 它应该是什么样子,我们提供 状态和规则,并让使用者决定结构。这正是 复合组件 和 渲染属性 发挥优势的地方。
什么是复合组件?
复合组件公开一组 相关组件,它们:
- 隐式共享状态。
- 只能在特定父组件内部使用。
- 形成声明式、可读的 API。
它们感觉自然,因为它们模仿了 HTML 模式。
示例用法
<Accordion>
<Accordion.Item>
<Accordion.Header>Title</Accordion.Header>
<Accordion.Body>Content</Accordion.Body>
</Accordion.Item>
</Accordion>
- 没有属性钻取。
- 没有配置地狱。
- 结构自解释。
复合组件的思维模型
Accordion (状态拥有者)
├─ Item (限定状态)
│ ├─ Header (读取 + 触发)
│ └─ Body (读取)
- Accordion 拥有全局状态。
- Item 将该状态缩小到单个项目。
- Header / Body 只消费它们需要的内容。
状态向下流动,事件向上流动——但仅在狭窄的作用域内。每一层都减少了责任,使得每个组件在孤立时都易于理解。这种隔离正是复合组件在大规模下保持可维护性的关键。
从头构建手风琴组件
1. 状态设计
const [activeItems, setActiveItems] = useState<Set<string>>(...);
const toggleItem = useCallback(
(itemId: string) => {
if (allowMultiple) {
setActiveItems(prev => {
const newItems = new Set(prev);
if (newItems.has(itemId)) {
newItems.delete(itemId);
} else {
newItems.add(itemId);
}
return newItems;
});
return;
}
setActiveItems(prev =>
prev.has(itemId) ? new Set() : new Set([itemId])
);
},
[allowMultiple]
);
手风琴的状态需要高效地回答一个问题:这个项是否打开?
为什么使用 Set?
- O(1) 查找。
- 自动防止重复 ID。
- 自然适用于“多个打开项”。
- 语义明确。
仅此决定就能在多个用户体验模式中实现复用,并且轻松支持:
- 受控和非受控两种用法。
- 单项打开和多项打开两种模式。
本示例侧重于非受控使用;受控版本则会接受 value 和 onChange 并在外部推导 activeItems。
2. 内部契约的 Context
type AccordionContextValue = {
activeItems: Set<string>;
toggleItem: (id: string) => void;
};
该 Context 用于 内部 在 Accordion、Item、Header 和 Body 之间共享状态和操作,而不向使用者暴露实现细节。
公共 vs 内部 API
Public API
-------------------
<Accordion>
<Accordion.Item>
<Accordion.Header />
<Accordion.Body />
</Accordion.Item>
</Accordion>
Internal API
-------------------
AccordionContext
├── activeItems
└── toggleItem
使用者依赖 组件,而不是这些组件内部的实现方式。如果使用者依赖了你的 context 结构,就等于泄露了内部实现。
复合组件让你可以在幕后更新所有内容,而不会破坏使用者。
关键点: 使用者进行组合 — 他们不管理状态。
创建上下文
import { createContext, useContext } from 'react';
// Accordion Context
type AccordionContextValue = {
allowMultiple?: boolean;
activeItems: Set<string>;
toggleItem: (id: string) => void;
};
export const AccordionContext = createContext<AccordionContextValue | null>(null);
export const useAccordion = () => {
const ctx = useContext(AccordionContext);
if (!ctx) {
throw new Error('useAccordion must be used within <Accordion>');
}
return ctx;
};
// Accordion Item Context
type AccordionItemContextValue = {
id: string;
isActive: boolean;
};
export const AccordionItemContext = createContext<AccordionItemContextValue | null>(null);
export const useAccordionItem = () => {
const ctx = useContext(AccordionItemContext);
if (!ctx) {
throw new Error('useAccordionItem must be used within <Accordion.Item>');
}
return ctx;
};
使用 AccordionContext.Provider
const getInitialValue = (defaultValue?: string | string[]): string[] => {
if (!defaultValue) return [];
if (Array.isArray(defaultValue)) return defaultValue;
return [defaultValue];
};
function Accordion({ children, allowMultiple, defaultValue }: Props) {
const [activeItems, setActiveItems] = useState<Set<string>>(
() => new Set(getInitialValue(defaultValue))
);
const toggleItem = useCallback(
(itemId: string) => {
if (allowMultiple) {
setActiveItems(prev => {
const newItems = new Set(prev);
if (newItems.has(itemId)) {
newItems.delete(itemId);
} else {
newItems.add(itemId);
}
return newItems;
});
return;
}
setActiveItems(prev =>
prev.has(itemId) ? new Set() : new Set([itemId])
);
},
[allowMultiple]
);
const value = useMemo(
() => ({ allowMultiple, activeItems, toggleItem }),
[allowMultiple, activeItems, toggleItem]
);
return (
<AccordionContext.Provider value={value}>
{children}
</AccordionContext.Provider>
);
}
使用 Accordion.Item 进行作用域划分
type ItemProps = {
children: React.ReactNode;
itemId: string;
};
function AccordionItem({ children, itemId }: ItemProps) {
const { activeItems } = useAccordion();
const isActive = activeItems.has(itemId);
const { element } = renderChildren(children, { isActive });
return (
<AccordionItemContext.Provider value={{ id: itemId, isActive }}>
{element}
</AccordionItemContext.Provider>
);
}
为什么需要第二个上下文?
- Header 和 Body 只需要了解 它们各自 的项。
- 防止抽象泄漏。
- 保持每个子组件的专注,降低耦合度和复杂性。
手风琴组件实现
Header 组件
type HeaderProps = {
children: Children<{ isActive: boolean; onClick: () => void }>;
className?: string;
};
function AccordionHeader({ children, className }: HeaderProps) {
const { toggleItem } = useAccordion();
const { id, isActive } = useAccordionItem();
const handleClick = () => toggleItem(id);
const { element } = renderChildren(children, {
isActive,
onClick: handleClick,
});
return <div className={className}>{element}</div>;
}
Render‑prop 支持
export type Children<T = any> = React.ReactNode | ((props: T) => React.ReactNode);
export const renderChildren = <T,>(children: Children<T>, props: T) => {
if (typeof children === 'function')
return { element: children(props), isRenderProp: true };
return { element: children, isRenderProp: false };
};
Body 组件
type BodyProps = {
children: Children;
};
function AccordionBody({ children }: BodyProps) {
const { isActive } = useAccordionItem();
const { element } = renderChildren(children, { isActive });
return <div>{element}</div>;
}
导出复合组件
Accordion.Item = AccordionItem;
Accordion.Header = AccordionHeader;
Accordion.Body = AccordionBody;
export { Accordion };
好处
- 导入单个组件并使用其所有子组件。
- TypeScript 能够理解结构,提供完整的类型安全。
示例用法
import { Accordion } from './components/accordion';
import { FAQs } from './data/faqs';
function App() {
return (
<Accordion allowMultiple>
{FAQs.map(faq => (
<Accordion.Item key={faq.id} itemId={faq.id}>
<Accordion.Header>{faq.question}</Accordion.Header>
<Accordion.Body>{faq.answer}</Accordion.Body>
</Accordion.Item>
))}
</Accordion>
);
}
export default App;
附加用‑例
带打开/关闭子组件的 Modal 组件
function App() {
return (
<>
<Modal>
<Modal.Trigger>Open Modal</Modal.Trigger>
<Modal.Window>
<Modal.Close>Close Modal</Modal.Close>
</Modal.Window>
</Modal>
</>
);
}
- 在任意位置渲染窗口,并可通过 Modal 上下文从任何后代组件打开/关闭它。
- 导出
useModalhook 以进行编程式控制:
function App() {
const { openWindow, closeWindow } = useModal();
const someHandler = () => {
// Do some logic
openWindow('window-1');
};
}
Mental Model: 一个状态拥有者,多个声明式消费者。
带触发器、预览、拖拽区和错误的文件输入组件
function App() {
return (
<FileInput>
<FileInput.Trigger>Upload Image</FileInput.Trigger>
<FileInput.Dropzone />
<FileInput.Preview>
{(files, removeFile) => (
<>
{/* Render each file with a delete button */}
</>
)}
</FileInput.Preview>
</FileInput>
);
}
渲染‑prop 模式在这里大放异彩,允许对预览布局进行完整控制,同时保持组件 API 简洁。
改进手风琴:性能与架构
改进 1 – 拆分 Context 以减少重新渲染
当前情况
activeItems的任何更改都会重新渲染 所有 消费者。
建议的更改
type AccordionState = {
allowMultiple?: boolean;
activeItems: Set<string>;
};
const AccordionStateContext = createContext<AccordionState | null>(null);
type AccordionActions = {
toggleItem: (id: string) => void;
};
const AccordionActionsContext = createContext<AccordionActions | null>(null);
结果
- 与 memoization 结合使用时,可减少不必要的重新渲染。
- 保持操作函数的引用稳定。
这些优化在组件被广泛复用或同时打开大量手风琴项(例如 100 项)时尤为重要。
⚠️ 警告: 添加额外的 Context 会增加复杂度。请在优化前进行测量。
改进 2 – 提取无 UI Hook
function useAccordionState({
defaultActive,
allowMultiple,
}: { defaultActive?: string[]; allowMultiple?: boolean }) {
// 仅包含状态和逻辑
}
好处
- 可测试、可复用的逻辑与 UI 分离。
- 可在不同的组合组件之间共享(例如 tabs、可折叠面板)。