likes
comments
collection

React通用解决方案——浮层容器

作者站长头像
站长
· 阅读数 16

1. 前话

距离上次分享React通用解决方案一眨眼就过了一个月,本系列前篇结尾说本篇会讲解「React通用解决方案——表单容器」。

但作者思索了一番,认为「表单容器」的实现核心是「浮层容器」,容器包裹的内容使用者可自行封装。「浮层容器」适配表单场景的时候它便是「表单容器」、适配XXX场景的时候它便是「XXX容器」。

因此决定先分享「浮层容器」的实现细节。

2. 正文

在B端中台类系统中,较为常见的一个场景是,利用浮层容器展现内容,并在浮层容器的「确认」动作执行前进行异步的处理逻辑(Promise)

例如,有一个列表每一条数据有一个按钮名为「信息确认」,点击「信息确认」按钮后会设置「当前选中的数据标识」、「异步获取信息」、「弹窗标题」和「弹窗显示状态」。其中弹窗内容根据「异步获取信息」结果进行展示。

用户可进行「确认」或「取消」动作,「取消」动作触发会隐藏弹窗,而「确认」动作触发过程中会前置的执行一个「异步提交」逻辑,这个逻辑执行成功后才会隐藏弹窗。

React通用解决方案——浮层容器

示例代码如下:

const TestPage: React.FC = () => {
  const [title, setTitle] = useState("");
  const [selectedId, setSelectedId] = useState<number>();
  const [selectedContent, setSelectedContent] = useState<string>();
  const [visible, setVisible] = useState(false);

  const handleConfirm = useCallback((id: number) => {
    // 模拟异步获取信息动作
    Promise.resolve(`Pwcong_${id}`).then((content) => {
      setTitle(`是否确认ID为${id}信息?`);
      setSelectedId(id);
      setSelectedContent(content);
      setVisible(true);
    });
  }, []);

  const handleOk = useCallback(async () => {
    // 模拟异步请求确认动作
    await Promise.resolve(selectedId);
    setVisible(false);
  }, [selectedId]);

  const handleCancel = useCallback(() => setVisible(false), []);

  return (
    <div className="test-page">
      <Button
        onClick={() => handleConfirm(Math.round(Math.random() * 10000))}
        type="primary"
      >
        信息确认
      </Button>

      <Modal
        title={title}
        visible={visible}
        onOk={handleOk}
        onCancel={handleCancel}
      >
        <div>Hello, {selectedContent}</div>
      </Modal>
    </div>
  );
};

这种写法是大多数人的写法,很标准,很直观。

某一天,产品认为弹窗太小了无法很好的展现内容,要求换成浮层的另一种——抽屉去做呈现。大家庆幸还好Arco组件的「弹窗」与「抽屉」的组件属性类似,所以把「Modal」直接替换成「Drawer」就完成了产品的需求,如下:

  // ...
  <Drawer
    title={title}
    visible={visible}
    onOk={handleOk}
    onCancel={handleCancel}
  >
    <div>Hello, {selectedContent}</div>
  </Drawer>

某一天,产品提了个新列表页面需求,也有个和之前列表的一样「确认信息」按钮。大家CV一遍同样快速完成需求交付。

某一天。。。

在后续这么开发了几个类似的页面后,很多人会发现,一直在不断地重复声明「title」、「visible」和「selectedXXX」等变量,且不断重复写着弹窗「显隐」处理相关的逻辑。但还是能接受,不就是CV嘛。

某一天,产品提了个新页面需求,因为内容和浮层内容一致,且觉得目前的浮层内容的样式挺好看的,想直接使用。大家立马再次CV一遍同样快速完成需求交付。

慢慢的,相同的组件越来越多,需要维护的组件也越来越多,加班时间也越来越多,头发也越来越少😭

因此亟需寻求一个解决方案应对这个问题——也就是「浮层容器」

2.1 浮层容器定义

浮层容器的定义如下:

  • 浮层容器支持各种呈现(弹窗和抽屉等);
  • 浮层容器只关注浮层的标题、显隐状态和显隐状态变更处理逻辑,不关注浮层内容;
  • 浮层容器组件提供接口控制浮层容器的标题和显隐状态;
  • 任何内容被浮层容器包裹即可获得浮层的能力;
  • 浮层容器提供向内容透传属性的能力;

基于上面的定义实现的TS类型定义如下:

import React from 'react';

import { ModalProps, DrawerProps } from '@arco-design/web-react';


export type IFloatWrapperBaseProps = {
  /** 标题 */
  title?: React.ReactNode;
};

export type IFloatWrapperOpenProps<P = {}> = IFloatWrapperBaseProps & {
  /** 内容属性 */
  props?: P;
};

export type IFloatWrapperProps<P = {}> = IFloatWrapperBaseProps & {
  /** 浮层弹窗显隐状态 */
  visible?: boolean;
  /** 浮层弹窗提交回调函数 */
  onOk?: (result?: any, componentProps?: P) => void | Promise<void>;
  /** 浮层弹窗取消回调函数 */
  onCancel?: () => void;
  /** 内容属性 */
  componentProps?: P;
};

