React 设计模式:组件抽象的艺术
组件怎么拆、状态怎么管、逻辑怎么复用——这些是"设计模式"决定的。本文讲清 React 常用模式:容器/展示分离、自定义 Hook(逻辑复用)、Render Props、Compound Components、Provider 模式,以及每个模式解决什么问题。
本文要点
- 主题聚焦:React、TypeScript
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
写 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 渲染。
// 展示组件:纯 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:
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)来动态决定渲染什么。父组件提供数据/逻辑,子组件只负责调这个函数。
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 是配置式:
<Tabs items={[...]} activeIndex={0} onChange={...} />
能用,但布局写死了——想在标签页之间插个按钮、想换排序、想自定义某个 panel 的样式,都很难。
Compound Components 的解法是把组件拆成一组可组合的小组件,内部通过 Context 共享状态,但对使用者完全隐藏:
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 是这样的:
<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 队列。
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 }}(每次新对象),整个树白跑一遍。两个原则:
- Provider value 必须 memo 化。
- Context 要按变化频率切片。把经常变的(如鼠标位置)和不常变的(如用户信息)放两个 Context,否则会互相连累重渲染。
别用 Context 当 Redux 用。它适合低频、全局、读多写少的状态。高频更新我更推荐状态库(Zustand / Jotai)或局部状态。
七、控制反转:forwardRef + useImperativeHandle
React 是声明式的,但偶尔你需要"命令"子组件:让 input 聚焦、让 list 滚到底、让 player 播放。这时候用 forwardRef 把 ref 暴露出去,再用 useImperativeHandle 精确控制暴露哪些方法。
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 来设计,而不是把整个内部状态裸奔地交出去。
小结
挑几个能记住的点:
- 逻辑复用 → Hook,这是默认选项。HOC 几乎可以进博物馆了。
- UI 组合 + 内部状态共享 → Compound Components,做组件库时的杀手锏。
- 全局/区域状态 → Context + Provider,但记得 memo 化 value、按频率切片。
- 命令式控制子组件 →
useImperativeHandle,只暴露最小接口。 - 简单优先。容器/展示分离、Render Props 都别预先套用——模式是复杂度的解药,不是先发制人的枷锁。
React 设计模式的本质,不是把代码塞进某个形状,而是把变化的那部分和稳定的那部分分离开。状态、副作用、布局、样式,各自有各自的边界——边界清楚了,模式自己就长出来了。
过度抽象比没有抽象更糟。看到一个模式想用之前,先问自己:我这个问题,真的到了需要它的复杂度吗?
Related