DevToolBox免费
博客

React 设计模式指南:复合组件、自定义 Hook、HOC、Render Props 与状态机

20 分钟阅读作者 DevToolBox Team
TL;DR: 掌握 13 种核心 React 设计模式,构建可扩展、可维护的应用。使用复合组件实现灵活的 API,自定义 Hook 复用逻辑,Provider 模式实现依赖注入,Error Boundary 构建弹性 UI。优先选择组合而非继承,将有状态逻辑提取到 Hook 中,利用 useReducer 处理复杂状态转换。选择受控组件进行表单验证,使用 Render Props 在组件间共享动态渲染逻辑。
核心要点:
  • 复合组件为组件库提供灵活且声明式的 API
  • 自定义 Hook 是现代 React 中最通用的逻辑复用模式
  • Provider 模式通过 Context + Hook 实现简洁的依赖注入
  • useReducer 自然地建模有限状态机,防止不可能的状态
  • 优先选择组合而非继承——使用 children、render props 和 slot 模式
  • Error Boundary 隔离故障,防止整个应用崩溃

1. 复合组件模式

复合组件模式允许父组件通过隐式状态与子组件通信。父组件管理内部状态,子组件通过 Context 或 React.Children 访问该状态。这种模式常见于 Select/Option、Tabs/Tab 和 Accordion 等组件库中,它使 API 保持声明式且灵活。

与通过 props 传递大量配置不同,复合组件让消费者通过组合子组件来声明意图。这种方式更接近原生 HTML(如 <select> + <option>),降低了使用门槛。

示例:Select 和 Option

function Select({ children, onChange }) {
  const [value, setValue] = React.useState(null);
  const handleSelect = (val) => {
    setValue(val);
    onChange?.(val);
  };
  return (
    <SelectContext.Provider value={{ value, onSelect: handleSelect }}>
      <div role="listbox">{children}</div>
    </SelectContext.Provider>
  );
}

function Option({ value, children }) {
  const ctx = React.useContext(SelectContext);
  const selected = ctx.value === value;
  return (
    <div role="option" onClick={() => ctx.onSelect(value)}
      style={{ fontWeight: selected ? "bold" : "normal" }}>
      {children}
    </div>
  );
}
提示:优先使用 Context 而非 React.Children.map,因为 Context 在深层嵌套和条件渲染时更可靠。

2. Render Props 模式

Render Props 是一种通过函数 prop 共享渲染逻辑的技术。组件接收一个返回 JSX 的函数作为 prop,将内部状态传递给该函数。这种模式适用于数据获取、鼠标追踪和交叉观察等需要将逻辑与渲染解耦的场景。

虽然自定义 Hook 在很多场景下已取代 Render Props,但当你需要根据共享逻辑动态决定渲染内容时,Render Props 仍然有其独特价值。

示例:鼠标位置追踪

function MouseTracker({ render }) {
  const [pos, setPos] = React.useState({ x: 0, y: 0 });
  React.useEffect(() => {
    const handler = (e) => setPos({ x: e.clientX, y: e.clientY });
    window.addEventListener("mousemove", handler);
    return () => window.removeEventListener("mousemove", handler);
  }, []);
  return render(pos);
}

// Usage:
<MouseTracker
  render={({ x, y }) => (
    <p>Cursor at: {x}, {y}</p>
  )}
/>
提示:命名 render prop 时可以使用 children 作为函数,这样调用更自然:<MouseTracker>{(pos) => ...}</MouseTracker>

3. 自定义 Hook 模式

自定义 Hook 是现代 React 中最推荐的逻辑复用模式。将有状态逻辑封装到以 use 开头的函数中,可以在多个组件间共享而不改变组件层级。常见的自定义 Hook 包括 useDebounce、useLocalStorage 和 useMediaQuery。

自定义 Hook 的优势在于:它们可以调用其他 Hook、返回任意值、并且不会产生额外的组件嵌套。每个使用自定义 Hook 的组件都有独立的状态副本。

