对我们的下一本书感兴趣吗?了解更多关于 使用 React 构建大型 JavaScript Web 应用程序

设计模式

复合模式

在我们的应用程序中,我们经常有相互关联的组件。它们通过共享状态相互依赖,并共同分享逻辑。你经常在 select、下拉组件或菜单项等组件中看到这种情况。复合组件模式允许你创建协同工作以完成任务的组件。


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 组件,它可以将 opentoggle 的值传递给它的子组件!

让我们创建 Toggle 组件。该组件只是渲染用户可以点击以切换菜单的组件。

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

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

为了真正让 Toggle 访问 FlyOutContext 提供者,我们需要将其作为 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 来渲染其子组件。让我们将 ListItem 设为 FlyOut 组件的属性,就像我们对 Toggle 组件所做的那样。

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 本身添加任何状态!

index.js
FlyOut.js
FlyoutMenu.js
Images.js
1import React from "react";
2import "./styles.css";
3import { FlyOut } from "./FlyOut";
4
5export default function FlyoutMenu() {
6 return (
7 <FlyOut>
8 <FlyOut.Toggle />
9 <FlyOut.List>
10 <FlyOut.Item>Edit</FlyOut.Item>
11 <FlyOut.Item>Delete</FlyOut.Item>
12 </FlyOut.List>
13 </FlyOut>
14 );
15}

当你在构建组件库时,复合模式非常有用。当你使用像 Semantic UI 这样的 UI 库时,你经常会看到这种模式。


React.Children.map

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

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

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

所有子组件都被克隆,并传递了 opentoggle 的值。现在,我们无需像上一个例子那样使用 Context API,就可以通过 props 访问这两个值。

index.js
FlyOut.js
FlyoutMenu.js
Images.js
1import React from "react";
2import Icon from "./Icon";
3
4const FlyOutContext = React.createContext();
5
6export function FlyOut(props) {
7 const [open, toggle] = React.useState(false);
8
9 return (
10 <div>
11 {React.Children.map(props.children, child =>
12 React.cloneElement(child, { open, toggle })
13 )}
14 </div>
15 );
16}
17
18function Toggle() {
19 const { open, toggle } = React.useContext(FlyOutContext);
20
21 return (
22 <div className="flyout-btn" onClick={() => toggle(!open)}>
23 <Icon />
24 </div>
25 );
26}
27
28function List({ children }) {
29 const { open } = React.useContext(FlyOutContext);
30 return open && <ul className="flyout-list">{children}</ul>;
31}
32
33function Item({ children }) {
34 return <li className="flyout-item">{children}</li>;
35}
36
37FlyOut.Toggle = Toggle;
38FlyOut.List = List;
39FlyOut.Item = Item;

优点

复合组件管理它们自己的内部状态,这些状态在多个子组件之间共享。在实现复合组件时,我们无需担心自己管理状态。

在导入复合组件时,我们无需显式导入该组件上可用的子组件。

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 来提供值时,组件嵌套受到限制。只有父组件的直接子组件才能访问 opentoggle props,这意味着我们不能将任何这些组件包装在另一个组件中。

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 克隆元素会执行浅合并。已经存在的 props 将与我们传递的新 props 合并在一起。如果已经存在的 prop 与我们传递给 React.cloneElement 方法的 props 有相同的名称,则可能会导致命名冲突。由于 props 是浅合并的,因此该 prop 的值将被我们传递的最新值覆盖。


参考资料