在 React 中构建可复用 UI:Compound Components、Render Props 与 API 设计

发布: (2025年12月27日 GMT+8 06:08)
12 min read
原文: Dev.to

Source: Dev.to

你是否曾经构建过一个最初干净的 React 组件——却在几天后变成拥有无数 props、相互冲突的布尔值,以及没人信任的 README?

每新增一个布尔值,可能的 UI 状态数量就会翻倍。当你拥有六个布尔值时,就需要支持 64 种 UI 变体——其中大多数都没有文档记录。

可复用的 UI 并不是写更少的代码,而是设计能够 经受变化 的 API。

大多数 React 组件之所以出问题,并不是因为 bug;而是它们的 API 在真实业务需求面前崩溃。

本文面向构建共享组件、设计系统或复杂 UI 基元的开发者。

我们将通过以下内容逐步讲解 如何以及为何 使用可复用 UI:

  • 复合组件 (Compound Components)
  • 渲染属性 (Render Props)
  • 真实的手风琴(Accordion)实现
  • 权衡、替代方案以及改进方法

核心问题:基于 Props 的 API 难以扩展

大多数组件都是这样开始的:

const [activeItems, setActiveItems] = useState([]);

const toggleItem = (id: string) => { /* … */ };

起初感觉还好,随后需求不断增长:

  • 不同的布局
  • 自定义标题
  • 条件化行为
  • 设计系统约束

突然之间:Props 爆炸

Accordion
 ├─ activeItems
 ├─ allowMultiple
 ├─ showIcon
 ├─ animated
 ├─ collapsible
 ├─ headerLayout
 └─ onToggle

   相互依赖的逻辑
   分散在各个使用方

单个 Prop 看似无害,但它们一起会产生隐藏的耦合和未文档化的行为。

  • 逻辑往往分散在 组件内部,而 使用方 必须自行管理状态,使组件变成难以调试的“黑盒”。
  • 小改动就会导致全部崩溃。

根本原因: 你把 布局和行为 编码进了 Props。

我们将在后面把它重构为复合组件。

心智模型转变

旧模型(属性驱动)

“我配置一个组件。”

  • 组件决定布局。
  • 受限于预定义的属性。
  • 随着每个新功能,复杂度都会增加。

新模型(组合驱动)

“我从各个部件组装行为。”

  • 使用者决定布局。
  • 通过包装和重新排序元素实现高度灵活性。
  • 复杂度保持平坦;各部件相互独立。

与其告诉组件 它应该是什么样子,我们提供 状态和规则,并让使用者决定结构。这正是 复合组件渲染属性 发挥优势的地方。

什么是复合组件?

复合组件公开一组 相关组件,它们:

  • 隐式共享状态。
  • 只能在特定父组件内部使用。
  • 形成声明式、可读的 API。

它们感觉自然,因为它们模仿了 HTML 模式。

示例用法

<Accordion>
  <Accordion.Item>
    <Accordion.Header>Title</Accordion.Header>
    <Accordion.Body>Content</Accordion.Body>
  </Accordion.Item>
</Accordion>
  • 没有属性钻取。
  • 没有配置地狱。
  • 结构自解释。

复合组件的思维模型

Accordion (状态拥有者)
 ├─ Item (限定状态)
 │   ├─ Header (读取 + 触发)
 │   └─ Body   (读取)
  • Accordion 拥有全局状态。
  • Item 将该状态缩小到单个项目。
  • Header / Body 只消费它们需要的内容。

状态向下流动,事件向上流动——但仅在狭窄的作用域内。每一层都减少了责任,使得每个组件在孤立时都易于理解。这种隔离正是复合组件在大规模下保持可维护性的关键。

从头构建手风琴组件

1. 状态设计

const [activeItems, setActiveItems] = useState<Set<string>>(...);

const toggleItem = useCallback(
  (itemId: string) => {
    if (allowMultiple) {
      setActiveItems(prev => {
        const newItems = new Set(prev);
        if (newItems.has(itemId)) {
          newItems.delete(itemId);
        } else {
          newItems.add(itemId);
        }
        return newItems;
      });
      return;
    }

    setActiveItems(prev =>
      prev.has(itemId) ? new Set() : new Set([itemId])
    );
  },
  [allowMultiple]
);

