#React#TypeScript

React 设计模式:组件抽象的艺术

组件怎么拆、状态怎么管、逻辑怎么复用——这些是"设计模式"决定的。本文讲清 React 常用模式:容器/展示分离、自定义 Hook(逻辑复用)、Render Props、Compound Components、Provider 模式,以及每个模式解决什么问题。

本文要点

  • 主题聚焦:React、TypeScript
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年3月7日·更新于 2026年7月23日

写 React 谁都会,但项目一大就分崩离析——组件塞了三百行、状态散在十个文件里、同样的取数逻辑抄了五遍。差距往往不在 React 本身,而在抽象的层次:你把什么放进组件、什么抽出来、什么共享、什么隔离。这就是设计模式要解决的事。

下面这些模式不是炫技,是十年社区踩坑后沉淀下来的"代码组织约定"。每一个都对应一类具体问题,记住问题比记住名字更重要。

一、为什么需要设计模式

React 本身只给你三个东西:组件、props、hooks。怎么用是你的事。这种自由在小项目里是优点,在中大型项目里是灾难。

典型症状你一定见过:

  • 一个组件既调接口、又管状态、又写 UI、还处理动画,改一行要读五百行。
  • 同一段"拿用户信息 + 处理 loading"逻辑,在 Header、Profile、Settings 里各写一遍,后端字段一改,三处一起出 bug。
  • 两个兄弟组件要共享一个状态,只能往共同祖先里塞,祖先越来越胖。
  • 一个表单库用法 <Form><Field><Submit>,你想让用户完全自由地排版,却又不希望他去管每个字段的 value/onChange。

设计模式就是这些问题的"标准答案"。

  • 容器/展示分离解决组件职责不清。
  • 自定义 Hook 解决逻辑复用。
  • Compound Components 解决灵活组合 + 内部状态共享。
  • Provider 模式解决跨层级状态共享。
  • 控制反转解决父子命令式通信。

理解了问题,模式就只是名字。下面逐个拆。

二、容器/展示组件分离(Smart / Dumb)

这是最古老也最常被误读的模式。核心思想一句话:把"做什么"和"怎么画"分开。

  • 容器组件(Smart):管数据获取、状态、副作用、业务逻辑。几乎不写 UI,只负责把数据喂给子组件。
  • 展示组件(Dumb):纯 UI,所有数据从 props 进来,自己不发请求、不存全局状态。可复用、可测、可放进 Storybook。

好处是:UI 组件变成"哑的",你可以在不同数据源之间复用它;逻辑组件可以独立测试,不用 mock 渲染。

tsx
// 展示组件:纯 UI,不知道数据从哪来
type UserCardProps = {
  name: string;
  avatar: string;
  loading?: boolean;
};

function UserCard({ name, avatar, loading }: UserCardProps) {
  if (loading) return <div className="h-16 animate-pulse rounded bg-gray-200" />;
  return (
    <div className="flex items-center gap-3">
      <img src={avatar} alt={name} className="h-12 w-12 rounded-full" />
      <span className="font-medium">{name}</span>
    </div>
  );
}

// 容器组件:管数据,把结果交给展示组件
import { useEffect, useState } from "react";

function UserCardContainer({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then((r) => r.json())
      .then((data) => {
        setUser(data);
        setLoading(false);
      });
  }, [userId]);

  return user
    ? <UserCard name={user.name} avatar={user.avatar} />
    : <UserCard name="" avatar="" loading />;
}

我的实战建议:别把它当教条。如果一个组件只有这一处用、逻辑就二十行,强行拆成两个文件是负担。分离的价值在组件真正变得复杂或UI 真的需要复用时才显现。引入 Server Components 后,这个模式的形态也变了——容器可以是一个 Server Component 直接查数据,展示组件挂在它下面。思想没变,边界往下推了。

三、自定义 Hook:逻辑复用的首选

React 团队有一句被反复引用的话:"人们用 HOC、Render Props 想解决的问题,90% 都该用 Hook 解决。"

Hook 是当前 React 里逻辑复用的最优解。它把"状态 + 副作用"打包成一个函数,任何组件都能调用,且各组件之间的状态是隔离的(每次调用都是独立实例)。

经典场景:埋点、表单、防抖、请求、订阅外部存储。下面是一个真正能上生产的 useFetch:

tsx
import { useEffect, useRef, useState } from "react";

type State<T> = {
  data: T | null;
  error: Error | null;
  loading: boolean;
};

export function useFetch<T>(url: string, options?: RequestInit): State<T> {
  const [state, setState] = useState<State<T>>({
    data: null,
    error: null,
    loading: true,
  });

  // 防止组件卸载后还 setState,以及 url 变化时竞态
  const aborted = useRef(false);

  useEffect(() => {
    aborted.current = false;
    setState((s) => ({ ...s, loading: true, error: null }));

    fetch(url, options)
      .then((r) => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
      })
      .then((data) => {
        if (!aborted.current) setState({ data, error: null, loading: false });
      })
      .catch((error) => {
        if (!aborted.current)
          setState({ data: null, error, loading: false });
      });

    return () => {
      aborted.current = true;
    };
  }, [url]);

  return state;
}

