mcpField
C
compound-pattern

compound-pattern

github.com/patternsdev开发2571.1k 次安装28 周前更新
代码仓库

关于 compound-pattern

介绍复合组件模式的 React 技能,适用于共享状态的相关组件。面向构建下拉菜单、标签页和菜单等组件组的开发者,说明 Context 实现及其适用边界。

主要功能

  1. 01通过 React Context 共享状态
  2. 02将子组件挂载为父组件属性
  3. 03支持灵活的组件嵌套
  4. 04比较 Context 与 React.Children.map
  5. 05判断模式是否带来额外开销

适用场景

  1. 01构建控件与列表协同的下拉菜单
  2. 02创建共享状态的标签页或菜单
  3. 03设计组件库 API
  4. 04在复合组件与简单属性间做选择

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/patternsdev/skills --skill compound-pattern

安装后在 Claude Code 中输入/compound-pattern确认已加载。

详细介绍

复合组件模式

目录

在我们的应用中,常常会有彼此相关联的组件。它们通过共享状态相互依赖,并共享逻辑。select、下拉组件或菜单项等组件通常就会采用这种模式。复合组件模式允许你创建一组协同工作、共同完成任务的组件。

适用场景

  • 构建下拉菜单、标签页或菜单等包含相关子组件的组件时,使用此模式
  • 如果你想提供简洁的组件 API,同时不暴露内部状态管理,此模式会很有帮助

不适用场景

  • 子组件之间没有共享有意义的状态时——此模式会带来不必要的 Context 开销
  • 对于简单的一次性 UI,使用单个带 props 的组件会更清晰
  • 隐式共享状态会让组件的行为难以预测时

说明

  • 使用 React Context API 在父复合组件与其子组件之间共享状态
  • 将子组件作为静态属性挂载到父组件上(例如 FlyOut.Toggle、FlyOut.List)
  • 在复杂场景中,对 Context 值进行记忆化处理,避免不必要的重新渲染
  • 为获得更灵活的组件嵌套,优先采用 Context 方案,而不是 React.Children.map

详情

Context API

来看一个例子:我们有一组松鼠图片!除了展示松鼠图片,我们还想添加一个按钮,让用户可以编辑或删除图片。我们可以实现一个 FlyOut 组件,在用户切换该组件时显示一个列表。

FlyOut 组件中主要包含三部分:

  • FlyOut 容器,其中包含切换按钮和列表
  • Toggle 按钮,用于切换 List 的显示状态
  • List,其中包含菜单项列表

在这个例子中,使用 React 的 Context API 实现复合组件模式再合适不过了!

首先,创建 FlyOut 组件。该组件维护状态,并将切换状态的值传递给它接收的所有子组件,同时返回一个 FlyOutProvider。

const FlyOutContext = createContext();

function FlyOut(props) {
  const [open, toggle] = useState(false);

  return (
    <FlyOutContext.Provider value={{ open, toggle }}>
      {props.children}
    </FlyOutContext.Provider>
  );
}

现在,我们有了一个有状态的 FlyOut 组件,可以向子组件传递 open 和 toggle 的值了!

接下来创建 Toggle 组件。该组件只需渲染一个组件,用户点击它即可切换菜单。

function Toggle() {
  const { open, toggle } = useContext(FlyOutContext);

  return (
    <div onClick={() => toggle(!open)}>
      <Icon />
    </div>
  );
}

若要让 Toggle 实际访问 FlyOutContext Provider,我们需要将它作为 FlyOut 的子组件来渲染!我们还可以将 Toggle 组件设为 FlyOut 组件的一个属性!

const FlyOutContext = createContext();

function FlyOut(props) {
  const [open, toggle] = useState(false);

  return (
    <FlyOutContext.Provider value={{ open, toggle }}>
      {props.children}
    </FlyOutContext.Provider>
  );
}

function Toggle() {
  const { open, toggle } = useContext(FlyOutContext);

  return (
    <div onClick={() => toggle(!open)}>
      <Icon />
    </div>
  );
}

FlyOut.Toggle = Toggle;

这意味着,如果我们想在任何文件中使用 FlyOut 组件,只需导入 FlyOut 即可!

import React from "react";
import { FlyOut } from "./FlyOut";

