React 组合模式

深入理解 React 中'组合优于继承'的实现:children prop、slot pattern、specialization pattern、render props 的实际使用场景,以及 HOC vs render props vs custom hooks 的取舍。

React 组合模式

为什么组合是 React 的核心设计哲学

React 的官方文档明确指出:"在 React 中,组合优于继承"。这不仅仅是一个设计建议,而是一套经过多年实践检验的组件复用模式体系。

理解组合模式,意味着理解 React 组件模型的核心:组件是 UI 的封装单元,而组合是跨越封装边界复用逻辑的方式。当我们需要让一个组件与另一个组件"合作"时,组合提供了比继承更灵活、更轻量的方式。

面试定位:组合模式是 React 面试中考察"组件设计能力"的核心话题。面试官通过候选人对 HOC、render props、custom hooks 三种模式的理解深度和取舍判断,来评估其构建可复用组件架构的能力。


一、"组合优于继承"在 React 中的表达

1.1 继承的困境

在传统的面向对象编程中,继承是一种常见的代码复用方式。但在 UI 组件设计中,继承会带来几个问题:

// 假设我们有一个 Button 基类
class Button extends React.Component {
  // 基本按钮逻辑
}

// 想要一个带图标的 Button
class IconButton extends Button {
  // 图标按钮逻辑
}

// 想要一个带 loading 状态的 IconButton
class LoadingIconButton extends IconButton {
  // Loading 逻辑
}

// 想要一个既带 loading 又有不同变体的...
// 继承链会爆炸性地增长

继承的问题

  1. 紧耦合:子类与父类的实现细节绑定
  2. 不灵活:难以动态组合行为
  3. 层次过深:继承链越长,维护越困难
  4. 隐式依赖:父类的实现会隐式地影响子类的行为

1.2 组合的优势

组合的核心思想是:将组件视为能力的组合,而不是继承链的节点

// 组合方式:功能是独立可拔插的
<Button
  leftIcon={<Icon />}
  rightIcon={<Spinner />}
  isLoading={isLoading}
  variant="primary"
>
  点击
</Button>

// 或者更灵活的 slot 模式
<Button>
  <ButtonIcon position="left"><Icon /></ButtonIcon>
  <ButtonContent>点击</ButtonContent>
  {isLoading && <ButtonSpinner />}
</Button>

组合的优势:

  1. 松耦合:功能是独立的,可以按需组合
  2. 灵活:可以动态决定组件的组成
  3. 扁平化:没有继承层次,所有功能在同一层级
  4. 显式依赖:所有依赖都是通过 props 传入,一目了然

二、Containment(包含)和 Slot Pattern

2.1 children prop:最基本的组合方式

React 组件的 children prop 允许组件在其标签之间包含任意内容:

function Card({ children }) {
  return <div className="card">{children}</div>;
}

function App() {
  return (
    <Card>
      <h2>标题</h2>
      <p>内容</p>
    </Card>
  );
}

这个简单的模式已经体现了组合的核心思想:Card 组件不知道也不关心它的 children 是什么

2.2 Slot Pattern:显式的内容位置

当 children 需要被放在特定位置时,slot pattern 提供了更明确的组合方式:

function Dialog({ header, children, footer }) {
  return (
    <div className="dialog">
      <div className="dialog-header">{header}</div>
      <div className="dialog-body">{children}</div>
      <div className="dialog-footer">{footer}</div>
    </div>
  );
}

function App() {
  return (
    <Dialog
      header={<h2>确认删除</h2>}
      footer={
        <>
          <button>取消</button>
          <button>删除</button>
        </>
      }
    >
      <p>确定要删除这个项目吗?此操作无法撤销。</p>
    </Dialog>
  );
}

Slot pattern 的优势:

  • 显式结构:组件的结构由调用者决定,而不是被限制
  • 类型安全:TypeScript 可以对每个 slot 的内容进行类型检查
  • 灵活性:调用者可以传递任何 React 可渲染的内容

2.3 实现可复用的 Slot 容器

