React 组件设计模式

HOC、Render Props、Compound Components、Controlled/Uncontrolled 等核心模式,以及组合优于继承的设计原则,是构建可复用、可维护 React 组件的工程基础。

React 组件设计模式

概述

React 的组件模型看似简单——一个接收 props、返回 JSX 的函数。但当项目规模扩大、组件需要被多处复用、在不同上下文中表现不同行为时,"如何设计组件接口"就成为了决定代码质量的关键问题。

组件设计模式本质上是在回答一个问题:如何在保持组件封装性的同时,给消费者足够的灵活性?

这个问题没有标准答案。不同的场景需要不同的模式——有时候需要高度受控的 API,有时候需要完全开放的组合能力。本话题将系统讲解 React 中经过工程验证的核心设计模式,以及它们各自的适用场景和 trade-off。


Controlled Components 与 Uncontrolled Components

问题背景

表单是 Web 应用中最常见的交互形式。在 React 中,表单元素的值有两种管理方式:

受控组件(Controlled Component):表单值由 React 状态管理,每次变化都通过 onChange 事件更新状态。

非受控组件(Uncontrolled Component):表单值由 DOM 自身管理,通过 ref 在需要时获取值。

理解这两种模式,是理解 React 数据流的基础。

受控组件

// 受控组件:值完全由 React 控制
function ControlledInput() {
  const [value, setValue] = useState('');

  return (
    <input
      value={value}
      onChange={(e) => setValue(e.target.value)}
      placeholder="Enter text..."
    />
  );
}

受控组件的核心特征:

  1. value prop 由状态决定value={value},不是 "xxx" 这样的固定值
  2. onChange 更新状态:用户输入时,事件处理器调用 setValue 更新状态
  3. 状态即真相:任何时刻,React 状态中的值就是屏幕上显示的值

受控组件的优势在于完全可控。你可以:

  • onChange 中对输入进行验证或转换
  • 根据其他字段动态改变输入行为
  • 轻松实现"校验不通过时禁用提交"这类逻辑
// 受控组件的高级用法:输入验证
function ValidatedInput() {
  const [value, setValue] = useState('');
  const [error, setError] = useState('');

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const newValue = e.target.value;
    // 输入验证:只允许字母
    if (!/^[a-zA-Z]*$/.test(newValue)) {
      setError('Only letters allowed');
    } else {
      setError('');
    }
    setValue(newValue);
  };

  return (
    <div>
      <input value={value} onChange={handleChange} />
      {error && <span className="error">{error}</span>}
    </div>
  );
}

非受控组件

// 非受控组件:值由 DOM 管理
function UncontrolledInput() {
  const inputRef = useRef<HTMLInputElement>(null);

  const handleSubmit = () => {
    // 在需要时通过 ref 获取值
    console.log('Input value:', inputRef.current?.value);
  };

  return (
    <div>
      <input ref={inputRef} defaultValue="Hello" />
      <button onClick={handleSubmit}>Submit</button>
    </div>
  );
}

非受控组件使用 defaultValue(对应 HTML 的 value)来设置初始值,之后就不再受 React 控制。

什么时候用非受控组件?

  • 表单不需要实时校验
  • 你只想在表单提交时获取值,不想追踪每次输入
  • 与不支持受控模式的第三方库集成

选择原则

Dan Abramov 的 Thinking in React 一文指出了一个实用原则:

找到"拥有数据的组件",让它负责渲染和使用数据。如果多个组件需要访问同一份数据,把那份数据提升到它们最近的公共祖先。

对于表单场景:

场景 推荐模式
需要实时验证 受控
需要根据其他字段动态变化 受控
表单复杂,跨字段有依赖 受控 + 表单库
简单场景,只需提交时取值 非受控
集成第三方输入控件 非受控(通过 ref)

Higher-Order Components(HOC)

模式定义

HOC 是一个接收组件、返回新组件的函数。它是 React 中最早的逻辑复用模式之一,在 Hooks 出现之前是复用状态逻辑的标准方案。

