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. 相关资源
- Presentational and Container Components
- React 组件模式