
一句话:React 的复用手段有五种——
children透传(最常用)、props 传元素(具名插槽)、复合组件(命名空间)、render props、HOC(历史方案);核心思想是「用组合代替继承」。
一、children 透传(最基础)
// 容器组件:只负责「结构与样式」,内容由外部注入
function Card({ children }: { children: React.ReactNode }) {
return <div className="card">{children}</div>;
}
// 使用
<Card>
<h2>标题</h2>
<p>内容</p>
</Card>
// children 的另一个重要作用:避免无关重渲染(见 3.4)
function Provider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState(initial);
// ⭐ children 是外部创建的元素,state 变化不会重建它
return <Ctx.Provider value={state}>{children}</Ctx.Provider>;
}
二、props 传元素(具名插槽)
// React 没有 Vue 的具名 slot,用 props 传元素实现
interface LayoutProps {
header: React.ReactNode;
sidebar?: React.ReactNode;
footer?: React.ReactNode;
children: React.ReactNode;
}
function Layout({ header, sidebar, footer, children }: LayoutProps) {
return (
<div className="layout">
<header>{header}</header>
<div className="body">
{sidebar && <aside>{sidebar}</aside>}
<main>{children}</main>
</div>
{footer && <footer>{footer}</footer>}
</div>
);
}
// 使用(类型清晰,每个插槽都能单独约束)
<Layout
header={<PageHeader title="用户列表" />}
sidebar={<FilterPanel />}
footer={<Pagination />}
>
<UserTable />
</Layout>
| 方案 | 写法 | 适合 |
|---|---|---|
children |
<Card>内容</Card> |
单一内容区 |
| 具名 props | header={<Header />} |
多个明确的插槽 |
| 位置 props | left={<A />} right={<B />} |
布局类组件(Split、Toolbar) |
三、复合组件(Compound Components)
// 把子组件挂到父组件上,形成「命名空间」,让 API 更有语义
import { createContext, useContext, useState, type ReactNode } from 'react';
const TabsContext = createContext<{
active: string;
setActive: (v: string) => void;
} | null>(null);
function Tabs({ defaultActive, children }: { defaultActive: string; children: ReactNode }) {
const [active, setActive] = useState(defaultActive);
return <TabsContext.Provider value={{ active, setActive }}>{children}</TabsContext.Provider>;
}
function TabList({ children }: { children: ReactNode }) {
return <div role="tablist">{children}</div>;
}
function Tab({ value, children }: { value: string; children: ReactNode }) {
const ctx = useContext(TabsContext)!;
const isActive = ctx.active === value;
return (
<button
role="tab"
aria-selected={isActive}
onClick={() => ctx.setActive(value)}
>
{children}
</button>
);
}
function TabPanel({ value, children }: { value: string; children: ReactNode }) {
const ctx = useContext(TabsContext)!;
if (ctx.active !== value) return null;
return <div role="tabpanel">{children}</div>;
}
// ⭐ 挂到父组件上形成命名空间
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
// 使用:语义清晰,顺序灵活,无需层层传 props
<Tabs defaultActive="profile">
<Tabs.List>
<Tabs.Tab value="profile">资料</Tabs.Tab>
<Tabs.Tab value="settings">设置</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="profile"><ProfileForm /></Tabs.Panel>
<Tabs.Panel value="settings"><SettingsForm /></Tabs.Panel>
</Tabs>
复合组件的三种实现方式:
| 方式 | 状态共享机制 | 优点 | 缺点 |
|---|---|---|---|
| Context(推荐) | 隐式(子组件从 Context 读) | 使用方无需传 props,顺序灵活 | 隐式依赖,调试稍难 |
React.Children.map + cloneElement |
显式注入 props | 实现简单 | 只能处理直接子元素、破坏封装 |
| 显式 props | 使用方手动传 | 最直观 | 使用方代码冗长 |
// cloneElement 的方式(了解即可,不推荐)
function TabsLegacy({ children, defaultActive }: TabProps) {
const [active, setActive] = useState(defaultActive);
return (
<div>
{React.Children.map(children, (child) =>
React.isValidElement(child)
? React.cloneElement(child as React.ReactElement<any>, { active, setActive })
: child
)}
</div>
);
}
// ⚠️ 问题:只能处理直接子元素(嵌套一层就失效);且会覆盖子组件自己的同名 props
四、render props 与「children 作为函数」
// ✅ render props:把「怎么渲染」交给使用方
interface MouseTrackerProps {
render: (pos: { x: number; y: number }) => React.ReactNode;
}
function MouseTracker({ render }: MouseTrackerProps) {
const [pos, setPos] = useState({ x: 0, y: 0 });
useEffect(() => {
const onMove = (e: MouseEvent) => setPos({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', onMove);
return () => window.removeEventListener('mousemove', onMove);
}, []);
return <>{render(pos)}</>;
}
// 使用
<MouseTracker render={({ x, y }) => <div>{x}, {y}</div>} />;
// ✅ 「children 作为函数」是 render props 的另一种写法(更贴近 JSX 直觉)
interface DataLoaderProps {
url: string;
children: (state: { data: unknown; loading: boolean }) => React.ReactNode;
}
function DataLoader({ url, children }: DataLoaderProps) {
const [data, setData] = useState<unknown>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
fetch(url)
.then((r) => r.json())
.then((d) => { if (!cancelled) { setData(d); setLoading(false); } });
return () => { cancelled = true; };
}, [url]);
return <>{children({ data, loading })}</>;
}
// 使用:children 是函数
<DataLoader url="/api/users">
{({ data, loading }) => (loading ? <Skeleton /> : <UserList data={data as User[]} />)}
</DataLoader>;
// ⚠️ 需要同时支持「函数 children」与「普通 children」时
function MaybeRender<T>({ children, ...rest }: { children: React.ReactNode | ((props: T) => React.ReactNode) } & T) {
return typeof children === 'function' ? <>{children(rest as T)}</> : <>{children}</>;
}
五、HOC(高阶组件,历史方案)
// HOC:接收组件,返回新组件
function withAuth<P extends object>(WrappedComponent: React.ComponentType<P>) {
return function WithAuth(props: P) {
const { user } = useAuth();
if (!user) return <Navigate to="/login" replace />;
return <WrappedComponent {...props} />;
};
}
// 使用
const ProtectedPage = withAuth(AdminPage);
// 带额外 props 的 HOC
function withLoading<P extends object>(WrappedComponent: React.ComponentType<P & { loading: boolean }>) {
return function WithLoading(props: Omit<P, 'loading'> & { loading: boolean }) {
const { loading, ...rest } = props;
if (loading) return <Spinner />;
return <WrappedComponent {...(rest as P)} loading={loading} />;
};
}
HOC 的四个问题(为什么现在不推荐):
| 问题 | 说明 |
|---|---|
| 嵌套地狱 | 多个 HOC 包裹会让组件树与 DevTools 变得难以阅读(withA(withB(withC(Comp)))) |
| 命名冲突 | 多个 HOC 注入同名 props 会互相覆盖 |
| 来源不明 | 组件里的 props.xxx 不知道是哪个 HOC 注入的 |
| 静态属性丢失 | 需要手动 hoistNonReactStatics 拷贝静态方法 |
| ref 传递 | 需要额外 forwardRef 处理 |
// ✅ 现代替代:自定义 Hook(同样复用逻辑,但更简单、无嵌套)
function AdminPage() {
const { user } = useAuth(); // ⭐ 直接在这里用,不需要包裹
if (!user) return <Navigate to="/login" replace />;
return <div>管理员内容</div>;
}
| 维度 | HOC | 自定义 Hook |
|---|---|---|
| 复用对象 | 组件(连同 UI) | 逻辑 |
| 嵌套 | ✅ 会产生嵌套 | ❌ 无 |
| 命名冲突 | ⚠️ 有 | ❌ 无(变量在各自作用域) |
| 来源清晰度 | 低 | 高(显式调用) |
| 类型推导 | 较麻烦 | 简单 |
| 现状 | 历史方案(装饰器/权限包装仍偶用) | 推荐 |
六、容器 / 展示组件(关注点分离)
// 展示组件:只关心「怎么显示」,从 props 拿数据(易复用、易测试)
function UserListView({ users, loading, onDelete }: UserListViewProps) {
if (loading) return <Skeleton />;
return (
<ul>
{users.map((u) => (
<li key={u.id}>
{u.name}
<button onClick={() => onDelete(u.id)}>删除</button>
</li>
))}
</ul>
);
}
// 容器组件:只关心「数据从哪来、怎么处理」
function UserListContainer() {
const { data: users, loading, refetch } = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
const deleteUser = useMutation({ mutationFn: api.deleteUser, onSuccess: () => refetch() });
return (
<UserListView
users={users ?? []}
loading={loading}
onDelete={(id) => deleteUser.mutate(id)}
/>
);
}
这个模式的现代形态是「自定义 Hook + 展示组件」:
useUserList()(数据逻辑) + <UserListView />(UI)
比「容器组件」更轻量(不需要多一层组件),也更易测试。
七、Headless 组件(现代趋势)
// Headless:库只提供「行为与状态」,不提供任何样式与结构
// 例:自己写一个 Headless Toggle
function useToggle({ defaultOpen = false } = {}) {
const [open, setOpen] = useState(defaultOpen);
const getToggleProps = (props = {}) => ({
'aria-expanded': open,
onClick: () => setOpen((v) => !v),
...props,
});
const getContentProps = (props = {}) => ({
hidden: !open,
...props,
});
return { open, setOpen, getToggleProps, getContentProps };
}
// 使用方决定 UI(可以完全自定义样式与结构)
function Accordion() {
const { getToggleProps, getContentProps } = useToggle();
return (
<div>
<button {...getToggleProps()}>展开</button>
<div {...getContentProps()}>内容</div>
</div>
);
}
这就是 Radix UI、Headless UI、TanStack Table 等库的设计思路:把「行为」与「样式」彻底分开——行为由库保证正确(含无障碍),样式完全由使用方决定。
八、方案对比与选择
| 需求 | 推荐方案 |
|---|---|
| 复用「结构与样式」,内容可变 | children |
| 多个明确的插槽 | props 传元素 |
| 一组组件需要共享隐式状态 | 复合组件(Context) |
| 把「怎么渲染」交给使用方 | render props / children 函数 |
| 复用「逻辑」(无 UI) | 自定义 Hook(首选) |
| 复用「逻辑 + 固定的 UI 包装」 | HOC(少见)/ 包装组件 |
| 提供「行为」但不提供样式 | Headless 组件 |
// 「包装组件」是现代 HOC 的替代(显式、无嵌套)
function RequireAuth({ children }: { children: React.ReactNode }) {
const { user } = useAuth();
if (!user) return <Navigate to="/login" replace />;
return <>{children}</>;
}
// 使用:语义清晰,不改变组件类型
<RequireAuth>
<AdminPage />
</RequireAuth>;
九、常见坑
// ① 用 HOC 造成嵌套地狱
withAuth(withTheme(withI18n(Page))); // ⚠️ 难以阅读与调试
// ② cloneElement 注入 props 覆盖子组件自己的 props
React.cloneElement(child, { onClick: handler }); // ⚠️ 覆盖了 child 原有的 onClick
// ③ 复合组件用 Context 但没做「Provider 缺失」的校验
const ctx = useContext(TabsContext)!; // ⚠️ 可能为 null → 崩溃
// ✅ 判空并抛错
// ④ children 是函数但当成普通 children 用
function Comp({ children }: { children: () => ReactNode }) {
return <div>{children}</div>; // ❌ 渲染函数本身会报错
// ✅ return <div>{children()}</div>;
}
// ⑤ 用「继承」而不是组合来复用
class AdminPage extends BasePage {} // ❌ React 不推荐继承
// ✅ 组合 + 自定义 Hook
// ⑥ 把「应该用 children 的场景」写成 props 传一堆配置对象
<Card title="a" subtitle="b" body="c" footer="d" onFooterClick={...} actions={[...]} />
// ✅ 需要复杂内容时用 children / 具名 props 传元素
// ⑦ render props 导致「回调地狱」(多层嵌套)
<A render={(a) => <B render={(b) => <C render={(c) => ...} />} />} />
// ⑧ 复合组件里用 cloneElement 处理「嵌套一层」的用法(失效)
// ⑨ 忘记给复合组件加无障碍属性(role/aria-*)
// ⑩ Headless 组件的 props getter 被覆盖(忘记展开顺序)
<button {...getToggleProps()} onClick={myHandler} /> // ⚠️ myHandler 会覆盖 getToggleProps 的 onClick
// ✅ 用参数传入:{...getToggleProps({ onClick: myHandler })}
面试延伸
- 「React 里有哪些组件复用方式?」
五种:①children透传(最基础,容器组件);② props 传元素(具名插槽);③ 复合组件(一组组件通过 Context 共享隐式状态,如Tabs+Tabs.Tab);④ render props / children 函数(把「怎么渲染」交给使用方);⑤ HOC(历史方案,现在用「自定义 Hook」或「包装组件」替代)。核心思想是用组合代替继承。 -
「为什么说『组合优于继承』?」
因为继承会带来三个问题:① 僵化——子类被迫继承父类的全部行为,即使不需要;② 脆弱基类——父类的改动会意外破坏子类;③ 单继承限制——JS 的 class 只能单继承,难以组合多个能力。而组合(children、props 传元素、自定义 Hook)是「需要什么就组合什么」,更灵活、更易测试、也没有「多个能力怎么同时要」的问题。 -
「什么是复合组件?」
指一组紧密相关、需要共享隐式状态的组件,通常用「命名空间」的形式组织(Tabs/Tabs.List/Tabs.Tab/Tabs.Panel)。实现上有两种:① Context(推荐)——子组件自己从 Context 读状态,使用方无需传 props、顺序灵活;②React.Children.map+cloneElement——父组件给子组件注入 props(实现简单,但只能处理直接子元素、且会覆盖子组件自己的同名 props)。 -
「HOC 有什么问题?现在用什么替代?」
HOC 的四个问题:① 嵌套地狱(withA(withB(withC(Comp)))难以阅读与调试);② 命名冲突(多个 HOC 注入同名 props 会覆盖);③ 来源不明(组件里的 props 不知道是谁注入的);④ 静态属性与 ref 需要额外处理。现代替代方案是自定义 Hook(复用逻辑、无嵌套、来源清晰、类型推导好),需要「逻辑 + UI 包装」时用包装组件(如<RequireAuth>{children}</RequireAuth>)。 -
「render props 和自定义 Hook 怎么选?」
判断标准是「是否需要把「渲染」交给使用方」:① 需要(比如「同一个数据源、不同渲染方式」的列表/图表)→ render props(或 children 函数);② 不需要(只是复用逻辑,UI 由调用方自己写)→ 自定义 Hook。现在的主流是「自定义 Hook 提供逻辑 + 使用方自己渲染」,因为 render props 会在多层组合时产生「回调地狱」式的嵌套。 -
「什么是 Headless 组件?」
指「只提供行为与状态、不提供任何样式与 DOM 结构」的组件(或 Hook 集合)。典型实现是「状态 Hook + props getter」:Hook 返回{ getToggleProps, getContentProps },使用方把它们展开到自己的元素上,从而获得正确的行为(含aria-*无障碍属性)与完全自由的样式。Radix UI、Headless UI、TanStack Table 都是这个思路。它的价值是「行为正确性与样式自由度兼得」。 -
「为什么推荐
children透传?」
除了「让容器组件更通用」之外,它还有一个性能好处:children是由外部创建的 React 元素,Provider/容器组件重渲染时,children是「同一个元素对象」,React 发现元素没变就会跳过它的子树渲染。这就是「把不依赖状态的子树作为 children 传进来」能减少重渲染的原理(详见 3.4)。 -
「复合组件怎么共享状态?」
两种方式:① Context(推荐)——父组件把状态放进 Context,子组件用useContext读取。优点是「使用方无需传 props、子组件可以任意嵌套/排序」;②cloneElement——父组件用React.Children.map遍历子元素并注入 props。优点是实现简单,但只能处理直接子元素(嵌套一层就失效),且会覆盖子组件自己的同名 props,所以现在不推荐。
一句话速记
React 的复用手段:
children透传(容器组件 + 顺带减少重渲染)、props 传元素(具名插槽)、复合组件(Tabs.Tab形式,用 Context 共享隐式状态,别用cloneElement)、render props / children 函数(把渲染交给使用方)、HOC(历史方案,问题多);现代首选是「自定义 Hook 提供逻辑 + 使用方自己渲染」,需要 UI 包装时用包装组件;高级形态是 Headless 组件(useXxx+getXxxProps,行为正确性与样式自由度兼得);核心原则是「组合优于继承」。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!