示例:useDebounce 和 useLocalStorage

function useDebounce(value, delay = 300) {
  const [debounced, setDebounced] = React.useState(value);
  React.useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}

function useLocalStorage(key, initial) {
  const [val, setVal] = React.useState(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initial;
  });
  React.useEffect(() => {
    localStorage.setItem(key, JSON.stringify(val));
  }, [key, val]);
  return [val, setVal];
}

4. 高阶组件 (HOC)

高阶组件是一个接收组件并返回新组件的函数,为原组件添加额外功能。常见的 HOC 包括 withAuth(鉴权)、withLoading(加载状态)和 withTheme(主题注入)。多个 HOC 可以通过 compose 函数组合使用。

HOC 的缺点包括:可能导致 prop 名冲突、使组件树变深导致调试困难、以及静态方法不会自动传递。在现代 React 中,优先使用自定义 Hook,仅在需要包裹渲染逻辑时使用 HOC。

示例:withAuth 高阶组件

function withAuth(WrappedComponent) {
  return function AuthenticatedComponent(props) {
    const { user, loading } = useAuth();
    if (loading) return <Spinner />;
    if (!user) return <Redirect to="/login" />;
    return <WrappedComponent {...props} user={user} />;
  };
}

// Compose multiple HOCs:
const compose = (...fns) =>
  fns.reduce((f, g) => (...args) => f(g(...args)));

const EnhancedPage = compose(
  withAuth,
  withTheme,
  withLoading
)(DashboardPage);

5. 容器组件/展示组件

容器/展示组件模式将数据逻辑与 UI 渲染分离。容器组件负责获取数据、管理状态和处理副作用,展示组件只接收 props 并渲染 UI。虽然 Hook 减少了对此模式的依赖,但在大型代码库中它仍然有助于保持清晰的关注点分离。

展示组件通常是纯函数组件,容易测试和复用。容器组件可以替换为自定义 Hook,但这种分离思想仍然有价值——将"做什么"与"怎么呈现"分开。

示例:用户列表分离

// Container: handles data and state
function UserListContainer() {
  const [users, setUsers] = React.useState([]);
  React.useEffect(() => {
    fetch("/api/users").then(r => r.json()).then(setUsers);
  }, []);
  return <UserListView users={users} />;
}

// Presentational: pure UI rendering
function UserListView({ users }) {
  return (
    <ul>
      {users.map(u => (
        <li key={u.id}>{u.name} — {u.email}</li>
      ))}
    </ul>
  );
}

6. Provider 模式

Provider 模式将 React Context 与自定义 Hook 结合,实现简洁的依赖注入。Provider 组件包裹组件树的一部分,使值对所有后代组件可用。自定义 Hook(如 useTheme)消费该 Context,避免了 prop drilling 的问题。

这是 Redux、Zustand、React Query 等状态管理库的基础架构。在自定义 Hook 中添加错误检查(确保在 Provider 内使用)是一个最佳实践,能在开发时提供清晰的错误信息。

示例:主题 Provider

const ThemeContext = React.createContext(null);

function ThemeProvider({ children }) {
  const [theme, setTheme] = React.useState("light");
  const toggle = () => setTheme(t => t === "light" ? "dark" : "light");
  return (
    <ThemeContext.Provider value={{ theme, toggle }}>
      {children}
    </ThemeContext.Provider>
  );
}

function useTheme() {
  const ctx = React.useContext(ThemeContext);
  if (!ctx) throw new Error("useTheme must be inside ThemeProvider");
  return ctx;
}
提示:将 Context value 用 useMemo 包裹,避免 Provider 的父组件重新渲染时导致所有消费者不必要地重新渲染。

7. 状态机模式

状态机模式使用有限状态和明确的转换规则来管理复杂 UI 逻辑。useReducer 天然适合建模状态机,因为每个 action 代表一个有效的状态转换。这种模式可以防止不可能的状态,使多步表单、异步工作流和复杂交互更容易推理和测试。