function Dialog({ header, children, footer }) {
  return (
    <div className="dialog">
      {header && <div className="dialog-header">{header}</div>}
      <div className="dialog-body">{children}</div>
      {footer && <div className="dialog-footer">{footer}</div>}
    </div>
  );
}

// 复用:不同页面使用相同的 Dialog 结构,但内容不同
function DeleteConfirmDialog({ item, onConfirm, onCancel }) {
  return (
    <Dialog
      header={<DialogTitle>确认删除</DialogTitle>}
      footer={
        <DialogActions>
          <Button onClick={onCancel}>取消</Button>
          <Button variant="danger" onClick={onConfirm}>删除</Button>
        </DialogActions>
      }
    >
      <p>确定要删除 {item.name} 吗?</p>
    </Dialog>
  );
}

2.4 多 slot 场景:使用对象代替多个 props

当组件有多个 slot 时,使用对象可以保持 API 的整洁:

function Layout({ slots }) {
  const { header, sidebar, main, footer } = slots;
  return (
    <div className="layout">
      {header && <header>{header}</header>}
      <div className="layout-body">
        {sidebar && <aside>{sidebar}</aside>}
        <main>{main}</main>
      </div>
      {footer && <footer>{footer}</footer>}
    </div>
  );
}

// 使用
<Layout
  slots={{
    header: <AppHeader />,
    sidebar: <Sidebar />,
    main: <MainContent />,
    footer: <Footer />,
  }}
/>

三、特例化(Specialization Pattern)

3.1 什么是特例化

特例化是组合模式的一个重要应用场景:通过 props 控制组件的"种类",而不是通过继承创建新的组件类型

// 反模式:通过继承创建"特殊按钮"
class PrimaryButton extends Button { /* ... */ }
class SecondaryButton extends Button { /* ... */ }
class DangerButton extends Button { /* ... */ }

// 正确方式:通过 props 实现特例化
function Button({ variant = 'primary', children, ...props }) {
  return (
    <button className={`btn btn-${variant}`} {...props}>
      {children}
    </button>
  );
}

// 使用
<Button variant="primary">主要按钮</Button>
<Button variant="secondary">次要按钮</Button>
<Button variant="danger">危险操作</Button>

3.2 组合 + 特例化的实际例子

一个典型的移动端页面结构:

// 基础页面组件:定义整体结构
function Page({ header, children, footer }) {
  return (
    <div className="page">
      <PageHeader>{header}</PageHeader>
      <PageContent>{children}</PageContent>
      <PageFooter>{footer}</PageFooter>
    </div>
  );
}

// 特例化页面:预设常用的页面类型
function SettingsPage({ title, children }) {
  return (
    <Page
      header={<BackHeader title={title} />}
      footer={<BottomSafeArea />}
    >
      {children}
    </Page>
  );
}

// 使用
function AppSettings() {
  return (
    <SettingsPage title="应用设置">
      <SettingItem icon={<BellIcon />} label="通知设置" />
      <SettingItem icon={<LockIcon />} label="隐私设置" />
      <SettingItem icon={<ThemeIcon />} label="主题设置" />
    </SettingsPage>
  );
}

3.3 特例化的边界

特例化模式有它的适用边界:当"种类"数量有限且行为差异不大时,props 方式更优;当"种类"之间行为差异很大时,可能需要真正的组件

// 适用特例化的场景:按钮变体
<Button variant="primary" />
<Button variant="secondary" />
<Button variant="danger" />

// 不适用特例化的场景:完全不同行为的组件
// 表单 vs 只读展示 → 应该用两个不同组件
// Dialog vs Drawer → 应该用两个不同组件(行为本身就不同)

四、render props:灵活的行为复用

4.1 render props 的概念

render props 是一种将渲染逻辑作为 props 传递给组件的技术:

function MouseTracker({ render }) {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  useEffect(() => {
    const handleMouseMove = (e) => {
      setPosition({ x: e.clientX, y: e.clientY });
    };
    window.addEventListener('mousemove', handleMouseMove);
    return () => window.removeEventListener('mousemove', handleMouseMove);
  }, []);

  return render(position);
}