export type IFloatWrappedModalProps<P = {}> = Omit<
  ModalProps,
  'onOk' | 'onCancel'
> &
  IFloatWrapperProps<P>;

export type IFloatWrappedDrawerProps<P = {}> = Omit<
  DrawerProps,
  'onOk' | 'onCancel'
> &
  IFloatWrapperProps<P>;

export type IFloatWrapperRef<P = {}> = {
  /** 浮层弹窗打开接口 */
  open: (openProps?: IFloatWrapperOpenProps<P>) => void;
  /** 浮层弹窗关闭接口 */
  close: () => void;
};

export type IWithFloatWrapperOptions<P = {}> = {
  /** 默认属性 */
  defaultProps?: Partial<IFloatWrapperProps<P>>;
};

export type IWithFloatWrapperProps<P = {}> = IFloatWrapperBaseProps &
  P & {
    /** 浮层弹窗显隐状态 */
    visible?: boolean;
    /** 浮层弹窗提交回调函数 */
    onOk?: (result?: any) => Promise<void>;
    /** 浮层弹窗取消回调函数 */
    onCancel?: () => void;
  };

2.2 浮层容器定义实现

基于上面的浮层定义,我们这里实现一个浮层容器定义实现Hook,实现代码如下:

/**
 * 浮层容器定义实现
 * @param ref 浮层实例
 * @param wrapperProps 浮层属性
 * @returns
 */
export function useFloatWrapper<P = {}>(
  ref: ForwardedRef<IFloatWrapperRef<P>>,
  wrapperProps: IFloatWrapperProps<P>,
) {
  const visible = useBoolean(false);
  const loading = useBoolean(false);

  const [title, setTitle] = useState<React.ReactNode>();
  const [componentProps, setComponentProps] = useState<P>();

  // 确认操作逻辑
  const onOk = async (result: any) => {
    loading.setTrue();

    const targetComponentProps = wrapperProps.componentProps ?? componentProps;

    try {
      await wrapperProps.onOk?.(result, targetComponentProps);
      visible.setFalse();
    } catch (err) {
      console.error(err);
    } finally {
      loading.setFalse();
    }
  };

  // 取消操作逻辑
  const onCancel = () => {
    wrapperProps.onCancel?.();
    visible.setFalse();
  };

  // 实例挂载浮层操作接口
  useImperativeHandle(
    ref,
    (): IFloatWrapperRef<P> => ({
      open: openProps => {
        const { title: newTitle, props: newComponentProps } = openProps ?? {};

        setTitle(newTitle);
        setComponentProps(newComponentProps);

        visible.setTrue();
      },
      close: onCancel,
    }),
  );

  const ret = [
    {
      loading: loading.state,
      visible: wrapperProps.visible ?? visible.state,
      title: wrapperProps.title ?? title,
      componentProps: wrapperProps.componentProps ?? componentProps,
    },
    {
      onOk,
      onCancel,
      setTitle,
      setComponentProps,
    },
  ] as const;

  return ret;
}

这里用到了useImperativeHandle这个Hook,详细细节可参考官方文档哈 👉 reactjs.org/docs/hooks-…

2.3 浮层容器呈现实现

浮层容器的呈现有多种,常见的为弹窗和抽屉。下面我使用Arco对应组件进行呈现实现 👇 。

2.3.1 弹窗浮层容器

/**
 * 弹窗浮层容器
 * @param options 浮层配置
 * @returns
 */
function withModal<P = {}>(options?: IWithFloatWrapperOptions<P>) {
  const { defaultProps } = options ?? {};

  return function (Component: any) {
    const WrappedComponent = (
      props: IFloatWrappedModalProps<P>,
      ref: ForwardedRef<IFloatWrapperRef<P>>
    ) => {
      const wrapperProps = {
        ...defaultProps,
        ...props,
      };

      const { unmountOnExit = true, ...restProps } = wrapperProps;

      const [{ visible, title, componentProps }, { onOk, onCancel }] =
        useFloatWrapper<P>(ref, wrapperProps);

      return (
        <Modal
          {...restProps}
          visible={visible}
          onOk={onOk}
          onCancel={onCancel}
          title={title}
          unmountOnExit={unmountOnExit}
        >
          {createElement(Component, {
            visible,
            title,
            onOk,
            onCancel,
            ...componentProps,
          })}
        </Modal>
      );
    };

    WrappedComponent.displayName = `FloatWrapper.withModal(${getDisplayName(
      Component
    )})`;

    const ForwardedComponent = forwardRef<
      IFloatWrapperRef<P>,
      IFloatWrappedModalProps<P>
    >(WrappedComponent);

    return ForwardedComponent;
  };
}

2.3.2 抽屉浮层容器

/**
 * 抽屉浮层容器
 * @param options 浮层配置
 * @returns
 */
