学习路径
高级
组合模式
学习如何使用组合模式构建灵活、可复用的 React 组件
组合模式概述
React 强调组合而非继承。通过组合,你可以构建灵活且可复用的组件。本章将探讨多种组合模式:
- 容器组件
- 插槽(Slots)
- 渲染属性(Render Props)
- 高阶组件(HOC)
- 自定义 Hooks
- 组件组合最佳实践
组合 vs 继承
React 不推荐使用继承来构建组件。相反,推荐使用组合:
- props 传递
- children
- 渲染属性
- 自定义 Hooks
容器组件
容器组件是一种将逻辑与展示分离的模式。容器组件负责获取数据和处理业务逻辑,展示组件负责渲染 UI。
基础容器组件
TypeScript
import { useState, useEffect } from 'react';
// 容器组件 - 负责数据获取
function UserContainer({ children, userId }: { children: (user: User) => React.ReactNode; userId: number }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetchUser(userId)
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, [userId]);
if (loading) return <div>加载中...</div>;
if (error) return <div>错误: {error.message}</div>;
if (!user) return null;
return <>{children(user)}</>;
}
// 展示组件 - 负责 UI 渲染
function UserCard({ user }: { user: User }) {
return (
<div className="card">
<img src={user.avatar} alt={user.name} />
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
);
}
// 使用
function App() {
return (
<UserContainer userId={1}>
{(user) => <UserCard user={user} />}
</UserContainer>
);
}
可复用的列表容器
TypeScript
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor: (item: T) => string | number;
emptyMessage?: string;
}
function List<T>({ items, renderItem, keyExtractor, emptyMessage = '无数据' }: ListProps<T>) {
if (items.length === 0) {
return <div className="text-text-secondary">{emptyMessage}</div>;
}
return (
<ul className="space-y-2">
{items.map((item, index) => (
<li key={keyExtractor(item)}>
{renderItem(item, index)}
</li>
))}
</ul>
);
}
// 使用
function UserList({ users }: { users: User[] }) {
return (
<List
items={users}
keyExtractor={(user) => user.id}
renderItem={(user) => (
<div>
<span>{user.name}</span>
<span>{user.email}</span>
</div>
)}
emptyMessage="暂无用户"
/>
);
}
插槽模式 (Slots)
插槽模式允许组件通过 props 接收多个子元素,实现灵活的布局。
基础插槽
TypeScript
interface ModalProps {
isOpen: boolean;
onClose: () => void;
header?: React.ReactNode;
footer?: React.ReactNode;
children: React.ReactNode;
}
function Modal({ isOpen, onClose, header, footer, children }: ModalProps) {
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
{header && <div className="modal-header">{header}</div>}
<div className="modal-body">
{children}
</div>
{footer && <div className="modal-footer">{footer}</div>}
</div>
</div>
);
}
// 使用
function App() {
const [isOpen, setIsOpen] = useState(false);
return (
<Modal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
header={<h2>标题</h2>}
footer={<button onClick={() => setIsOpen(false)}>关闭</button>}
>
<p>这是模态框内容</p>
</Modal>
);
}
命名插槽
TypeScript
interface LayoutProps {
header?: React.ReactNode;
sidebar?: React.ReactNode;
content?: React.ReactNode;
footer?: React.ReactNode;
}
function Layout({ header, sidebar, content, footer }: LayoutProps) {
return (
<div className="layout">
{header && <header className="layout-header">{header}</header>}
<div className="layout-body">
{sidebar && <aside className="layout-sidebar">{sidebar}</aside>}
<main className="layout-content">{content}</main>
</div>
{footer && <footer className="layout-footer">{footer}</footer>}
</div>
);
}
// 使用
function App() {
return (
<Layout
header={<nav>导航栏</nav>}
sidebar={<Menu />}
content={<main>主要内容</main>}
footer={<div>页脚</div>}
/>
);
}
动态插槽
TypeScript
interface TabsProps {
items: Array<{
key: string;
label: string;
content: React.ReactNode;
}>;
defaultActiveKey?: string;
}
function Tabs({ items, defaultActiveKey }: TabsProps) {
const [activeKey, setActiveKey] = useState(defaultActiveKey || items[0]?.key);
const activeItem = items.find(item => item.key === activeKey);
return (
<div>
<div className="tabs-nav">
{items.map(item => (
<button
key={item.key}
onClick={() => setActiveKey(item.key)}
className={activeKey === item.key ? 'active' : ''}
>
{item.label}
</button>
))}
</div>
<div className="tabs-content">
{activeItem?.content}
</div>
</div>
);
}
// 使用
function App() {
return (
<Tabs
items={[
{
key: 'home',
label: '首页',
content: <div>首页内容</div>
},
{
key: 'profile',
label: '个人资料',
content: <div>个人资料内容</div>
},
{
key: 'settings',
label: '设置',
content: <div>设置内容</div>
}
]}
defaultActiveKey="home"
/>
);
}
渲染属性 (Render Props)
渲染属性是一种通过函数传递 React 元素的技术。它允许组件共享状态逻辑,同时让调用者决定如何渲染。
基础渲染属性
TypeScript
interface MouseTrackerProps {
render: (position: { x: number; y: number }) => React.ReactNode;
}
function MouseTracker({ render }: MouseTrackerProps) {
const [position, setPosition] = useState({ x: 0, y: 0 });
const handleMouseMove = (e: React.MouseEvent) => {
setPosition({
x: e.clientX,
y: e.clientY
});
};
return (
<div onMouseMove={handleMouseMove}>
{render(position)}
</div>
);
}
// 使用
function App() {
return (
<MouseTracker
render={({ x, y }) => (
<div>
鼠标位置: {x}, {y}
</div>
)}
/>
);
}
使用 children 作为渲染属性
TypeScript
interface DataFetcherProps {
url: string;
children: (data: any) => React.ReactNode;
}
function DataFetcher({ url, children }: DataFetcherProps) {
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(data => {
setData(data);
setLoading(false);
});
}, [url]);
if (loading) return <div>加载中...</div>;
if (!data) return null;
return <>{children(data)}</>;
}
// 使用
function App() {
return (
<DataFetcher url="/api/user/1">
{(user) => (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
)}
</DataFetcher>
);
}
渲染属性的注意事项
- 如果直接在 JSX 中写内联函数,每次渲染都会创建新函数,可能导致子组件不必要的重渲染
- 解决方案: 将渲染函数提取为组件方法或使用 useCallback
- 考虑使用自定义 Hooks 替代渲染属性
高阶组件 (HOC)
高阶组件是参数为组件,返回值为新组件的函数。HOC 用于复用组件逻辑。
基础 HOC
TypeScript
interface WithLoadingProps {
loading?: boolean;
}
function withLoading<P extends object>(
WrappedComponent: React.ComponentType<P>
): React.ComponentType<P & WithLoadingProps> {
return function WithLoadingComponent({ loading, ...props }: P & WithLoadingProps) {
if (loading) {
return <div className="spinner">加载中...</div>;
}
return <WrappedComponent {...(props as P)} />;
};
}
// 使用
function UserList({ users }: { users: User[] }) {
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
const UserListWithLoading = withLoading(UserList);
function App() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUsers().then(data => {
setUsers(data);
setLoading(false);
});
}, []);
return <UserListWithLoading users={users} loading={loading} />;
}
添加数据获取逻辑
TypeScript
interface WithDataProps<T> {
data: T | null;
error: Error | null;
loading: boolean;
refetch: () => void;
}
function withData<T, P extends object>(
fetcher: () => Promise<T>
) {
return function (WrappedComponent: React.ComponentType<P & WithDataProps<T>>) {
return functionWithData(props: P) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(true);
const fetchData = useCallback(async () => {
try {
setLoading(true);
const result = await fetcher();
setData(result);
setError(null);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
}, [fetcher]);
useEffect(() => {
fetchData();
}, [fetchData]);
return (
<WrappedComponent
{...(props as P)}
data={data}
error={error}
loading={loading}
refetch={fetchData}
/>
);
};
};
}
// 使用
function UserProfile({ data, error, loading }: WithDataProps<User>) {
if (loading) return <div>加载中...</div>;
if (error) return <div>错误: {error.message}</div>;
if (!data) return null;
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}
const UserProfileWithData = withData(() =>
fetch('/api/user/1').then(res => res.json())
)(UserProfile);
HOC 最佳实践
- 不要修改原始组件,应该组合
- 传递所有 props 到包装组件
- 最大化可组合性
- 显示 displayName 方便调试
- 考虑使用自定义 Hooks 替代 HOC
组件组合最佳实践
1. 单一职责原则
TypeScript
// ❌ 不好:组件做太多事情
function UserPage() {
const [user, setUser] = useState(null);
const [posts, setPosts] = useState([]);
const [comments, setComments] = useState([]);
// 获取用户、文章、评论...
// 渲染所有内容...
}
// ✅ 好:拆分为小组件
function UserPage() {
return (
<>
<UserInfo />
<UserPosts />
<UserComments />
</>
);
}
2. 控制输入 vs 受控输出
TypeScript
// 组件控制输入,父组件控制输出
interface CheckboxProps {
checked?: boolean;
defaultChecked?: boolean;
onChange?: (checked: boolean) => void;
children: React.ReactNode;
}
function Checkbox({ checked, defaultChecked = false, onChange, children }: CheckboxProps) {
const [internalChecked, setInternalChecked] = useState(defaultChecked);
const isChecked = checked !== undefined ? checked : internalChecked;
const handleChange = () => {
const newChecked = !isChecked;
if (checked === undefined) {
setInternalChecked(newChecked);
}
onChange?.(newChecked);
};
return (
<label>
<input
type="checkbox"
checked={isChecked}
onChange={handleChange}
/>
{children}
</label>
);
}
3. 使用 TypeScript 提升类型安全
TypeScript
interface Props<T> {
items: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string | number;
}
function GenericList<T>({ items, renderItem, keyExtractor }: Props<T>) {
return (
<ul>
{items.map(item => (
<li key={keyExtractor(item)}>
{renderItem(item)}
</li>
))}
</ul>
);
}
// 使用时类型自动推断
<GenericList
items={users}
keyExtractor={user => user.id}
renderItem={user => <UserCard user={user} />}
/>
4. 避免过度组合
TypeScript
// ❌ 不好:过度组合,难以理解
<Container>
<Wrapper>
<Box>
<Card>
<Header>
<Title>
<Text>内容</Text>
</Title>
</Header>
</Card>
</Box>
</Wrapper>
</Container>
// ✅ 好:简洁明了
<Card title="内容">
内容...
</Card>
如何选择组合方式
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 共享 UI 逻辑 | 自定义 Hooks | 简洁、类型安全 |
| 共享状态逻辑 | 自定义 Hooks 或 Context | 灵活、可组合 |
| 条件渲染 | 渲染属性或 children | 调用者控制渲染 |
| 增强组件功能 | HOC 或自定义 Hooks | 横切关注点 |
| 灵活布局 | 插槽模式 | 多个插入点 |