对于更复杂的状态机需求,可以使用 XState 库,它提供可视化调试工具和更严格的类型安全。简单场景下,useReducer 配合 switch 语句已经足够。

示例:异步请求状态机

function fetchReducer(state, action) {
  switch (state.status) {
    case "idle":
      if (action.type === "FETCH") return { status: "loading" };
      break;
    case "loading":
      if (action.type === "SUCCESS")
        return { status: "success", data: action.data };
      if (action.type === "ERROR")
        return { status: "error", error: action.error };
      break;
    case "error":
      if (action.type === "RETRY") return { status: "loading" };
      break;
  }
  return state;
}

const [state, dispatch] = React.useReducer(
  fetchReducer, { status: "idle" }
);

8. 受控组件 vs 非受控组件

受控组件将表单值存储在 React 状态中,通过 onChange 更新;非受控组件让 DOM 管理值,通过 ref 读取。受控组件适合需要验证、条件渲染或派生状态的场景;非受控组件适合简单表单、文件输入或与非 React 库集成。

React.forwardRef 和 useImperativeHandle 可以让父组件通过 ref 访问非受控组件的内部方法,这在需要命令式操作(如聚焦输入框或重置表单)时非常有用。

示例:受控与非受控对比

// Controlled: React owns the value
function ControlledInput() {
  const [value, setValue] = React.useState("");
  return (
    <input
      value={value}
      onChange={(e) => setValue(e.target.value)}
    />
  );
}

// Uncontrolled: DOM owns the value
const UncontrolledInput = React.forwardRef((props, ref) => {
  const inputRef = React.useRef(null);
  React.useImperativeHandle(ref, () => ({
    getValue: () => inputRef.current.value,
  }));
  return <input ref={inputRef} defaultValue={props.initial} />;
});

9. 组合 vs 继承

React 官方推荐组合而非继承。通过 children prop、render props 和 slot 模式,可以在不创建紧耦合的情况下自定义组件行为。在 Facebook 数千个 React 组件中,没有发现继承优于组合的场景。

Slot 模式通过命名 props 传递 JSX 片段,提供了类似 Vue slot 的能力。这比继承更灵活,因为每个"槽位"可以是任意 JSX,而继承只能沿类层次结构扩展。

示例:Slot 模式

// Slots pattern: named regions via props
function Card({ header, body, footer }) {
  return (
    <div style={{ border: "1px solid #ccc", borderRadius: 8 }}>
      <div style={{ padding: 16, borderBottom: "1px solid #eee" }}>
        {header}
      </div>
      <div style={{ padding: 16 }}>{body}</div>
      {footer && (
        <div style={{ padding: 16, borderTop: "1px solid #eee" }}>
          {footer}
        </div>
      )}
    </div>
  );
}

// Usage: fully customizable without inheritance
<Card
  header={<h3>Title</h3>}
  body={<p>Content here</p>}
  footer={<button>Save</button>}
/>

10. 观察者模式

观察者模式(发布/订阅)在 React 中用于解耦组件间的通信。通过事件发射器,组件可以发布事件而不需要知道谁在监听,订阅者也不需要知道事件来自哪里。这在跨组件树通信和与外部系统集成时非常有用。

在 React 中使用观察者模式时,务必在 useEffect 的清理函数中取消订阅,避免内存泄漏。对于简单的跨组件通信,Context 或状态管理库通常是更好的选择。

示例:事件发射器

function createEventBus() {
  const listeners = new Map();
  return {
    on(event, cb) {
      if (!listeners.has(event)) listeners.set(event, new Set());
      listeners.get(event).add(cb);
      return () => listeners.get(event).delete(cb);
    },
    emit(event, data) {
      listeners.get(event)?.forEach(cb => cb(data));
    },
  };
}

