design-pattern
design-pattern/react-patterns/container-presentational.md
low
react-container-presentational
把取数+状态逻辑(容器/hook)与纯渲染(展示组件,只收 props 无副作用)分离,展示层易测易复用易 Storybook。Use when 取数与渲染揉在一个大组件 / 想让展示组件可单测可 Storybook / 同一份 UI 复用于多个数据源时。
container presentational容器组件展示组件presentational component取数与渲染分离纯渲染组件
paths
frontend/src/**/*.tsx
Design Pattern · 容器/展示分离
规则
决策点:这段代码在"拿数据"还是在"画 UI"? 拿数据→容器(自定义 hook 承载逻辑);画 UI→展示组件(纯函数、只收 props、零副作用)。
| 层 | 职责 | 约束 |
|---|---|---|
| 容器 | 取数、订阅、状态、回调编排 | 现代做法用 useXxx hook 承载,不再写 class 容器 |
| 展示 | 把 props 渲染成 JSX | 纯函数、无 useEffect/fetch/全局状态,回调一律 props 注入 |
逻辑下沉到 hook 见 ./custom-hook-extraction.md。何时不必拆: 极简组件(一处取数 + 几行 JSX、不复用、无需单测/Storybook)别硬拆,徒增间接层。
反例:取数与渲染耦合在一个大组件
// ❌ 请求、loading、渲染全堆一起:无法单独测 UI,也无法在 Storybook 喂假数据
function OrderList() {
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
fetch("/api/orders").then((r) => r.json()).then((d) => { setOrders(d); setLoading(false); });
}, []);
if (loading) return <Spin />;
return <List dataSource={orders} renderItem={(o) => <List.Item>{o.title}</List.Item>} />;
}
正例:useOrders hook(容器) + 纯展示组件
// frontend/src/features/order/useOrders.ts —— 容器逻辑:取数 + 状态,不含任何 JSX
import { useEffect, useState } from "react";
import { fetchOrders } from "@/api/order";
interface OrdersState {
orders: Order[];
loading: boolean;
}
export function useOrders(): OrdersState {
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
// 取消令牌:卸载时中断在途请求
const ctrl = new AbortController();
// 取数:请求下沉 api 层纯函数,signal 透传以支持取消
setLoading(true);
fetchOrders(ctrl.signal).then((list) => {
setOrders(list);
setLoading(false);
});
return () => ctrl.abort();
}, []);
return { orders, loading }; // 多个相关项→返回对象,展示侧按需解构
}
// frontend/src/features/order/OrderListView.tsx —— 纯展示:只收 props,无副作用,易测易 Storybook
interface OrderListViewProps {
orders: Order[];
loading: boolean;
}
export function OrderListView({ orders, loading }: OrderListViewProps) {
// 前置:加载中早返回,渲染体保持平坦
if (loading) return <Spin />;
// 渲染:列表用 antd renderItem 映射,不手写 for
return (
<List
dataSource={orders}
renderItem={(order) => <List.Item key={order.id}>{order.title}</List.Item>}
/>
);
}
// 装配:容器 hook 喂数据给纯展示组件,各司其职
function OrderListPage() {
const { orders, loading } = useOrders();
return <OrderListView orders={orders} loading={loading} />;
}
自检
- [ ] 展示组件是纯函数?(无
useEffect/fetch/全局状态读写,数据与回调全经 props) - [ ] 取数与状态逻辑收敛在
useXxxhook(而非 class 容器),副作用有清理函数取消在途请求? - [ ] 展示组件能脱离真实接口单测、能在 Storybook 喂假数据?
- [ ] 极简且不复用的组件没有为拆而拆?