Skip to main content

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

· 6 min read

在 Ant Design(简称 Antd) 组件库中弹窗的使用频率很高,Antd 提供的Modal组件一般用法如下:

import React, { useState } from 'react';
import { Button, Modal } from 'antd';

const App: React.FC = () => {
const [isModalOpen, setIsModalOpen] = useState(false);

const showModal = () => {
setIsModalOpen(true);
};

const handleOk = () => {
setIsModalOpen(false);
};

const handleCancel = () => {
setIsModalOpen(false);
};

return (
<>
<Button type="primary" onClick={showModal}>
Open Modal
</Button>
<Modal title="Basic Modal" open={isModalOpen} onOk={handleOk} onCancel={handleCancel}>
<p>Some contents...</p>
<p>Some contents...</p>
<p>Some contents...</p>
</Modal>
</>
);
};

上面使用方式存在几个问题:

  1. 弹窗与所属组件的状态混在一起,容易依赖组件内部的状态,导致其他地方不易复用
  2. 使用时需要关注弹窗的渲染位置,并主动控制弹窗的显示和隐藏细节
  3. 弹窗内容的渲染时机不好控制(需要额外处理),例如实现仅当弹窗可见时,才动态加载内容

我认为一个好用的弹窗我认为应该具备几个特点:

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

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

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

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

第一版:基于 ref

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

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

第二版:基于 ref 进行抽象

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

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()显示弹窗和传参。

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

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

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

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

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

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

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

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

小结

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