React 组件设计模式:容器/展示组件、自定义 Hooks 与控制反转
组件不只是「能跑就行」,长期维护下来,最能拉开差距的是:怎么划分职责、怎么复用逻辑、怎么控制依赖方向。
这一篇围绕几个常见模式,把这些问题串起来。
容器组件 vs 展示组件
最经典的一种切分方式是:
- 展示组件(Presentational Component):只关心「长什么样」,从 props 里拿数据和回调;
- 容器组件(Container Component):只关心「从哪来」,负责数据获取、状态管理、与外部系统交互。
一个简单的例子:
1// 展示组件
2function UserListView(props: {
3 users: { id: string; name: string }[];
4 loading: boolean;
5 onRetry: () => void;
6}) {
7 if (props.loading) {
8 return <div>加载中...</div>;
9 }
10
11 if (!props.users.length) {
12 return (
13 <div>
14 没有用户数据
15 <button onClick={props.onRetry}>重试</button>
16 </div>
17 );
18 }
19
20 return (
21 <ul>
22 {props.users.map(u => (
23 <li key={u.id}>{u.name}</li>
24 ))}
25 </ul>
26 );
27}
28
29// 容器组件
30function UserListContainer() {
31 const [users, setUsers] = useState<{ id: string; name: string }[]>([]);
32 const [loading, setLoading] = useState(false);
33
34 const fetchUsers = async () => {
35 setLoading(true);
36 try {
37 const res = await fetch('/api/users');
38 const data = await res.json();
39 setUsers(data);
40 } finally {
41 setLoading(false);
42 }
43 };
44
45 useEffect(() => {
46 fetchUsers();
47 }, []);
48
49 return (
50 <UserListView
51 users={users}
52 loading={loading}
53 onRetry={fetchUsers}
54 />
55 );
56}
这种切分方式的好处是明显的:
- UI 层可以在不动业务逻辑的情况下重构;
- 同一套数据逻辑可以搭配多种不同的展示形态。
从容器组件到自定义 Hooks
在函数组件 + Hooks 普及之后,更推荐的做法是把「容器逻辑」下沉到自定义 Hook 里:
1function useUsers() {
2 const [users, setUsers] = useState<{ id: string; name: string }[]>([]);
3 const [loading, setLoading] = useState(false);
4 const [error, setError] = useState<Error | null>(null);
5
6 const fetchUsers = useCallback(async () => {
7 setLoading(true);
8 setError(null);
9 try {
10 const res = await fetch('/api/users');
11 const data = await res.json();
12 setUsers(data);
13 } catch (e) {
14 setError(e as Error);
15 } finally {
16 setLoading(false);
17 }
18 }, []);
19
20 useEffect(() => {
21 fetchUsers();
22 }, [fetchUsers]);
23
24 return { users, loading, error, retry: fetchUsers };
25}
然后在组件里按需组合:
1function UserList() {
2 const { users, loading, error, retry } = useUsers();
3
4 if (loading) return <div>加载中...</div>;
5 if (error) return <div>出错了 <button onClick={retry}>重试</button></div>;
6
7 return (
8 <ul>
9 {users.map(u => (
10 <li key={u.id}>{u.name}</li>
11 ))}
12 </ul>
13 );
14}
自定义 Hook 相当于把原来「容器组件里的那块逻辑」抽出来,变成一个可复用的逻辑单元,再由各个组件去消费。
受控组件与非受控组件
受控组件:外界完全掌控
受控组件指的是:组件自身不持久保存状态,所有值都从 props 传入,并通过回调把变更通知父组件。
1type InputProps = {
2 value: string;
3 onChange: (next: string) => void;
4};
5
6function TextInput({ value, onChange }: InputProps) {
7 return (
8 <input
9 value={value}
10 onChange={e => onChange(e.target.value)}
11 />
12 );
13}
父组件可以把它当作一个「带 UI 的函数」,所有逻辑和状态都在外面:
1function Form() {
2 const [name, setName] = useState('');
3 return (
4 <TextInput
5 value={name}
6 onChange={setName}
7 />
8 );
9}
好处是:
- 状态集中,调试容易;
- 更容易做表单校验、回滚、持久化等。
非受控组件:自己内部管一部分
非受控组件会自己内部维护状态,例如直接用 defaultValue 或 useRef 访问 DOM。
1function UncontrolledInput() {
2 const inputRef = useRef<HTMLInputElement | null>(null);
3
4 function handleSubmit() {
5 alert(inputRef.current?.value);
6 }
7
8 return (
9 <>
10 <input ref={inputRef} defaultValue="hello" />
11 <button onClick={handleSubmit}>提交</button>
12 </>
13 );
14}
对于简单、边缘性的输入,非受控组件可以节省不少样板代码;但一旦表单复杂起来,通常会转向受控模型或表单库。
受控/非受控的混合模式
在实际组件库设计中,很常见的一种模式是:
- 既支持受控,也支持非受控;
- 组件内部用一个 Hook 统一处理这两种情况。
简单示意:
1function useControllable<T>({
2 value,
3 defaultValue,
4 onChange,
5}: {
6 value?: T;
7 defaultValue: T;
8 onChange?: (v: T) => void;
9}) {
10 const [inner, setInner] = useState(defaultValue);
11 const isControlled = value !== undefined;
12 const current = isControlled ? (value as T) : inner;
13
14 const setValue = useCallback(
15 (next: T) => {
16 if (!isControlled) {
17 setInner(next);
18 }
19 onChange?.(next);
20 },
21 [isControlled, onChange]
22 );
23
24 return [current, setValue] as const;
25}
组件内部只关心 value 和 setValue,外界可以自由选择是完全受控还是半受控。
控制反转:让父层决定策略
React 天然鼓励一种「控制反转」的写法:组件不要自己拍板,而是通过回调、Render Props、自定义 Hook 等,把「关键节点」交出去。
用回调开放关键环节
例如一个通用列表组件,可以让父组件决定「如何渲染行」:
1type ListProps<T> = {
2 data: T[];
3 renderItem: (item: T, index: number) => React.ReactNode;
4};
5
6function List<T>({ data, renderItem }: ListProps<T>) {
7 return (
8 <ul>
9 {data.map((item, index) => (
10 <li key={index}>{renderItem(item, index)}</li>
11 ))}
12 </ul>
13 );
14}
这里的关键点在于:
- List 只管「循环」和「结构」,不关心内容细节;
- 具体一行长什么样,由外部通过
renderItem决定。
这种模式可以一路叠加:一个组件只处理自己最擅长的那一块,剩下的交给上游。
用自定义 Hook 做「业务逻辑的控制反转」
自定义 Hook 不一定是对 UI 的抽象,很多时候更像是对「业务流程」的抽象:
1function useSubmitOrder(
2 onSuccess?: (orderId: string) => void,
3 onError?: (e: Error) => void
4) {
5 const [loading, setLoading] = useState(false);
6
7 const submit = useCallback(
8 async (payload: any) => {
9 setLoading(true);
10 try {
11 const res = await fetch('/api/order', {
12 method: 'POST',
13 body: JSON.stringify(payload),
14 });
15 const data = await res.json();
16 onSuccess?.(data.id);
17 } catch (e) {
18 onError?.(e as Error);
19 } finally {
20 setLoading(false);
21 }
22 },
23 [onSuccess, onError]
24 );
25
26 return { submit, loading };
27}
这个 Hook 把「下单流程」打包好了,但并不决定:
- 成功后跳去哪个页面;
- 失败时用什么样的提示方式。
这些决策完全交给调用方,通过回调注入,避免逻辑散落在一堆组件事件处理里。
组合优先于继承
在 React 世界里,组合几乎是默认答案:
- 用组件组合来拼 UI 结构;
- 用 Hook 组合来拼逻辑;
- 用 Context/Provider 组合来拼上下文。
当你发现某个组件「越来越像一个基类」时,通常可以尝试:
- 把公共逻辑挪到 Hook 里;
- 把公共结构拆成更小的子组件;
- 把「容易变化的决策点」通过 props 或回调外抛。
这样做的结果是:大部分差异都能在组合层解决,而不是在继承层解决。
小结
- 容器/展示组件的划分,解决的是「数据来自哪、UI 长什么样」的职责分离问题。
- 自定义 Hook 是容器逻辑的自然演化形式,更适合函数组件下的逻辑复用。
- 受控与非受控的选择,影响的是状态在哪里收束;复杂表单通常更适合受控或借助表单库。
- 控制反转贯穿在 props、回调、自定义 Hook 这些机制里,让组件可以把决策权交给调用方,保持自身简单可组合。