React 组件设计模式:容器/展示组件、自定义 Hooks 与控制反转

组件不只是「能跑就行」,长期维护下来,最能拉开差距的是:怎么划分职责、怎么复用逻辑、怎么控制依赖方向

这一篇围绕几个常见模式,把这些问题串起来。

最经典的一种切分方式是:

  • 展示组件(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 普及之后,更推荐的做法是把「容器逻辑」下沉到自定义 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}

好处是:

  • 状态集中,调试容易;
  • 更容易做表单校验、回滚、持久化等。

非受控组件会自己内部维护状态,例如直接用 defaultValueuseRef 访问 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}

组件内部只关心 valuesetValue,外界可以自由选择是完全受控还是半受控。

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 不一定是对 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 这些机制里,让组件可以把决策权交给调用方,保持自身简单可组合。