06-高级模式与实战项目——05. 容器与展示组件
·
05. 容器与展示组件
概述
容器与展示组件模式是一种将逻辑与 UI 分离的设计模式。容器组件负责数据获取和状态管理,展示组件负责 UI 渲染。这种模式提高了代码的可测试性和复用性。
| 维度 | 内容 |
|---|---|
| What | 容器组件负责逻辑和数据,展示组件负责 UI 渲染 |
| Why | 分离关注点,提高可测试性和复用性 |
| When | 组件有复杂逻辑和 UI 时 |
| Where | 组件分层设计 |
| Who | 需要组件逻辑复用的开发者 |
| How | ContainerComponent → 获取数据 → PresentationalComponent 渲染 |
1. 什么是容器与展示组件
1.1 基本概念
- 容器组件:负责数据获取、状态管理、业务逻辑
- 展示组件:负责 UI 渲染,通过 props 接收数据
// 展示组件:只负责 UI 渲染
function UserList({ users, loading, error, onRetry }) {
if (loading) return <div>加载中...</div>;
if (error) return <div>错误: {error.message} <button onClick={onRetry}>重试</button></div>;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
// 容器组件:负责数据获取和逻辑
function UserListContainer() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchUsers = async () => {
try {
setLoading(true);
const response = await fetch('/api/users');
const data = await response.json();
setUsers(data);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, []);
return (
<UserList
users={users}
loading={loading}
error={error}
onRetry={fetchUsers}
/>
);
}
1.2 为什么需要分离
// ❌ 耦合在一起:难以测试和复用
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(setUsers)
.finally(() => setLoading(false));
}, []);
if (loading) return <div>加载中...</div>;
return <ul>{users.map(user => <li key={user.id}>{user.name}</li>)}</ul>;
}
2. 展示组件
2.1 展示组件的特点
- 只关心 UI 渲染
- 通过 props 接收数据
- 不依赖外部 API
- 易于测试
- 易于复用
// 展示组件示例
function Button({ children, onClick, disabled, variant = 'primary' }) {
const variants = {
primary: 'bg-blue-500 text-white',
secondary: 'bg-gray-500 text-white',
danger: 'bg-red-500 text-white',
};
return (
<button
onClick={onClick}
disabled={disabled}
className={`px-4 py-2 rounded ${variants[variant]} ${disabled ? 'opacity-50' : ''}`}
>
{children}
</button>
);
}
function Card({ title, children, footer }) {
return (
<div className="border rounded-lg shadow">
{title && <div className="border-b p-4 font-bold">{title}</div>}
<div className="p-4">{children}</div>
{footer && <div className="border-t p-4 text-gray-500">{footer}</div>}
</div>
);
}
function UserCard({ user, onEdit, onDelete }) {
return (
<Card title={user.name} footer={`邮箱: ${user.email}`}>
<p>角色: {user.role}</p>
<div className="flex gap-2 mt-4">
<Button variant="secondary" onClick={() => onEdit(user)}>编辑</Button>
<Button variant="danger" onClick={() => onDelete(user.id)}>删除</Button>
</div>
</Card>
);
}
2.2 展示组件的测试
// 展示组件测试非常容易
import { render, screen, fireEvent } from '@testing-library/react';
test('UserCard 渲染用户信息', () => {
const user = { id: 1, name: '张三', email: 'zhang@example.com', role: 'admin' };
const onEdit = jest.fn();
const onDelete = jest.fn();
render(<UserCard user={user} onEdit={onEdit} onDelete={onDelete} />);
expect(screen.getByText('张三')).toBeInTheDocument();
expect(screen.getByText('zhang@example.com')).toBeInTheDocument();
fireEvent.click(screen.getByText('编辑'));
expect(onEdit).toHaveBeenCalledWith(user);
});
3. 容器组件
3.1 容器组件的特点
- 负责数据获取
- 负责状态管理
- 负责业务逻辑
- 将数据传递给展示组件
// 容器组件示例
function UserListContainer() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchUsers = useCallback(async () => {
try {
setLoading(true);
const response = await api.getUsers();
setUsers(response.data);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
}, []);
const handleEdit = useCallback(async (user) => {
await api.updateUser(user.id, user);
fetchUsers(); // 刷新列表
}, [fetchUsers]);
const handleDelete = useCallback(async (id) => {
await api.deleteUser(id);
fetchUsers();
}, [fetchUsers]);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
return (
<div>
<h1>用户管理</h1>
{loading ? (
<Spinner />
) : error ? (
<ErrorMessage error={error} onRetry={fetchUsers} />
) : (
<div className="user-grid">
{users.map(user => (
<UserCard
key={user.id}
user={user}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
)}
</div>
);
}
3.2 容器组件的测试
// 容器组件测试需要 mock API
import { render, screen, waitFor } from '@testing-library/react';
import { api } from './api';
jest.mock('./api');
test('UserListContainer 加载并显示用户', async () => {
const mockUsers = [
{ id: 1, name: '张三', email: 'zhang@example.com', role: 'admin' },
];
api.getUsers.mockResolvedValue({ data: mockUsers });
render(<UserListContainer />);
expect(screen.getByText('加载中...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('张三')).toBeInTheDocument();
});
});
4. 实际应用场景
4.1 数据获取场景
// 展示组件
function PostList({ posts, loading, error, onRetry }) {
if (loading) return <PostSkeleton count={5} />;
if (error) return <ErrorView error={error} onRetry={onRetry} />;
return (
<div className="posts">
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}
// 容器组件
function PostListContainer() {
const { data: posts, isLoading, error, refetch } = useQuery({
queryKey: ['posts'],
queryFn: () => fetch('/api/posts').then(res => res.json()),
});
return (
<PostList
posts={posts}
loading={isLoading}
error={error}
onRetry={refetch}
/>
);
}
4.2 表单场景
// 展示组件:表单 UI
function LoginForm({ values, errors, onChange, onSubmit, isLoading }) {
return (
<form onSubmit={onSubmit} className="login-form">
<div>
<input
name="email"
type="email"
value={values.email}
onChange={onChange}
placeholder="邮箱"
className={errors.email ? 'error' : ''}
/>
{errors.email && <span className="error">{errors.email}</span>}
</div>
<div>
<input
name="password"
type="password"
value={values.password}
onChange={onChange}
placeholder="密码"
/>
</div>
<button type="submit" disabled={isLoading}>
{isLoading ? '登录中...' : '登录'}
</button>
</form>
);
}
// 容器组件:表单逻辑
function LoginFormContainer() {
const [values, setValues] = useState({ email: '', password: '' });
const [errors, setErrors] = useState({});
const [isLoading, setIsLoading] = useState(false);
const handleChange = (e) => {
const { name, value } = e.target;
setValues(prev => ({ ...prev, [name]: value }));
// 实时验证
if (name === 'email' && !value.includes('@')) {
setErrors(prev => ({ ...prev, email: '邮箱格式错误' }));
} else {
setErrors(prev => ({ ...prev, email: '' }));
}
};
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
try {
await login(values.email, values.password);
// 跳转到首页
} catch (error) {
setErrors(prev => ({ ...prev, form: error.message }));
} finally {
setIsLoading(false);
}
};
return (
<LoginForm
values={values}
errors={errors}
onChange={handleChange}
onSubmit={handleSubmit}
isLoading={isLoading}
/>
);
}
5. 完整示例:商品管理系统
// ============ 展示组件 ============
// ProductCard 展示组件
function ProductCard({ product, onEdit, onDelete }) {
return (
<div className="product-card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p className="price">¥{product.price}</p>
<p className="stock">库存: {product.stock}</p>
<div className="actions">
<button onClick={() => onEdit(product)}>编辑</button>
<button onClick={() => onDelete(product.id)}>删除</button>
</div>
</div>
);
}
// ProductForm 展示组件
function ProductForm({ initialValues, onSubmit, isLoading }) {
const [values, setValues] = useState(initialValues || { name: '', price: '', stock: '' });
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(values);
};
return (
<form onSubmit={handleSubmit} className="product-form">
<input
value={values.name}
onChange={(e) => setValues({ ...values, name: e.target.value })}
placeholder="商品名称"
required
/>
<input
type="number"
value={values.price}
onChange={(e) => setValues({ ...values, price: e.target.value })}
placeholder="价格"
required
/>
<input
type="number"
value={values.stock}
onChange={(e) => setValues({ ...values, stock: e.target.value })}
placeholder="库存"
required
/>
<button type="submit" disabled={isLoading}>
{isLoading ? '保存中...' : '保存'}
</button>
</form>
);
}
// ProductList 展示组件
function ProductList({ products, loading, error, onRetry, onEdit, onDelete }) {
if (loading) return <div>加载商品中...</div>;
if (error) return (
<div>
错误: {error.message}
<button onClick={onRetry}>重试</button>
</div>
);
return (
<div className="product-grid">
{products.map(product => (
<ProductCard
key={product.id}
product={product}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
);
}
// ============ 容器组件 ============
function ProductManagement() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [showForm, setShowForm] = useState(false);
const [editingProduct, setEditingProduct] = useState(null);
const [saving, setSaving] = useState(false);
const fetchProducts = useCallback(async () => {
try {
setLoading(true);
const response = await fetch('/api/products');
const data = await response.json();
setProducts(data);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
}, []);
const handleSave = async (product) => {
setSaving(true);
try {
const url = editingProduct ? `/api/products/${editingProduct.id}` : '/api/products';
const method = editingProduct ? 'PUT' : 'POST';
await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(product),
});
await fetchProducts();
setShowForm(false);
setEditingProduct(null);
} catch (err) {
console.error('保存失败:', err);
} finally {
setSaving(false);
}
};
const handleEdit = (product) => {
setEditingProduct(product);
setShowForm(true);
};
const handleDelete = async (id) => {
if (!confirm('确定删除吗?')) return;
try {
await fetch(`/api/products/${id}`, { method: 'DELETE' });
await fetchProducts();
} catch (err) {
console.error('删除失败:', err);
}
};
useEffect(() => {
fetchProducts();
}, [fetchProducts]);
return (
<div className="product-management">
<div className="header">
<h1>商品管理</h1>
<button onClick={() => setShowForm(true)}>添加商品</button>
</div>
{showForm && (
<div className="modal">
<div className="modal-content">
<h2>{editingProduct ? '编辑商品' : '添加商品'}</h2>
<ProductForm
initialValues={editingProduct}
onSubmit={handleSave}
isLoading={saving}
/>
<button onClick={() => {
setShowForm(false);
setEditingProduct(null);
}}>取消</button>
</div>
</div>
)}
<ProductList
products={products}
loading={loading}
error={error}
onRetry={fetchProducts}
onEdit={handleEdit}
onDelete={handleDelete}
/>
</div>
);
}
6. 总结
核心要点
| 类型 | 职责 | 特点 |
|---|---|---|
| 展示组件 | UI 渲染 | 纯函数、易测试、易复用 |
| 容器组件 | 逻辑状态 | 有副作用、连接数据 |
优势
- 关注点分离
- 提高可测试性
- 提高可复用性
- 便于协作开发
记忆口诀
容器组件管逻辑,展示组件管 UI
数据 props 传下去,测试复用都容易
7. 相关资源
更多推荐


所有评论(0)