// HOC 的类型签名
function withAuth<P extends object>(Component: React.ComponentType<P>) {
  return function AuthenticatedComponent(props: P) {
    const { user } = useAuth();
    if (!user) {
      return <Navigate to="/login" />;
    }
    return <Component {...props} user={user} />;
  };
}

// 使用
const ProtectedPage = withAuth(DashboardPage);

HOC 的名称约定:with 前缀 + 功能名(如 withAuthwithRouterwithTheme)。

经典应用场景

1. 权限控制

function withPermission<P>(
  WrappedComponent: React.ComponentType<P>,
  requiredRole: string
) {
  return function PermissionGuard(props: P) {
    const { user } = useAuth();
    const hasPermission = user?.role === requiredRole;

    if (!hasPermission) {
      return <AccessDenied />;
    }

    return <WrappedComponent {...props} />;
  };
}

// 使用
const AdminPanel = withPermission(AdminPanelContent, 'admin');

2. 数据获取

function withData<P>(
  WrappedComponent: React.ComponentType<P>,
  fetchFn: (props: P) => Promise<any>
) {
  return function DataWrapper(props: P) {
    const [data, setData] = useState(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState<Error | null>(null);

    useEffect(() => {
      fetchFn(props)
        .then(setData)
        .catch(setError)
        .finally(() => setLoading(false));
    }, [props.id]);

    if (loading) return <Skeleton />;
    if (error) return <ErrorMessage error={error} />;

    return <WrappedComponent {...props} data={data} />;
  };
}

HOC 的问题

HOC 虽然强大,但有几个显著问题:

1. 嵌套地狱

// 多个 HOC 嵌套,调试困难
const EnhancedComponent = withAuth(withTheme(withData(Component)));

2. Props 命名冲突

// 如果 HOC A 注入了 `user` prop,HOC B 也注入了 `user` prop
// 就会发生冲突,后面的覆盖前面的

3. 静态组合 vs 动态组合

HOC 在组件定义时组合,而不是在渲染时。这导致:

  • 很难在 HOC 内部访问组件的内部状态
  • 很难在 HOC 条件性地决定是否包装组件

4. 类型推导困难

// HOC 的类型推导需要大量 boilerplate
function withAuth<P extends { user?: User }>(
  Component: React.ComponentType<P>
): React.ComponentType<Omit<P, 'user'>> {
  // ...
}

React 官方文档现在推荐用 Hooks 替代 HOC 进行逻辑复用。但 HOC 在渲染劫持条件渲染场景仍有价值,理解它有助于理解 React 生态的历史演进。


Render Props

模式定义

Render Props 是一种通过函数 prop 传递 JSX 渲染逻辑的模式:

// MouseTracker 使用 render prop 传递鼠标位置
<MouseTracker
  render={(position) => (
    <div>Mouse is at {position.x}, {position.y}</div>
  )}
/>

// 或者用 children prop(更常见)
<MouseTracker>
  {(position) => (
    <div>Mouse is at {position.x}, {position.y}</div>
  )}
</MouseTracker>

Render Props 的核心思想:把"怎么渲染"的决定权交给消费者,而不是组件作者。

经典实现

// MouseTracker 组件
function MouseTracker({ children }: { children: (pos: Point) => ReactNode }) {
  const [position, setPosition] = useState({ x: 0, y: 0 });

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

  return children(position);
}

Render Props vs HOC

维度 Render Props HOC
组合方式 扁平(一次调用) 嵌套(多层包装)
Props 冲突 无(显式传递) 有(隐式注入)
类型推导 自然 需要额外 boilerplate
静态分析 困难 困难
执行顺序 渲染时 定义时

Render Props 的遗留价值

Hooks 出现后,Render Props 的大部分场景被 Hooks 替代。但它仍有不可替代的场景:

1. 条件渲染(Feature Flag)

<Feature flag="new-ui">
  {(enabled) => enabled ? <NewUI /> : <LegacyUI />}
</Feature>

2. 跨组件树的状态传递

Render Props 本质上是一种状态分配机制。如果你的状态共享需求比较简单,Context + Hooks 通常是更好的方案。但当需要精确控制渲染时机和位置时,Render Props 仍有价值。


Compound Components(复合组件)

模式定义

Compound Components 是一种声明式组合模式。组件的各个"部分"作为子组件暴露,消费者通过组合这些子组件来控制结构和顺序,而状态在内部通过 Context 共享。

这种模式的灵感来自 Web Components 的 slot 概念和 React 的组合模型。

典型实现

// Tabs 组件的消费者视角
<Tabs defaultValue="tab1">
  <Tabs.List>
    <Tabs.Trigger value="tab1">Profile</Tabs.Trigger>
    <Tabs.Trigger value="tab2">Settings</Tabs.Trigger>
  </Tabs.List>
  <Tabs.Content value="tab1">
    <ProfileTab />
  </Tabs.Content>
  <Tabs.Content value="tab2">
    <SettingsTab />
  </Tabs.Content>
</Tabs>

内部实现

import { createContext, useContext, useState, ReactNode } from 'react';

// Context 存储共享状态
interface TabsContextValue {
  value: string;
  onValueChange: (value: string) => void;
}

const TabsContext = createContext<TabsContextValue | null>(null);

// 顶层组件:管理状态
function Tabs({
  defaultValue,
  children,
}: {
  defaultValue: string;
  children: ReactNode;
}) {
  const [value, setValue] = useState(defaultValue);

  return (
    <TabsContext.Provider value={{ value, onValueChange: setValue }}>
      {children}
    </TabsContext.Provider>
  );
}

// List 组件
Tabs.List = function TabsList({ children }: { children: ReactNode }) {
  return <div role="tablist" className="flex gap-1">{children}</div>;
};

// Trigger 组件
Tabs.Trigger = function TabsTrigger({
  value,
  children,
}: {
  value: string;
  children: ReactNode;
}) {
  const ctx = useContext(TabsContext);
  if (!ctx) throw new Error('Tabs.Trigger must be used within Tabs');

  const isActive = ctx.value === value;

  return (
    <button
      role="tab"
      aria-selected={isActive}
      onClick={() => ctx.onValueChange(value)}
    >
      {children}
    </button>
  );
};

// Content 组件
Tabs.Content = function TabsContent({
  value,
  children,
}: {
  value: string;
  children: ReactNode;
}) {
  const ctx = useContext(TabsContext);
  if (!ctx) throw new Error('Tabs.Content must be used within Tabs');

  if (ctx.value !== value) return null;

  return <div role="tabpanel">{children}</div>;
};

为什么 Compound Components 有效

1. 控制反转(Inversion of Control)

消费者决定渲染什么、以什么顺序渲染。组件只负责状态协调,不预设渲染结构。

2. 声明式 API

renderHeaderrenderFooter 等配置 prop 更直观。你看到 JSX,就知道渲染结果。

3. 可组合性

可以在子组件之间插入自定义内容:

<Tabs defaultValue="overview">
  <Tabs.List>
    <Tabs.Trigger value="overview">Overview</Tabs.Trigger>
    <Tabs.Trigger value="analytics">Analytics</Tabs.Trigger>
    {/* 可以在任意位置插入其他组件 */}
    <div className="ml-auto">
      <ExportButton />
    </div>
  </Tabs.List>
  <Tabs.Content value="overview">...</Tabs.Content>
  <Tabs.Content value="analytics">...</Tabs.Content>
</Tabs>

4. 样式灵活性

子组件接受 className prop,允许消费者覆盖样式:

<Tabs.Trigger value="tab1" className="custom-trigger">
  Custom Styled Trigger
</Tabs.Trigger>

代表实现

Radix UI、Reach UI、Ariakit 都采用 Compound Components 作为核心架构。shadcn/ui 同样基于这个模式。


设计原则:组合优于继承

为什么"组合"是 React 的核心思想

React 的官方文档在 Composition vs Inheritance 中明确建议:组合优于继承

这有几个原因:

  1. 显式优于隐式:通过 props 传递的数据流是显式的,容易追踪和调试
  2. 灵活组合:任何组件只要 props 接口兼容,就可以被组合
  3. 打破层级限制:继承有严格的层级关系,组合没有

组合的三种主要形式

1. Children 组合

// 通过 children 传递 UI
function Card({ children }: { children: ReactNode }) {
  return <div className="card">{children}</div>;
}

<Card>
  <Card.Header>Title</Card.Header>
  <Card.Body>Content</Card.Body>
</Card>

2. Slot 组合(Render Props)

// 通过函数 prop 传递渲染逻辑
<Modal
  header={(onClose) => <button onClick={onClose}>X</button>}
>
  Content here
</Modal>

3. Headless 组合(逻辑分离)

// 只提供逻辑,不提供 UI
function useCombobox({ items, onSelect }) {
  // 状态管理和交互逻辑
  return { getInputProps, getListProps, getItemProps };
}

// 消费者完全控制渲染
function MyCombobox() {
  const { getInputProps, getListProps } = useCombobox({ items, onSelect });
  return (
    <div>
      <input {...getInputProps()} />
      <ul {...getListProps()} />
    </div>
  );
}

继承的合理使用场景

组合不是银弹。在某些场景下,继承仍然是合理的选择:

// 一个具体组件继承另一个具体组件
class Button extends React.Component {
  // ...
}

class IconButton extends Button {
  // IconButton 是 Button 的特化,仍然使用继承
}

判断标准:如果你发现自己在写 class A extends B,先问自己:

  • A 是否是 B 的一种(is-a 关系)?
  • 还是 A 需要使用 B 的能力(has-a / uses-a 关系)?

如果是后者,组合更合适。


Headless UI:无头组件

概念定义

Headless UI 是一种将逻辑层和渲染层完全分离的模式。组件提供:

  • 状态管理
  • 键盘交互
  • ARIA 属性
  • 焦点管理

但不提供任何 UI 样式。

Headless vs Compound Components

维度 Compound Components Headless UI
UI 控制 部分控制(子组件结构) 完全控制(零 UI)
学习曲线 低(JSX 组合直觉) 中(需要理解 prop getters)
样式绑定 通常有默认样式 零样式
适用场景 Design System 组件 跨 Design System 基础设施
可访问性 内置 内置

实际应用:React Aria

Adobe 的 React Aria 是 Headless UI 的标杆实现:

function useComboBox({
  items,
  onSelectionChange,
}: {
  items: string[];
  onSelectionChange: (item: string) => void;
}) {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);

  return {
    getInputProps: () => ({
      role: 'combobox',
      'aria-expanded': isOpen,
      onChange: (e) => {
        setIsOpen(true);
        // filter logic
      },
    }),
    getListProps: () => ({
      role: 'listbox',
    }),
    getItemProps: (index: number) => ({
      role: 'option',
      'aria-selected': index === activeIndex,
      onClick: () => {
        onSelectionChange(items[index]);
        setIsOpen(false);
      },
    }),
  };
}