手风琴的状态需要高效地回答一个问题:这个项是否打开?

为什么使用 Set

  • O(1) 查找。
  • 自动防止重复 ID。
  • 自然适用于“多个打开项”。
  • 语义明确。

仅此决定就能在多个用户体验模式中实现复用,并且轻松支持:

  • 受控和非受控两种用法。
  • 单项打开和多项打开两种模式。

本示例侧重于非受控使用;受控版本则会接受 valueonChange 并在外部推导 activeItems

2. 内部契约的 Context

type AccordionContextValue = {
  activeItems: Set<string>;
  toggleItem: (id: string) => void;
};

该 Context 用于 内部AccordionItemHeaderBody 之间共享状态和操作,而不向使用者暴露实现细节。

公共 vs 内部 API

Public API
-------------------
<Accordion>
  <Accordion.Item>
    <Accordion.Header />
    <Accordion.Body />
  </Accordion.Item>
</Accordion>

Internal API
-------------------
AccordionContext
  ├── activeItems
  └── toggleItem

使用者依赖 组件,而不是这些组件内部的实现方式。如果使用者依赖了你的 context 结构,就等于泄露了内部实现。

复合组件让你可以在幕后更新所有内容,而不会破坏使用者。

关键点: 使用者进行组合 — 他们不管理状态。

创建上下文

import { createContext, useContext } from 'react';

// Accordion Context
type AccordionContextValue = {
  allowMultiple?: boolean;
  activeItems: Set<string>;
  toggleItem: (id: string) => void;
};

export const AccordionContext = createContext<AccordionContextValue | null>(null);

export const useAccordion = () => {
  const ctx = useContext(AccordionContext);
  if (!ctx) {
    throw new Error('useAccordion must be used within <Accordion>');
  }
  return ctx;
};

// Accordion Item Context
type AccordionItemContextValue = {
  id: string;
  isActive: boolean;
};

export const AccordionItemContext = createContext<AccordionItemContextValue | null>(null);

export const useAccordionItem = () => {
  const ctx = useContext(AccordionItemContext);
  if (!ctx) {
    throw new Error('useAccordionItem must be used within <Accordion.Item>');
  }
  return ctx;
};

使用 AccordionContext.Provider

const getInitialValue = (defaultValue?: string | string[]): string[] => {
  if (!defaultValue) return [];
  if (Array.isArray(defaultValue)) return defaultValue;
  return [defaultValue];
};

function Accordion({ children, allowMultiple, defaultValue }: Props) {
  const [activeItems, setActiveItems] = useState<Set<string>>(
    () => new Set(getInitialValue(defaultValue))
  );

  const toggleItem = useCallback(
    (itemId: string) => {
      if (allowMultiple) {
        setActiveItems(prev => {
          const newItems = new Set(prev);
          if (newItems.has(itemId)) {
            newItems.delete(itemId);
          } else {
            newItems.add(itemId);
          }
          return newItems;
        });
        return;
      }
      setActiveItems(prev =>
        prev.has(itemId) ? new Set() : new Set([itemId])
      );
    },
    [allowMultiple]
  );

  const value = useMemo(
    () => ({ allowMultiple, activeItems, toggleItem }),
    [allowMultiple, activeItems, toggleItem]
  );

  return (
    <AccordionContext.Provider value={value}>
      {children}
    </AccordionContext.Provider>
  );
}

使用 Accordion.Item 进行作用域划分

type ItemProps = {
  children: React.ReactNode;
  itemId: string;
};

function AccordionItem({ children, itemId }: ItemProps) {
  const { activeItems } = useAccordion();

  const isActive = activeItems.has(itemId);
  const { element } = renderChildren(children, { isActive });

  return (
    <AccordionItemContext.Provider value={{ id: itemId, isActive }}>
      {element}
    </AccordionItemContext.Provider>
  );
}

为什么需要第二个上下文?

  • HeaderBody 只需要了解 它们各自 的项。
  • 防止抽象泄漏。
  • 保持每个子组件的专注,降低耦合度和复杂性。

手风琴组件实现

Header 组件