// 使用
<MouseTracker
  render={({ x, y }) => (
    <p>鼠标位置: {x}, {y}</p>
  )}
/>

4.2 render props 的实际使用场景

场景一:抽象状态逻辑

function DataFetcher({ url, render }) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [url]);

  return render({ data, loading, error });
}

// 使用
<DataFetcher
  url="/api/user"
  render={({ data, loading, error }) => {
    if (loading) return <Skeleton />;
    if (error) return <ErrorMessage error={error} />;
    return <UserProfile user={data} />;
  }}
/>

场景二:列表渲染逻辑

function VirtualList({ items, renderItem, estimatedItemSize = 50 }) {
  const [visibleRange, setVisibleRange] = useState({ start: 0, end: 20 });

  // 滚动计算省略...

  return (
    <div className="virtual-list">
      {items.slice(visibleRange.start, visibleRange.end).map(renderItem)}
    </div>
  );
}

// 使用
<VirtualList
  items={largeDataSet}
  renderItem={(item, index) => (
    <ListItem key={item.id} data={item} index={index} />
  )}
/>

4.3 render props 的变形:children as function

React 官方支持的一种模式是将 children 作为函数:

function MouseTracker({ children }) {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  // ...
  return children(position);
}

// 使用(更符合 React 的习惯)
<MouseTracker>
  {({ x, y }) => <p>鼠标位置: {x}, {y}</p>}
</MouseTracker>

4.4 render props 的优点和局限

优点

  • 状态逻辑与 UI 完全分离
  • 调用者完全控制渲染结果
  • 可以传递任意 props 给 render 函数

局限

  • 容易产生"嵌套地狱"(多层 render props 嵌套)
  • TypeScript 支持不够友好(需要复杂的泛型)
  • 对于简单逻辑显得过于复杂

五、HOC vs render props vs custom hooks:取舍

5.1 三种模式的对比

维度 HOC render props custom hooks
数据来源 通过 props 注入 通过 render prop 回调 在 hook 内部自己管理
组合方式 包装(wrapping) 渲染回调(render callback) 组合(composition)
命名冲突 可能有(需要 mergeProps)
TypeScript 支持 一般 复杂 良好
学习曲线 较陡 中等 平缓
适用场景 横切关注点 状态抽象 逻辑复用

5.2 HOC(高阶组件)

HOC 是一个函数,接收一个组件并返回一个新组件:

function withLoading(Component) {
  return function WithLoadingComponent({ isLoading, ...props }) {
    if (isLoading) return <Skeleton />;
    return <Component {...props} />;
  };
}

// 使用
const UserListWithLoading = withLoading(UserList);
<UserListWithLoading isLoading={isLoading} users={users} />

HOC 的问题:render hollow problem

HOC 会引入一个额外的组件层级,这可能导致:

// 问题:debug 时组件树多了一层
<withLogger(Connect(UsersPage))>
  <Connect(UsersPage)>
    <UsersPage>
      ...
    </UsersPage>
  </Connect(UsersPage)>
</withLogger(Connect(UsersPage))>

// 问题:React DevTools 中组件名称混乱

5.3 render props 的取舍

render props 的核心优势是调用者完全控制渲染,但代价是逻辑和数据都在 render 函数内部,调用者必须知道如何处理这些数据。

// render props:调用者必须处理所有状态
<DataFetcher
  url="/api/users"
  render={({ data, loading, error }) => {
    if (loading) return <Loading />;
    if (error) return <Error error={error} />;
    return <UserList users={data} />;
  }}
/>

// custom hooks:调用者只需要关注数据
const { data, loading, error } = useDataFetcher('/api/users');
// 调用者决定如何渲染

5.4 custom hooks 的现代选择

随着 React Hooks 的普及,custom hooks 已经成为逻辑复用的首选方式:

// useDataFetcher hook
function useDataFetcher(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [url]);

  return { data, loading, error };
}

