在 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>
</>
);
};
上面使用方式存在几个问题:
- 弹窗与所属组件的状态混在一起,容易依赖组件内部的状态,导致其他地方不易复用
- 使用时需要关注弹窗的渲染位置,并主动控制弹窗的显示和隐藏细节
- 弹窗内容的渲染时机不好控制(需要额外处理),例如实现仅当弹窗可见时,才动态加载内容
我认为一个好用的弹窗我认为应该具备几个特点:
- 支持命令式调用,类似
window.alert()
这种方式(使用方无需关注弹窗的声明位置) - 支持与弹窗的双向交互,即提供参数控制弹窗的渲染,同时弹窗关闭时能从中获取所需数据(这个在表单场景常见)
- 弹窗的状态与其所在组件树隔离(避免状态污染)
Antd 提供的 Modal.method() 很好的处理了第 1 和 3 个问题,使用起来很简单,只需一行代码即可。
Modal.confirm({
title: '确认?',
content: <MyModalContent>,
onOk () {
// do something
}
})
但是Modal.method()
并未解决第 2 个问题,其内容展示后就与当前上下文脱离联系了,为了实现所期望的弹窗,下面我尝试了几种封装方式,试图解决这个问题。
第一版:基于 ref
下面是我封装的第一版弹窗,弹窗通过暴露 ref 引用,让外部控制弹窗的显示,以及传递数据给弹窗容,通过回调函数将弹窗内部的处理结果传递给外部。
- App
- MyModal
- 效果
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} />
</>
);
}
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
,这样可以大大减少样板代码,聚焦于编写弹窗的内容和交互逻辑。
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
- MyModal
- 效果
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 />
</>
);
}
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
- MyModal
- 效果
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 />
</>
);
}
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
- MyModal
- 效果
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>
);
}
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 的弹窗,大大简化了弹窗的处理逻辑,让业务层专注于弹窗的内容逻辑。