type HeaderProps = {
  children: Children<{ isActive: boolean; onClick: () => void }>;
  className?: string;
};

function AccordionHeader({ children, className }: HeaderProps) {
  const { toggleItem } = useAccordion();
  const { id, isActive } = useAccordionItem();

  const handleClick = () => toggleItem(id);

  const { element } = renderChildren(children, {
    isActive,
    onClick: handleClick,
  });

  return <div className={className}>{element}</div>;
}

Render‑prop 支持

export type Children<T = any> = React.ReactNode | ((props: T) => React.ReactNode);

export const renderChildren = <T,>(children: Children<T>, props: T) => {
  if (typeof children === 'function')
    return { element: children(props), isRenderProp: true };
  return { element: children, isRenderProp: false };
};

Body 组件

type BodyProps = {
  children: Children;
};

function AccordionBody({ children }: BodyProps) {
  const { isActive } = useAccordionItem();
  const { element } = renderChildren(children, { isActive });

  return <div>{element}</div>;
}

导出复合组件

Accordion.Item = AccordionItem;
Accordion.Header = AccordionHeader;
Accordion.Body = AccordionBody;

export { Accordion };

好处

  • 导入单个组件并使用其所有子组件。
  • TypeScript 能够理解结构,提供完整的类型安全。

示例用法

import { Accordion } from './components/accordion';
import { FAQs } from './data/faqs';

function App() {
  return (
    <Accordion allowMultiple>
      {FAQs.map(faq => (
        <Accordion.Item key={faq.id} itemId={faq.id}>
          <Accordion.Header>{faq.question}</Accordion.Header>
          <Accordion.Body>{faq.answer}</Accordion.Body>
        </Accordion.Item>
      ))}
    </Accordion>
  );
}

export default App;

附加用‑例

带打开/关闭子组件的 Modal 组件

function App() {
  return (
    <>
      <Modal>
        <Modal.Trigger>Open Modal</Modal.Trigger>
        <Modal.Window>
          <Modal.Close>Close Modal</Modal.Close>
        </Modal.Window>
      </Modal>
    </>
  );
}
  • 在任意位置渲染窗口,并可通过 Modal 上下文从任何后代组件打开/关闭它。
  • 导出 useModal hook 以进行编程式控制:
function App() {
  const { openWindow, closeWindow } = useModal();

  const someHandler = () => {
    // Do some logic
    openWindow('window-1');
  };
}

Mental Model: 一个状态拥有者,多个声明式消费者。

带触发器、预览、拖拽区和错误的文件输入组件

function App() {
  return (
    <FileInput>
      <FileInput.Trigger>Upload Image</FileInput.Trigger>
      <FileInput.Dropzone />
      <FileInput.Preview>
        {(files, removeFile) => (
          <>
            {/* Render each file with a delete button */}
          </>
        )}
      </FileInput.Preview>
    </FileInput>
  );
}

渲染‑prop 模式在这里大放异彩,允许对预览布局进行完整控制,同时保持组件 API 简洁。

改进手风琴:性能与架构

改进 1 – 拆分 Context 以减少重新渲染

当前情况

  • activeItems 的任何更改都会重新渲染 所有 消费者。

建议的更改

type AccordionState = {
  allowMultiple?: boolean;
  activeItems: Set<string>;
};
const AccordionStateContext = createContext<AccordionState | null>(null);

type AccordionActions = {
  toggleItem: (id: string) => void;
};
const AccordionActionsContext = createContext<AccordionActions | null>(null);

结果

  • 与 memoization 结合使用时,可减少不必要的重新渲染。
  • 保持操作函数的引用稳定。

这些优化在组件被广泛复用或同时打开大量手风琴项(例如 100 项)时尤为重要。

⚠️ 警告: 添加额外的 Context 会增加复杂度。请在优化前进行测量。

改进 2 – 提取无 UI Hook

function useAccordionState({
  defaultActive,
  allowMultiple,
}: { defaultActive?: string[]; allowMultiple?: boolean }) {
  // 仅包含状态和逻辑
}

好处

  • 可测试、可复用的逻辑与 UI 分离。
  • 可在不同的组合组件之间共享(例如 tabs、可折叠面板)。
Back to Blog

相关文章

阅读更多 »