// 使用:简洁、直观
function UsersPage() {
  const { data, loading, error } = useDataFetcher('/api/users');

  if (loading) return <Skeleton />;
  if (error) return <Error error={error} />;
  return <UserList users={data} />;
}

custom hooks 的优势

  • 符合 React Hooks 的心智模型
  • 没有额外的组件层级
  • TypeScript 支持良好
  • 可以在多个 hooks 之间组合

5.5 何时用哪种模式

场景 推荐模式
跨应用复用 UI 样式/布局 HOC(如 styled-components)
封装状态逻辑但不控制渲染 custom hooks
调用者需要完全控制渲染 render props
简单的逻辑复用 custom hooks
横切关注点(日志、性能监控) HOC

六、高阶组件的 render hollow problem

6.1 什么是 render hollow problem

render hollow problem 指的是 HOC 在组件树中引入了一层"空壳"组件,这层组件除了传递 props 之外没有任何实际 UI:

// 组件树看起来是这样的
<App>
  <Router>
    <withAuth(ProtectedPage)>
      <withTheme(withLogging(ProtectedPage))>
        <ProtectedPage />
      </withTheme(withLogging(ProtectedPage))>
    </withAuth(ProtectedPage)>
  </Router>
</App>

6.2 render hollow 带来的问题

问题一:React DevTools 中的可读性

多层 HOC 包装后,DevTools 中的组件名称会变得混乱,难以定位问题:

▼ UsersPage
    ▼ withAuth
      ▼ withTheme
        ▼ withLogging
          ▼ Connect
            ▼ UsersPage (actual)

问题二:Refs 无法穿透

const EnhancedComponent = withAuth(MyComponent);

// ref 指向的是 withAuth 返回的组件,而不是 MyComponent
const ref = useRef();
// ref.current 是 withAuth 的实例,不是 MyComponent 的实例

问题三:Context 无法直接穿透

如果 HOC 内部也使用了 Context,需要确保正确的 Context 层次。

6.3 解决方案

方案一:使用 forwardRef + cloneElement 组合

function withAuth(Component) {
  return forwardRef(function WithAuthComponent(props, ref) {
    const { user } = useAuth();
    if (!user) return <LoginPrompt />;
    return <Component ref={ref} {...props} />;
  });
}

方案二:优先使用 custom hooks

对于大多数场景,custom hooks 可以替代 HOC,同时避免 render hollow 问题:

// 替代 withAuth HOC
function useAuth() {
  const context = useContext(AuthContext);
  if (!context) throw new Error('useAuth must be used within AuthProvider');
  return context;
}

七、面试高频问题

Q: React 中组合优于继承的原因是什么?

回答要点:继承的的问题是子类与父类实现细节紧耦合、行为难以动态组合、层次过深维护困难。React 的组件模型本质上适合组合而非继承——组件通过 props 接收数据和回调函数,通过 children/Slot Pattern 接收 UI 结构,这种方式让组件的边界清晰、依赖显式、组合灵活。继承在 UI 组件库中几乎找不到合理的应用场景。

Q: HOC、render props、custom hooks 三种模式的区别和使用场景?

回答要点:HOC 适合横切关注点(日志、样式、性能监控)和跨应用复用 UI;render props 适合需要调用者完全控制渲染结果的场景;custom hooks 是目前最推荐的逻辑复用方式,因为没有额外组件层级、TypeScript 支持好、符合 Hooks 心智模型。对于简单的逻辑复用,无脑选择 custom hooks;对于需要包装组件本身的场景(如添加 loading 状态、错误边界),HOC 仍然有价值。

Q: 什么是 render hollow problem?如何解决?

回答要点:HOC 在组件树中引入额外的"空壳"层级,除了传递 props 外没有实际 UI,这会导致 React DevTools 可读性差、Refs 无法穿透、Context 层次混乱等问题。解决方案包括:使用 forwardRef 确保 ref 能正确穿透;优先使用 custom hooks 替代 HOC;对于必须使用 HOC 的场景,确保 HOC 使用 displayName 便于调试。


延展阅读