陌上人如玉
公子世无双

3.5 组合模式

3.5 组合模式

一句话: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 })}

面试延伸

  1. 「React 里有哪些组件复用方式?」
    五种:① children 透传(最基础,容器组件);② props 传元素(具名插槽);③ 复合组件(一组组件通过 Context 共享隐式状态,如 Tabs + Tabs.Tab);④ render props / children 函数(把「怎么渲染」交给使用方);⑤ HOC(历史方案,现在用「自定义 Hook」或「包装组件」替代)。核心思想是用组合代替继承。

  2. 「为什么说『组合优于继承』?」
    因为继承会带来三个问题:① 僵化——子类被迫继承父类的全部行为,即使不需要;② 脆弱基类——父类的改动会意外破坏子类;③ 单继承限制——JS 的 class 只能单继承,难以组合多个能力。而组合(children、props 传元素、自定义 Hook)是「需要什么就组合什么」,更灵活、更易测试、也没有「多个能力怎么同时要」的问题。

  3. 「什么是复合组件?」
    指一组紧密相关、需要共享隐式状态的组件,通常用「命名空间」的形式组织(Tabs / Tabs.List / Tabs.Tab / Tabs.Panel)。实现上有两种:① Context(推荐)——子组件自己从 Context 读状态,使用方无需传 props、顺序灵活;② React.Children.map + cloneElement——父组件给子组件注入 props(实现简单,但只能处理直接子元素、且会覆盖子组件自己的同名 props)。

  4. 「HOC 有什么问题?现在用什么替代?」
    HOC 的四个问题:① 嵌套地狱(withA(withB(withC(Comp))) 难以阅读与调试);② 命名冲突(多个 HOC 注入同名 props 会覆盖);③ 来源不明(组件里的 props 不知道是谁注入的);④ 静态属性与 ref 需要额外处理。现代替代方案是自定义 Hook(复用逻辑、无嵌套、来源清晰、类型推导好),需要「逻辑 + UI 包装」时用包装组件(如 <RequireAuth>{children}</RequireAuth>)。

  5. 「render props 和自定义 Hook 怎么选?」
    判断标准是「是否需要把「渲染」交给使用方」:① 需要(比如「同一个数据源、不同渲染方式」的列表/图表)→ render props(或 children 函数);② 不需要(只是复用逻辑,UI 由调用方自己写)→ 自定义 Hook。现在的主流是「自定义 Hook 提供逻辑 + 使用方自己渲染」,因为 render props 会在多层组合时产生「回调地狱」式的嵌套。

  6. 「什么是 Headless 组件?」
    指「只提供行为与状态、不提供任何样式与 DOM 结构」的组件(或 Hook 集合)。典型实现是「状态 Hook + props getter」:Hook 返回 { getToggleProps, getContentProps },使用方把它们展开到自己的元素上,从而获得正确的行为(含 aria-* 无障碍属性)与完全自由的样式。Radix UI、Headless UI、TanStack Table 都是这个思路。它的价值是「行为正确性与样式自由度兼得」。

  7. 「为什么推荐 children 透传?」
    除了「让容器组件更通用」之外,它还有一个性能好处:children 是由外部创建的 React 元素,Provider/容器组件重渲染时,children 是「同一个元素对象」,React 发现元素没变就会跳过它的子树渲染。这就是「把不依赖状态的子树作为 children 传进来」能减少重渲染的原理(详见 3.4)。

  8. 「复合组件怎么共享状态?」
    两种方式:① 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,行为正确性与样式自由度兼得);核心原则是「组合优于继承」。

赞(0) 打赏
未经允许不得转载:陌上寒 » 3.5 组合模式

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