// Usage in a hook:
function useEventBus(bus, event, handler) {
  React.useEffect(() => bus.on(event, handler),
    [bus, event, handler]);
}

11. 代理模式

代理模式在 React 中用于延迟加载和虚拟化。代理组件作为真实组件的替代,在满足条件时(如进入视口)才加载实际内容。React.lazy 和 Intersection Observer 是实现代理模式的常用工具。

虚拟滚动也是代理模式的一种应用:只渲染视口内的列表项,用占位元素代替不可见的部分。这对包含数千条目的长列表至关重要。

示例:视口延迟加载

function LazyVisible({ children, fallback = null }) {
  const ref = React.useRef(null);
  const [visible, setVisible] = React.useState(false);
  React.useEffect(() => {
    const obs = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        setVisible(true);
        obs.disconnect();
      }
    });
    if (ref.current) obs.observe(ref.current);
    return () => obs.disconnect();
  }, []);
  return (
    <div ref={ref}>
      {visible ? children : fallback}
    </div>
  );
}

12. 模块模式

模块模式在 React 中体现为桶导出、功能模块和代码分割。通过将相关组件、Hook 和工具函数组织在同一目录下,使用 index.ts 作为公共 API,可以保持代码整洁且易于维护。React.lazy 和动态 import 实现按需加载。

功能模块的好处是:团队成员可以独立开发各自负责的模块,模块间通过明确的公共 API 交互,内部实现细节被封装起来。这也使得代码分割的边界更加清晰。

示例:功能模块结构

// features/auth/index.ts — barrel export
export { AuthProvider } from "./AuthProvider";
export { useAuth } from "./useAuth";
export { LoginForm } from "./LoginForm";
export { SignupForm } from "./SignupForm";

// Lazy loading a feature module:
const AuthModule = React.lazy(() => import("./features/auth"));

function App() {
  return (
    <React.Suspense fallback={<Spinner />}>
      <AuthModule />
    </React.Suspense>
  );
}
提示:注意桶导出可能影响 tree-shaking。对于大型库,考虑使用直接导入路径而非桶导出。

13. Error Boundary 模式

Error Boundary 是捕获子组件树中 JavaScript 错误的类组件,显示降级 UI 而不是让整个应用崩溃。它们使用 componentDidCatch 和 getDerivedStateFromError 生命周期方法。在不同的 UI 区域使用多个 Error Boundary 可以隔离故障。

Error Boundary 只能捕获渲染期间、生命周期方法和构造函数中的错误。它们无法捕获事件处理器、异步代码或服务端渲染中的错误。对于这些场景,需要使用 try/catch 或全局错误处理。

示例:带重试功能的 Error Boundary

class ErrorBoundary extends React.Component {
  state = { hasError: false, error: null };

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  componentDidCatch(error, info) {
    console.error("Caught:", error, info.componentStack);
  }

  handleRetry = () => this.setState({ hasError: false, error: null });

  render() {
    if (this.state.hasError) {
      return (
        <div style={{ padding: 20, textAlign: "center" }}>
          <h3>Something went wrong</h3>
          <button onClick={this.handleRetry}>Try Again</button>
        </div>
      );
    }
    return this.props.children;
  }
}
提示:在生产环境中,将 componentDidCatch 中的错误发送到错误监控服务(如 Sentry),以便及时发现和修复问题。

模式演进:从 Mixins 到 Hooks

React 的设计模式随着框架的发展而不断演进。理解这个演进过程有助于你明白为什么某些模式被推荐,以及何时使用旧模式仍然合理。

  • Mixins (2013-2015): React 最早的逻辑复用方式,仅支持 createClass。因为命名冲突和隐式依赖问题被废弃。
  • HOC (2015-2018): 用函数包裹组件来注入行为。解决了 Mixins 的问题,但引入了 wrapper hell 和 prop 冲突。
  • Render Props (2017-2019): 通过函数 prop 共享逻辑。比 HOC 更显式,但仍然有组件嵌套问题。
  • Hooks (2019-present): 革命性的逻辑复用方案。无嵌套、无命名冲突、可组合性强。是目前推荐的首选方式。