// 用法:干净、可复用、状态隔离
function Profile({ userId }: { userId: string }) {
  const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`);

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  return <UserCard name={user!.name} avatar={user!.avatar} />;
}

Hook 的隐藏优势:它不绑架你的组件树。HOC 会层层包裹组件、显示在 DevTools 里一坨 withX(withY(withZ(...))),props 也会冲突。Hook 是平铺的,想用就在组件里 use 一下,不想用就删掉。新建逻辑复用代码,默认从 Hook 起手。

四、Render Props:被 Hook 替代大半,但仍有场景

Render Props 是指:用一个值为函数的 prop(通常叫 render 或 children)来动态决定渲染什么。父组件提供数据/逻辑,子组件只负责调这个函数。

tsx
import { useState, type ReactNode } from "react";

// 鼠标位置追踪器:逻辑在这里,渲染由使用者决定
type MouseTrackerProps = {
  children: (pos: { x: number; y: number }) => ReactNode;
};

function MouseTracker({ children }: MouseTrackerProps) {
  const [pos, setPos] = useState({ x: 0, y: 0 });
  return (
    <div onMouseMove={(e) => setPos({ x: e.clientX, y: e.clientY })}>
      {children(pos)}
    </div>
  );
}

// 用法:渲染完全由调用方控制
<MouseTracker>
  {({ x, y }) => <div>当前坐标:{x}, {y}</div>}
</MouseTracker>

实事求是地说:这个模式 80% 的旧用途都该用 Hook 重写(上面这个例子就是个标准的 useMousePosition)。Hook 更轻、可组合、不会产生"嵌套地狱"。

那 Render Props 什么时候还有价值?当你需要把状态保留在子组件里、又想让父组件控制渲染结构时。例如一个动画库 <Motion> 暴露当前进度,或者一个虚拟列表暴露当前可见项。React Motion、downshift 这类库至今保留 Render Props API,就是因为这种"我管状态、你管画"的边界比 Hook 更自然。学会识别它,但别第一反应就用它。

五、Compound Components:组合式 API 的精髓

这是我认为最优雅的一个模式,也是 Radix UI、shadcn/ui、Headless UI 的核心套路。

问题场景:你要做一个 Tabs 组件。最朴素的 API 是配置式:

tsx
<Tabs items={[...]} activeIndex={0} onChange={...} />

能用,但布局写死了——想在标签页之间插个按钮、想换排序、想自定义某个 panel 的样式,都很难。

Compound Components 的解法是把组件拆成一组可组合的小组件,内部通过 Context 共享状态,但对使用者完全隐藏:

tsx
import {
  createContext,
  useContext,
  useState,
  type ReactNode,
} from "react";

// 1) 内部 Context:把状态藏起来,只暴露给子组件
type TabsCtx = { value: string; setValue: (v: string) => void };
const TabsContext = createContext<TabsCtx | null>(null);
const useTabs = () => {
  const ctx = useContext(TabsContext);
  if (!ctx) throw new Error("Tabs 子组件必须放在 <Tabs> 内");
  return ctx;
};

// 2) 容器:持有状态,提供 Context
function Tabs({
  default_value,
  children,
}: {
  default_value: string;
  children: ReactNode;
}) {
  const [value, setValue] = useState(default_value);
  return (
    <TabsContext.Provider value={{ value, setValue }}>
      {children}
    </TabsContext.Provider>
  );
}

// 3) 各子组件:从 Context 读状态,不知道彼此存在
function TabsList({ children }: { children: ReactNode }) {
  return <div role="tablist">{children}</div>;
}

function TabsTrigger({ value, children }: { value: string; children: ReactNode }) {
  const { value: active, setValue } = useTabs();
  return (
    <button
      role="tab"
      aria-selected={active === value}
      onClick={() => setValue(value)}
      className={active === value ? "border-b-2 border-blue-500" : ""}
    >
      {children}
    </button>
  );
}

function TabsPanel({ value, children }: { value: string; children: ReactNode }) {
  const { value: active } = useTabs();
  if (active !== value) return null;
  return <div role="tabpanel">{children}</div>;
}

// 4) 暴露组合式 API
Tabs.List = TabsList;
Tabs.Trigger = TabsTrigger;
Tabs.Panel = TabsPanel;

使用者拿到的 API 是这样的:

tsx
<Tabs default_value="overview">
  <Tabs.List>
    <Tabs.Trigger value="overview">概览</Tabs.Trigger>
    <Tabs.Trigger value="settings">设置</Tabs.Trigger>
    {/* 想插个按钮?随便插 */}
    <button className="ml-auto">导出</button>
  </Tabs.List>
  <Tabs.Panel value="overview"><Overview /></Tabs.Panel>
  <Tabs.Panel value="settings"><Settings /></Tabs.Panel>
</Tabs>

关键点:使用者完全控制 JSX 结构,但不需要管状态(谁激活、切换逻辑)。状态在内部用 Context 共享,对外是黑盒。这就是 Headless 库最值钱的能力——把行为给你,把样式和结构留给自己。一旦你写过这种 API,就再也回不去配置式了。

六、Provider 模式:Context + Provider

跨组件树传数据,props drilling(层层透传)是反模式。Provider 模式用 Context 把状态"挂"到树上的某个节点,后代都能取。

典型用途:主题、当前用户、i18n、编辑器实例、Toast 队列。

tsx
import {
  createContext,
  useContext,
  useMemo,
  useState,
  type ReactNode,
} from "react";

type Theme = "light" | "dark";
type ThemeCtx = { theme: Theme; toggle: () => void };

const ThemeContext = createContext<ThemeCtx | null>(null);

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>("light");

  // 关键:useMemo 稳定 value,避免每次重渲染所有消费者
  const value = useMemo<ThemeCtx>(
    () => ({
      theme,
      toggle: () => setTheme((t) => (t === "light" ? "dark" : "light")),
    }),
    [theme],
  );

  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

export function useTheme() {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error("useTheme 必须在 <ThemeProvider> 内调用");
  return ctx;
}

// 任何深度后代都能直接拿
function Toolbar() {
  const { theme, toggle } = useTheme();
  return <button onClick={toggle}>当前:{theme}</button>;
}

Provider 的坑在性能:Context value 变化时,所有 useContext 它的组件都会重渲染,不管你取的字段有没有变。常见误操作是直接 value={{ a, b }}(每次新对象),整个树白跑一遍。两个原则:

  1. Provider value 必须 memo 化。
  2. Context 要按变化频率切片。把经常变的(如鼠标位置)和不常变的(如用户信息)放两个 Context,否则会互相连累重渲染。

别用 Context 当 Redux 用。它适合低频、全局、读多写少的状态。高频更新我更推荐状态库(Zustand / Jotai)或局部状态。

七、控制反转:forwardRef + useImperativeHandle

React 是声明式的,但偶尔你需要"命令"子组件:让 input 聚焦、让 list 滚到底、让 player 播放。这时候用 forwardRef 把 ref 暴露出去,再用 useImperativeHandle 精确控制暴露哪些方法。

tsx
import {
  forwardRef,
  useImperativeHandle,
  useRef,
  type ImperativeHandle,
} from "react";

// 子组件:定义自己对外暴露的"指令集"
type FancyInputHandle = {
  focus: () => void;
  clear: () => void;
  getValue: () => string;
};

const FancyInput = forwardRef<FancyInputHandle, { placeholder?: string }>(
  ({ placeholder }, ref) => {
    const inputRef = useRef<HTMLInputElement>(null);

    useImperativeHandle(ref, () => ({
      focus: () => inputRef.current?.focus(),
      clear: () => {
        if (inputRef.current) inputRef.current.value = "";
      },
      getValue: () => inputRef.current?.value ?? "",
    }));

    return <input ref={inputRef} placeholder={placeholder} />;
  },
);
FancyInput.displayName = "FancyInput";

// 父组件:像调方法一样命令子组件
function Form() {
  const inputRef = useRef<FancyInputHandle>(null);
  return (
    <>
      <FancyInput ref={inputRef} placeholder="搜索..." />
      <button onClick={() => inputRef.current?.focus()}>聚焦</button>
      <button onClick={() => inputRef.current?.clear()}>清空</button>
    </>
  );
}

注意 React 19 起,函数组件可以直接接收 ref 作为 props,forwardRef 正在被淡化。但 useImperativeHandle 这个"暴露有限接口"的思想不会变:永远只暴露最小集合,把 ref 当成组件的公开 API 来设计,而不是把整个内部状态裸奔地交出去。

小结

挑几个能记住的点:

  1. 逻辑复用 → Hook,这是默认选项。HOC 几乎可以进博物馆了。
  2. UI 组合 + 内部状态共享 → Compound Components,做组件库时的杀手锏。
  3. 全局/区域状态 → Context + Provider,但记得 memo 化 value、按频率切片。
  4. 命令式控制子组件 → useImperativeHandle,只暴露最小接口。
  5. 简单优先。容器/展示分离、Render Props 都别预先套用——模式是复杂度的解药,不是先发制人的枷锁。

React 设计模式的本质,不是把代码塞进某个形状,而是把变化的那部分和稳定的那部分分离开。状态、副作用、布局、样式,各自有各自的边界——边界清楚了,模式自己就长出来了。

过度抽象比没有抽象更糟。看到一个模式想用之前,先问自己:我这个问题,真的到了需要它的复杂度吗?

Related

相关推荐