React高质量组件开发——复合组件模式的应用

2,565 阅读4分钟

背景介绍

这是学习设计模式的第十四章,学习的是复合模式内容。记录的是自己学习理解的过程,欢迎大家讨论。

关于设计模式前十三节的内容,可以关注React设计模式专栏获取更多内容。

极简释义

复合组件模式:使用个组件协同完成单一功能。

正文

在我们的项目中,经常有很多组件。有些组件通过共享state,或者共享逻辑,相互依赖复合组件常见于selectmenudropdown组件和他们的子项之间。复合组件模式正是通过创建多个互相协作组件共同完成一个功能。

Context API

下面我们通过一个图片列表组件来具体阐述一下复合组件模式。比如说有一个图片列表组件,展示很多松鼠🐿图片,当然不仅展示松鼠图片,我们希望用户可以编辑删除图片,所以我们给每个图片添加一个操作按钮。这时我们可以创建一个FlyOut组件,用来展示用户点击操作按钮后下拉弹出的操作菜单,如下图所示:

image.png

分析:要实现一个下拉菜单组件,我们需要拆分成个组件:

  • 一个容器组件 FlyOut,state状态,容纳操作按钮和下拉菜单;
  • 一个操作按钮 Toggle,让用户点击;
  • 一个列表菜单 List,让用户选择操作;

使用React Context API来实现这样一个下拉菜单组件是一个很好的共享状态的方案。

首先,我们实现容器组件FlyOut,这个组件包含state,返回一个Context.Provider,为子组件提供所需要的open状态。

const FlyOutContext = createContext();
 
function FlyOut(props) {
  const [open, setOpen] = useState(false);
 
  return (
    <FlyOutContext.Provider value={{ open, setOpen }}>
      {props.children}
    </FlyOutContext.Provider>
  );
}

现在我们有了一个状态组件FlyOut,并且为所有的子组件提供了open状态修改open状态的方法

接下来我们来实现操作按钮组件Toggle,这个组件仅展示操作按钮,并修改FlyOut的open状态。

function Toggle() {
  const { open, setOpen } = useContext(FlyOutContext);
 
  return (
    <div onClick={() => setOpen(!open)}>
      <Icon />
    </div>
  );
}

为了让Toggle组件能正确地使用FlyOutContext,我们需要把Toggle组件用做FlyOutchildren,当然我们也可以把Toggle组件作为FlyOut组件的一个属性,正如antd组件库Slect.Option那样的形式。

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的一个属性
FlyOut.Toggle = Toggle;

这样做的一个原因一方面是Toggle组件不能脱离FlyOut单独使用,另一方面,在引入组件时,只需要引入FlyOut就可以。

import React from "react";
import { FlyOut } from "./FlyOut";
 
export default function FlyoutMenu() {
  return (
    <FlyOut>
      <FlyOut.Toggle />
    </FlyOut>
  );
}

接下来我们来开发List组件,List组件依赖FlyOutContextopen属性:

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

List组件的children可能有多个Item,我们可以像Toggle组件一样,把ListItem组件作为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>;
}
// 把Toggle、List、Item作为FlyOut的属性
FlyOut.Toggle = Toggle;
FlyOut.List = List;
FlyOut.Item = Item;

接下来我们看怎么使用FlyOut这个组件:

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>
  );
}

现在我们FlyOutMenu组件不再需要任何state,只需要引入FlyOut组件就可以了。

在线调试

当我们开发多个相互依赖的组件时,复合模式就很有用,相信大家在使用antd等优秀组件库时,就会发现这种模式比较常见。

React.Children.map

当然除了使用Context外,我们也可以使用React.Children.map结合React.cloneElementopensetOpen方法传递给子组件:

export function FlyOut(props) {
  const [open, setOpen] = React.useState(false);
 
  return (
    <div>
      {React.Children.map(props.children, (child) =>
        React.cloneElement(child, { open, setOpen })
      )}
    </div>
  );
}

想想大家能看懂,Children.map方法会遍历所有的children,通过第二个参数,把opensetOpen映射给循环子组件;需要注意的是,这里的map方法要和数组的map方法对比记忆一下。

下面我们来看具体的实现代码

import React from "react";
import Icon from "./Icon";

export function FlyOut(props) {

    const [open, toggle] = React.useState(false);
    return (
        <div className={`flyout`}>
            {React.Children.map(props.children, child =>
                React.cloneElement(child, { open, toggle })
            )}
        </div>
    );
}

function Toggle({ open, toggle }) {

    return (
        <div className="flyout-btn" onClick={() => toggle(!open)}>
            <Icon />
        </div>
    );
}

function List({ children, open }) {
    return open && <ul className="flyout-list">{children}</ul>;
}

function Item({ children }) {
    return <li className="flyout-item">{children}</li>;
}

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

总结

复合模式维护内部state,并共享给多个不同的子组件;当为复合模式添加新组件时,就不用再考虑自己维护state

引入复合模式的组件时,我们不用再单独引入模式内的子组件。

相较于FlyOutContext.ProviderReact.Children.map这种传递state的方式只能向直接子组件提供,不能为嵌套更深的子组件传递state,同时意味着父级和子级组件中不能有其他元素比如div:

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

另外React.cloneElement只能进行浅合并,所以存在props命名冲突、覆盖问题。

相关活动

本文正在参加「金石计划」