尽管 Hook 是首选方案,但 HOC 在某些场景(如路由守卫、权限控制)中仍然有用,Render Props 在需要动态渲染决策时仍然有价值,Error Boundary 仍然必须使用类组件。

实战组合模式

在实际项目中,设计模式很少单独使用。以下是几种常见的模式组合方式:

Provider + Custom Hook + Error Boundary

这是最常见的组合。Provider 提供数据源,自定义 Hook 封装访问逻辑,Error Boundary 处理加载失败。例如一个数据获取层:AuthProvider 提供用户信息,useAuth Hook 让组件访问认证状态,Error Boundary 在 API 异常时显示降级 UI。

Compound Components + Provider + State Machine

构建复杂的表单向导时,外层使用复合组件定义步骤(Wizard + Step),内部通过 Provider 共享向导状态,用 useReducer 状态机管理步骤转换和验证逻辑。

Module Pattern + Proxy + Code Splitting

大型应用通过模块模式组织功能边界,使用代理模式(React.lazy + Suspense)按需加载模块,结合路由级代码分割实现最优的首屏加载性能。

如何测试设计模式

良好的设计模式应该使测试更容易而非更困难。以下是每种模式的测试建议:

  • 自定义 Hook:使用 renderHook 从 @testing-library/react 独立测试 Hook 逻辑。
  • 复合组件:将父子组件一起渲染测试,验证交互行为而非实现细节。
  • Provider 模式:创建测试用的 Provider wrapper,注入模拟数据,测试消费组件的行为。
  • 状态机:Reducer 是纯函数,可以脱离 React 直接单元测试每个状态转换。
  • Error Boundary:故意渲染抛出错误的子组件,验证降级 UI 是否正确显示。
  • 展示组件:使用快照测试或视觉回归测试,因为展示组件是纯函数。

模式选择指南

选择正确的设计模式取决于你的具体需求。以下是按用途分类的快速参考:

  • 逻辑复用 → 自定义 Hook(首选)或 Render Props
  • 组件库 API → 复合组件
  • 全局状态/依赖注入 → Provider 模式
  • 复杂状态逻辑 → 状态机 (useReducer / XState)
  • 跨功能增强 → 高阶组件(少用,优先 Hook)
  • 错误恢复 → Error Boundary
  • 性能优化 → 代理模式(懒加载/虚拟化)
  • 代码组织 → 模块模式(桶导出 + 代码分割)
  • 表单处理 → 受控(验证)/ 非受控(简单场景)
  • 松耦合通信 → 观察者模式(事件总线)

常见反模式

了解反模式与了解设计模式同样重要。避免以下常见陷阱可以显著提升代码质量:

  • Prop Drilling:传递 props 超过 3 层时,考虑使用 Context 或状态管理库。过深的 prop 传递使组件耦合,重构困难。
  • 过度抽象:不要在只有一个使用场景时就创建抽象,遵循"三次法则"——只有当模式重复三次时才提取。
  • 巨型组件:超过 200 行的组件应该被拆分为更小的子组件或自定义 Hook。大组件难以测试、难以维护。
  • 在 render 中定义组件:在 render 函数内部定义组件会导致每次渲染都创建新的组件实例,破坏 reconciliation 和状态保持。
  • 滥用 useEffect:不要用 useEffect 来同步派生状态。如果一个值可以从 props 或 state 计算得出,直接在渲染中计算即可。

性能注意事项