export default function FlyoutMenu() {
  return (
    <FlyOut>
      <FlyOut.Toggle />
    </FlyOut>
  );
}

只有切换按钮还不够。我们还需要一个包含列表项的 List,其显示和隐藏状态取决于 open 的值。

function List({ children }) {
  const { open } = React.useContext(FlyOutContext);
  return open && <ul>{children}</ul>;
}

function Item({ children }) {
  return <li>{children}</li>;
}

List 组件会根据 open 的值是 true 还是 false 来渲染其子组件。像对 Toggle 组件所做的那样,将 List 和 Item 设为 FlyOut 组件的属性。

const FlyOutContext = createContext();

function FlyOut(props) {
  const [open, toggle] = useState(false);

  return (
    <FlyOutContext.Provider value={{ open, toggle }}>
      {props.children}
    </FlyOutContext.Provider>
  );
}

function Toggle() {
  const { open, toggle } = useContext(FlyOutContext);

  return (
    <div onClick={() => toggle(!open)}>
      <Icon />
    </div>
  );
}

function List({ children }) {
  const { open } = useContext(FlyOutContext);
  return open && <ul>{children}</ul>;
}

function Item({ children }) {
  return <li>{children}</li>;
}

FlyOut.Toggle = Toggle;
FlyOut.List = List;
FlyOut.Item = Item;

现在,我们可以将它们作为 FlyOut 组件的属性来使用!在这个例子中,我们想向用户展示两个选项:编辑和删除。接下来创建一个 FlyOut.List,其中渲染两个 FlyOut.Item 组件,分别对应编辑和删除选项。

import React from "react";
import { FlyOut } from "./FlyOut";

export default function FlyoutMenu() {
  return (
    <FlyOut>
      <FlyOut.Toggle />
      <FlyOut.List>
        <FlyOut.Item>Edit</FlyOut.Item>
        <FlyOut.Item>Delete</FlyOut.Item>
      </FlyOut.List>
    </FlyOut>
  );
}

太好了!我们创建了一个完整的 FlyOut 组件,而无需在 FlyOutMenu 本身中添加任何状态!

构建组件库时,复合组件模式非常实用。使用 Semantic UI 等 UI 库时,你经常会看到这种模式。

React.Children.map

我们还可以通过遍历组件的子元素来实现复合组件模式。通过克隆这些元素并添加额外的属性,我们可以为它们添加 open 和 toggle 属性。

export function FlyOut(props) {
  const [open, toggle] = React.useState(false);

  return (
    <div>
      {React.Children.map(props.children, (child) =>
        React.cloneElement(child, { open, toggle })
      )}
    </div>
  );
}

所有子组件都会被克隆,并接收 open 和 toggle 的值。与前一个示例中必须使用 Context API 不同,现在我们可以通过 props 访问这两个值。

优点

复合组件会管理自己的内部状态,并在多个子组件之间共享该状态。实现复合组件时,我们无需自行管理状态。

导入复合组件时,无需显式导入该组件提供的子组件。

import { FlyOut } from "./FlyOut";

export default function FlyoutMenu() {
  return (
    <FlyOut>
      <FlyOut.Toggle />
      <FlyOut.List>
        <FlyOut.Item>Edit</FlyOut.Item>
        <FlyOut.Item>Delete</FlyOut.Item>
      </FlyOut.List>
    </FlyOut>
  );
}
缺点

使用 React.Children.map 传递这些值时,组件嵌套会受到限制。只有父组件的_直接子组件_才能访问 open 和 toggle 属性,这意味着我们无法在这些组件外包裹其他组件。

export default function FlyoutMenu() {
  return (
    <FlyOut>
      {/* This breaks */}
      <div>
        <FlyOut.Toggle />
        <FlyOut.List>
          <FlyOut.Item>Edit</FlyOut.Item>
          <FlyOut.Item>Delete</FlyOut.Item>
        </FlyOut.List>
      </div>
    </FlyOut>
  );
}

使用 React.cloneElement 克隆元素时,会进行浅合并。已存在的属性会与我们传入的新属性合并。如果已有属性与我们传递给 React.cloneElement 方法的属性同名,就可能发生命名冲突。由于属性是浅合并的,该属性的值会被我们传入的最新值覆盖。

来源

参考资料

评论

支持 Markdown