Ant Design 弹窗封装:易用性和可维护性

lxf2023-12-21 03:10:02

原文链接:whinc.github.io/blog/2023/0…

文章包含部分交互式例子,当前平台不支持,请前往原文查看

在 Antd 中弹窗的使用频率很高,一个好用的弹窗我认为应该具备几个特点:

  1. 支持命令式调用,类似window.alert()这种方式(无需关注弹窗的声明位置)
  2. 能够与弹窗进行交互,即提供参数控制弹窗的渲染,同时弹窗关闭时能从中获取所需数据(这个在表单场景常见)
  3. 弹窗的状态与其所在组件树隔离(避免状态污染)

Antd 提供的 Modal.method() 很好的处理了第 1 和 3 个问题,使用起来很简单,只需一行代码即可。

Modal.confirm({
    title: '确认?',
    content: <MyModalContent>,
    onOk () {
        // do something
    }
})

但是Modal.method()并未解决第 2 个问题,其内容展示后就与当前上下文脱离联系了,为了实现所期望的弹窗,下面我尝试了几种封装方式,试图解决这个问题。

第一版:基于 ref​

下面是我封装的第一版弹窗,弹窗通过暴露 ref 引用,让外部控制弹窗的显示,以及传递数据给弹窗容,通过回调函数将弹窗内部的处理结果传递给外部。

App.tsx

import { Button, message } from "antd";
import React, { useRef } from "react";
import { MyModal, MyModalInstance } from "./MyModal";

export default function App() {
  const ref = useRef<MyModalInstance>(null);
  return (
    <>
      <Button
        onClick={() =>
          ref.current?.open({
            value: "hello world!",
            modalProps: {
              onCancel: ref.current.close,
              onOk: async () => {
                message.success("ok");
                ref.current?.close();
              },
            },
          })
        }
      >
        显示弹窗
      </Button>
      <MyModal ref={ref} />
    </>
  );
}

MyModal.tsx

import { Modal, ModalProps } from "antd";
import React, { useCallback, useImperativeHandle, useState } from "react";
import { ContentProps } from "./MyModalContent";

export interface MyModalProps {
  onSuccess?(): void;
}

export interface MyModalInstance {
  open(payload?: Payload): void;
  close(): void;
}

type Payload = ContentProps & { modalProps?: ModalProps };

export const MyModal = React.forwardRef<MyModalInstance, MyModalProps>(
  ({ onSuccess }, ref) => {
    const [open, setOpen] = useState(false);
    const [payload, setPayload] = useState<Payload>({});

    const onOk = useCallback(async () => {
      onSuccess?.();
    }, [onSuccess]);

    const openModal = useCallback((_payload = {}) => {
      setOpen(true);
      setPayload(_payload);
    }, []);

    const closeModal = useCallback(() => {
      setOpen(false);
      // reset payload
      setPayload({});
    }, []);

    useImperativeHandle(
      ref,
      () => ({
        open: openModal,
        close: closeModal,
      }),
      [open]
    );

    return (
      <Modal {...payload.modalProps} open={open}>
        <div>{payload.value}</div>
      </Modal>
    );
  }
);

渲染效果

第二版:基于 ref 进行抽象​

基于上面弹窗的封装模式,根据需要可以进一步扩展,基本能满足大部分弹窗使用场景了。但是上面封装方式有不少样板代码,每次写个弹窗有不少重复工作,于是将对弹窗的封装模式剥离出来形成一个独立的函数createModal,这样可以大大减少样板代码,聚焦于编写弹窗的内容和交互逻辑。

createModal.tsx

import { Modal } from 'antd';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';