设计模式不仅影响代码的可维护性,还直接影响运行时性能。以下是使用各模式时需要注意的性能问题:

  • Context: 每次 Context value 变化时,所有消费者都会重新渲染。通过拆分 Context(读写分离)或使用 useMemo 包裹 value 来优化。
  • 复合组件:使用 React.memo 包裹子组件,避免父组件状态变化导致所有子组件不必要地重新渲染。
  • HOC: 确保 HOC 返回的组件被正确 memo 化。避免在每次渲染时创建新的 HOC 实例。
  • 事件总线:频繁触发的事件(如鼠标移动)应该使用节流或防抖来限制回调执行频率。
  • 代码分割:React.lazy 产生的网络请求会影响感知性能。使用预加载策略(如 hover 时预加载)减少用户等待时间。
提示:使用 React DevTools Profiler 来识别不必要的重新渲染。先保证正确性,再考虑优化——不要过早优化。

何时不使用设计模式

设计模式是解决特定问题的工具,而非炫技的手段。在以下场景中,简单直接的代码比精心设计的模式更好:

  • 原型或 MVP 阶段——快速验证想法比代码架构更重要
  • 只有一个组件使用的逻辑——不需要提取自定义 Hook
  • Props 只传两层——不需要引入 Context
  • 状态只有两个分支——不需要状态机,简单的 useState + if/else 就够了
  • 团队不熟悉某个模式——可读性和团队效率比"最佳实践"更重要

总结

React 设计模式不是互斥的——它们经常组合使用。一个典型的 React 应用可能同时使用 Provider 模式管理主题和认证、自定义 Hook 共享业务逻辑、复合组件构建可复用的 UI 组件库、Error Boundary 处理异常情况。

关键是理解每种模式解决的问题,选择最适合你场景的方案,并避免过度工程化。从简单开始,随着需求增长逐步引入更高级的模式。记住:最好的模式是你的团队能理解和维护的模式。

随着 React Server Components 和新的编译器优化(React Compiler)的引入,某些模式可能会继续演进。但核心原则不会改变:关注点分离、单一职责、组合优于继承、以及保持 API 的简洁性。掌握这些模式的本质思想,你就能在任何新框架或新范式中快速适应。

推荐学习资源

  • React Official Docs: react.dev 的"Thinking in React"和"Reusing Logic with Custom Hooks"章节是理解模式思想的最佳起点。
  • Patterns.dev: 由 Lydia Hallie 和 Addy Osmani 维护的免费资源,提供交互式的设计模式和渲染模式讲解。
  • XState: 如果你对状态机感兴趣,XState 提供了可视化编辑器和完整的有限状态机实现。
  • Testing Library: @testing-library/react 的文档展示了如何以用户为中心的方式测试各种模式。

设计模式是经验的结晶。多读开源代码(如 Radix UI、Headless UI、React Query 的源码),观察这些成熟的库如何运用这些模式,是提升架构能力的最有效方法之一。

𝕏 Twitterin LinkedIn
这篇文章有帮助吗?

保持更新

获取每周开发技巧和新工具通知。

无垃圾邮件,随时退订。

试试这些相关工具

{ }JSON FormatterJSTypeScript to JavaScript.*Regex Tester

相关文章

高级TypeScript指南:泛型、条件类型、映射类型、装饰器和类型收窄

掌握高级TypeScript模式。涵盖泛型约束、带infer的条件类型、映射类型(Partial/Pick/Omit)、模板字面量类型、判别联合、工具类型深入、装饰器、模块增强、类型收窄、协变/逆变以及satisfies运算符。

代码整洁之道:命名规范、SOLID 原则、代码异味、重构与最佳实践

全面的代码整洁指南,涵盖命名规范、函数设计、SOLID 原则、DRY/KISS/YAGNI、代码异味与重构、错误处理模式、测试、代码审查、契约式设计和整洁架构。

函数式编程指南:纯函数、不可变性、Monad、组合与 JavaScript/TypeScript 中的 FP

完整的函数式编程指南,涵盖纯函数、不可变性、高阶函数、Monad、Functor、函数组合、模式匹配,以及 JavaScript 和 TypeScript 中的实用 FP。