function withDrawer<P = {}>(options?: IWithFloatWrapperOptions<P>) {
  const { defaultProps } = options ?? {};

  return function (Component: any) {
    const WrappedComponent = (
      props: IFloatWrappedDrawerProps<P>,
      ref: ForwardedRef<IFloatWrapperRef<P>>
    ) => {
      const wrapperProps = {
        ...defaultProps,
        ...props,
      };

      const { unmountOnExit = true, ...restProps } = wrapperProps;

      const [{ visible, title, componentProps }, { onOk, onCancel }] =
        useFloatWrapper<P>(ref, wrapperProps);

      return (
        <Drawer
          {...restProps}
          visible={visible}
          title={title}
          unmountOnExit={unmountOnExit}
          onOk={onOk}
          onCancel={onCancel}
        >
          {createElement(Component, {
            visible,
            title,
            onOk,
            onCancel,
            ...componentProps,
          })}
        </Drawer>
      );
    };

    WrappedComponent.displayName = `FloatWrapper.withDrawer(${getDisplayName(
      Component
    )})`;

    const ForwardedComponent = forwardRef<
      IFloatWrapperRef<P>,
      IFloatWrappedDrawerProps<P>
    >(WrappedComponent);

    return ForwardedComponent;
  };
}

2.3.3 浮层容器HOC

/**
 * 浮层容器Hoc
 * @param Component 浮层组件
 * @returns
 */
export function withFloatWrapper<P = {}>(Component: any) {
  const WrappedComponent = (
    props: IFloatWrapperProps<P>,
    ref: ForwardedRef<IFloatWrapperRef<P>>,
  ) => {
    const [{ visible, title, componentProps }, { onOk, onCancel }] =
      useFloatWrapper<P>(ref, props);

    return createElement(Component, {
      visible,
      title,
      onOk,
      onCancel,
      ...componentProps,
    });
  };

  WrappedComponent.displayName = `withFloatWrapper(${getDisplayName(
    Component,
  )})`;

  const ForwardedComponent = forwardRef<
    IFloatWrapperRef<P>,
    IFloatWrapperProps<P>
  >(WrappedComponent);

  return ForwardedComponent;
}

2.4 浮层容器用例

回到一开始描述的问题,「浮层状态和逻辑冗余」和「组件复用性差」。

对于「组件复用性差」的问题我们进行以下改造,将浮层内容抽离成单独的组件,代码如下:

type IContentProps = {
  id?: number;
};

const Content: React.FC<IContentProps> = ({ id }) => {
  const [who, setWho] = useState("");

  useEffect(() => {
    // 模拟异步获取信息动作
    Promise.resolve(`Pwcong_${id}`).then(setWho);
  }, [id]);

  return <div>Hello, {who}</div>;
};

下面我们就可以使用上面实现的浮层容器进行包裹生成浮层内容组件,代码如下:

const defaultContentFloatProps = {
  onOk: async (props?: IContentProps) => {
    // 模拟异步请求确认动作
    await Promise.resolve(props?.id);
  },
};

// 弹窗浮层内容组件
const ContentModal = FloatWrapper.withModal<IContentProps>({
  defaultProps: defaultContentFloatProps,
})(Content);

// 抽屉浮层内容组件
const ContentDrawer = FloatWrapper.withDrawer<IContentProps>({
  defaultProps: defaultContentFloatProps,
})(Content);

通过上面的浮层内容组件我们就可以优化之前的示例代码,优化后的结果如下:

const TestPage: React.FC = () => {
  const contentModalRef = useRef<IFloatWrapperRef<IContentProps>>(null);
  const contentDrawerRef = useRef<IFloatWrapperRef<IContentProps>>(null);

  const handleConfirm = useCallback((id: number) => {
    contentModalRef.current?.open({
      title: `是否确认ID为${id}信息?`,
      props: {
        id,
      },
    });
    // contentDrawerRef.current?.open({
    //   title: `是否确认ID为${id}信息?`,
    //   props: {
    //     id,
    //   },
    // });
  }, []);

  return (
    <div className="test-page">
      <Button
        onClick={() => handleConfirm(Math.round(Math.random() * 10000))}
        type="primary"
      >
        信息确认
      </Button>

      <ContentModal ref={contentModalRef} />
      <ContentDrawer ref={contentDrawerRef} />
    </div>
  );
};

到这里我们就基本掌握了如何使用「浮层容器」,同时通过前后代码对比我们也能够发现「浮层容器」能够明显优化这类场景的代码实现。

3. 拓展

看到《React通用解决方案——组件二次包装》的同学们应该会注意到,「浮层容器呈现实现」尽可能的继承了原呈现的属性(除了onOk和onCancel),同时也以容器属性为优先。

因此若显式设定了title和visible等与「浮层容器定义实现」同名的属性,那么「浮层容器操作接口」透传的属性将失去效果,代码示例如下:

<ContentModal
  ref={contentModalRef}
  title="查看信息"
  visible={true}
  componentProps={{ id: 123 }}
/>

React通用解决方案——浮层容器

4. 最后

前话说了「浮层容器」适配表单场景的时候它便是「表单容器」,今天时间不够,那么下篇我再继续给大家分享《React通用解决方案——表单容器》哈。

最后的最后,本月内推简历OKR还没完成(2个有效简历+,给自己挖坑了),求求大佬们有兴趣可以到我的主页的内推链接投递😭😭😭

React通用解决方案——浮层容器