export function createModal(ContentComponent) {
  const ContentComponent2 = React.forwardRef((props, ref) => ContentComponent(props, ref));

  return function NewModal({ modalProps: modalProps, ...props }) {
    const [open, setOpen] = useState(false);

    // store UI data
    const [state, setState] = useState({});
    const [payload, setPayload] = useState({});
    const ref = useRef();

    const close = useCallback(() => {
      setOpen(false);
      // reset
      setState({});
      setPayload({});
    }, []);

    const actions = useMemo(() => ({ close, state, setState, ref }), [close, state]);

    useEffect(() => {
      const show = (_payload) => {
        setOpen(true);
        setPayload(() => _payload);
      };
      NewModal.show = show;
      return () => {
        NewModal.show = null;
      };
    }, []);

    const { modalProps: modalProps2 = {}, ...props2 } = useMemo(
      () => (typeof payload === 'function' ? payload(actions) : payload),
      [actions, payload],
    );

    /** @type {import('antd').ModalProps} */
    const defaultModalProps = {
      maskClosable: false,
      destroyOnClose: true,
      onCancel: close,
      onOk: close,
    };
    return (
      <Modal {...defaultModalProps} {...modalProps} {...modalProps2} open={open}>
        <ContentComponent2 ref={ref} {...props} {...props2} />
      </Modal>
    );
  };
}

下面是基于 createModal 创建弹窗的示例,可以看到弹窗的源码已经减少到一行代码了,写弹窗时只需要关注于弹窗的内容区域,使用弹窗时也无需创建 ref 来引用弹窗,而是通过弹窗上暴露的静态方法MyModal.show()显示弹窗和传参。

App.tsx

import { Button, message } from "antd";
import React from "react";
import MyModal from "./MyModal";

export default function App() {
  return (
    <>
      <Button
        onClick={() =>
          MyModal.show(({ close }) => ({
            value: 'hello world!',
            modalProps: {
              onCancel: close,
              onOk: async () => {
                message.success("ok");
                close()
              },
            },
          }))
        }
      >
        显示弹窗
      </Button>
      <MyModal />
    </>
  );
}

MyModal.tsx

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

export interface ContentProps {
  value?: any;
}

export default createModal(function MyModalContent(props: ContentProps) {
  return <div>{props.value}</div>;
});

渲染效果

经过createModal封装后,业务层只需关注弹窗的内容(即<Modal>的子组件),无需处理弹窗与内容的交互。 那使用时如何控制弹窗的交互呢?例如点击确认时获取弹窗中的表单内容、点击确认时弹窗按钮显示 loading 效果等。

答案是通过弹窗暴露的静态方法MyModal.show()实现,请看下面例子,通过 state/setState可以在弹窗组件内部存储状态,通过 ref 可以调用弹窗内容组件暴露的方法。

App.tsx

import { Button, message } from "antd";
import React from "react";
import MyModal from "./MyModal";

const mockRequest = (data: any) => new Promise((r) => setTimeout(r, 2000));

export default function App() {
  return (
    <>
      <Button
        onClick={() =>
          MyModal.show(({ close, state, setState, ref }) => ({
            initialValues: {
              name: "zhangsan",
              age: 20,
            },
            modalProps: {
              title: "注册用户",
              confirmLoading: state.loading,
              onCancel: close,
              onOk: async () => {
                setState({ loading: true });
                const formData = await ref.current.submit();
                await mockRequest(formData);
                setState({ loading: false });
                message.success("提交表单:" + JSON.stringify(formData));
                close();
              },
            },
          }))
        }
      >
        显示弹窗
      </Button>
      <MyModal />
    </>
  );
}

MyModal.tsx

import { Form, Input, InputNumber } from "antd";
import React, { useImperativeHandle } from "react";
import { createModal } from "../createModal";

export interface ContentProps {
  initialValues?: any;
}

export interface ContentInstance {
  submit(): Promise<void>;
}

export default createModal(function MyModalContent(
  props: ContentProps,
  ref: React.MutableRefObject<ContentInstance>
) {
  const [form] = Form.useForm();

  useImperativeHandle(ref, () => ({
    async submit() {
      return await form.validateFields();
    },
  }));
  return (
    <Form form={form} initialValues={props.initialValues}>
      <Form.Item name={"name"} label="姓名">
        <Input />
      </Form.Item>
      <Form.Item name={"age"} label="年龄">
        <InputNumber />
      </Form.Item>
    </Form>
  );
});