代表库

  • React Aria(Adobe)— 专注于可访问性
  • Downshift — Combobox 的完整实现
  • TanStack Table — 表格逻辑
  • Headless UI(Tailwind Labs)— 组件级实现

实际工程决策

模式选择指南

场景 推荐模式
简单 UI 复用 Children 组合
需要在多处使用相同交互逻辑 自定义 Hook
需要声明式控制组件内部结构 Compound Components
需要完全自定义 UI,只复用逻辑 Headless UI
一次性增强组件 HOC(遗留场景)
条件渲染 Render Props(遗留场景)

常见反模式

1. Boolean Explosion(布尔值爆炸)

// ❌ 反模式
<Modal
  isOpen
  isFullscreen
  isClosable
  hasOverlay
  showHeader
  showFooter
/>

// ✅ 正确做法:variant 或 Compound Components
<Modal variant="fullscreen">
  <Modal.Header />
  <Modal.Body />
  <Modal.Footer />
</Modal>

2. God Component(上帝组件)

单个组件超过 500 行,混合了数据获取、状态管理、UI 渲染。拆分为 Container + Presentational,或用 Hooks 提取逻辑。

3. Prop Drilling(属性穿透)

Props 穿越 5+ 层组件。使用 Context、状态管理库,或重新设计组件边界。


延展阅读