渲染效果

第三版:基于 nice-modal-react 包​

偶然浏览知乎看到这篇文章,才得知已经有人做了类似的工作,并且开源了叫 nice-modal-react,其原理是提供全局的<Provider>来存储和渲染全局弹窗,通过其提供的 create()方法创建的弹窗后,即可通过show()/hide()方法命令式使用弹窗,弹窗的内部状态与外部组件隔离,完全满足了文章开头提到的几个特点。

下面是基于 nice-modal-react 库重写的上面例子

App.tsx

import NiceModal from "@ebay/nice-modal-react";
import { Button, message } from "antd";
import React from "react";
import MyModal from "./MyModal";

const mockRequest = (data: any) => new Promise((r) => setTimeout(r, 2000));

export default function App() {
  return (
    <NiceModal.Provider>
      <Button
        onClick={() => {
          const props = {
            initialValues: {
              name: "zhangsan",
              age: 20,
            },
            async onSubmit(values) {
              const destory = message.loading(
                "提交数据:" + JSON.stringify(values)
              );
              await mockRequest(values);
              destory();
            },
          };

          NiceModal.show(MyModal, props).then(
            () => message.success("提交成功!"),
            (error) => {
              message.error("提交失败:" + error.message);
            }
          );
        }}
      >
        显示弹窗
      </Button>
    </NiceModal.Provider>
  );
}

MyModal.tsx

import NiceModal, { useModal } from "@ebay/nice-modal-react";
import { Form, Input, InputNumber, Modal } from "antd";
import React, { useState } from "react";

export interface MyModalProps {
  initialValues?: any;
  onSubmit?: (values: any) => Promise<any>;
}

export default NiceModal.create(({ onSubmit, initialValues }: MyModalProps) => {
  const [form] = Form.useForm();
  const modal = useModal();
  const [loading, setLoading] = useState(false);
  return (
    <Modal
      {...NiceModal.antdModal(modal)}
      title="注册用户"
      confirmLoading={loading}
      onOk={async () => {
        const values = await form.validateFields();
        try {
          setLoading(true);
          await onSubmit(values);
          modal.resolve();
          modal.hide();
        } catch (err) {
          modal.reject(err);
        } finally {
          setLoading(false);
        }
      }}
    >
      <Form form={form} initialValues={initialValues}>
        <Form.Item name={"name"} label="姓名" rules={[{ required: true }]}>
          <Input />
        </Form.Item>
        <Form.Item name={"age"} label="年龄">
          <InputNumber />
        </Form.Item>
      </Form>
    </Modal>
  );
});

渲染效果

小结​

Antd 的弹窗功能很多,但是在实际使用时还是需要做不少工作,通过封装 Antd 的弹窗,大大简化了弹窗的处理逻辑,让业务层专注于弹窗的内容逻辑。

本网站是一个以CSS、JavaScript、Vue、HTML为核心的前端开发技术网站。我们致力于为广大前端开发者提供专业、全面、实用的前端开发知识和技术支持。 在本网站中,您可以学习到最新的前端开发技术,了解前端开发的最新趋势和最佳实践。我们提供丰富的教程和案例,让您可以快速掌握前端开发的核心技术和流程。 本网站还提供一系列实用的工具和插件,帮助您更加高效地进行前端开发工作。我们提供的工具和插件都经过精心设计和优化,可以帮助您节省时间和精力,提升开发效率。 除此之外,本网站还拥有一个活跃的社区,您可以在社区中与其他前端开发者交流技术、分享经验、解决问题。我们相信,社区的力量可以帮助您更好地成长和进步。 在本网站中,您可以找到您需要的一切前端开发资源,让您成为一名更加优秀的前端开发者。欢迎您加入我们的大家庭,一起探索前端开发的无限可能!