前言
所接手的项目是一个后台管理系统,频繁使用的弹窗是永远避不开的一个话题,在使用弹窗的历程中也经历了多次改变,总的来说就是由声明式弹窗转变为命令式弹窗。
声明式弹窗与命令式弹窗
以Antd5为例,在Antd5的Modal组件示例中使用的是声明式弹窗,通常是声明一个变量visible,用于控制弹窗的显示与隐藏,这个变量由调用方进行管理和更新。但在应用程序中频繁地改变这个变量的状态,可能会导致性能问题。每次更新visible变量时,框架或库都会触发重新渲染组件,并且可能会执行一系列与渲染相关的操作,每次重新渲染都需要创建新的虚拟 DOM 或组件实例,并且旧实例可能无法及时被垃圾回收。这可能导致内存占用增加,并且在长时间运行时可能出现内存泄漏问题。除此之外,对于编程人员来说,如果一个父组件需要调用多个弹窗,我们需要使用useState管理每个弹窗的显示与隐藏,而生成的变量也仅仅是用于控制弹窗的显示与隐藏。这不仅给编程带来了心智负担,还让代码变得非常冗余。但同样在Antd中有一个message组件,它是一个命令式的组件,调用即渲染,即用即调,在使用时仅仅需要调用message的各API就可以显示信息。
在Antd5中也提供了命令式弹窗的写法,如下:
function testModal() {
const [modal, contextHolder] = Modal.useModal();
return (
<>
<Button
onClick={async () => {
const confirmed = await modal.confirm('xxx');
}}
>
Confirm
</Button>
{contextHolder}
</>
);
}
在这个示例中不需要外部变量控制显示与隐藏,我们只需要调用modal方法就能进行状态控制了。当然在Antd v5版本下推荐Modal.method搭配App.useApp使用,message同理。即:
const { modal, message } = App.useApp()
但上述的命令式Modal示例中定义比较简单,不适合承载复杂的业务逻辑,一般只用于对话框提示。
项目中对于声明式弹窗的使用
在刚接手这个项目时,对于弹窗的调用都是这样的: 将业务逻辑复杂的弹窗抽为一个组件,在父组件中调用子组件定义的show方法,示例如下:
// 子组件
import React, { useEffect, useState } from "react";
import { Button, Modal } from "antd";
const callbackModal: any = React.memo(() => {
const [visible, setVisible] = useState(false);
useEffect(() => {
const lastShow = callbackModal.show;
callbackModal.show = () => {
setVisible(true);
return () => {
callbackModal.show = lastShow;
};
};
}, []);
const close = () => {
setVisible(false);
};
return (
<Modal
title="callbackModal"
open={visible}
onOk={close}
onCancel={close}
afterClose={close}
footer={[
<Button type="primary" onClick={close} key="close">
知道了
</Button>,
]}
>
<h1>callbackModal示例</h1>
</Modal>
) as any;
});
export default callbackModal;
// 父组件
import React, { useRef } from "react";
import { Button } from "antd";
import CallbackModal from './components/callbackModal'
export default function Router1() {
return (
<div>
<div>
<h1>callbackModal实践</h1>
<Button onClick={() => CallbackModal.show({})} type="primary">
打开callbackModal
</Button>
</div>
<CallbackModal/>
</div>
);
}
这种方法后来被废弃了,主要原因是当弹窗子组件中修改代码时,Vite的热更新会失效,在父组件中调用show方法无法打开弹窗并会在控制台报错:XXX.show is not a function
后面基于这个原因通过forwardRef和useImperativeHandle对声明式弹窗进行了优化以方便调试,示例如下:
// 子组件
import React, { useImperativeHandle, useState } from "react";
import { Button, Modal } from "antd";
const refModal = React.forwardRef((_props, ref) => {
const [visible, setVisible] = useState(false);
useImperativeHandle(ref, () => ({
show: () => {
setVisible(true);
},
}));
const close = () => {
setVisible(false);
};
return (
<Modal
title="refModal"
open={visible}
onOk={close}
onCancel={close}
afterClose={close}
footer={[
<Button type="primary" onClick={close} key="close">
知道了
</Button>,
]}
>
<h1>refModal示例</h1>
</Modal>
) as any;
});
export default refModal;
// 父组件
import React, { useRef } from "react";
import { Button } from "antd";
import RefModal from "./components/refModal";
export default function Router1() {
const refModalRef = useRef<any>(null);
function openRefModal() {
refModalRef.current?.show({})
}
return (
<div>
<div>
<h1>modalRef实践</h1>
<Button onClick={openRefModal} type="primary">
打开refModal
</Button>
</div>
<RefModal ref={refModalRef} />
</div>
);
}
React.forwardRef它会创建一个React组件,这个组件能够将其接受的 ref 属性转发到其组件树下的另一个组件中, 当 React 附加了 ref 属性之后,ref.current 将直接指向 DOM 元素实例。useImperativeHandle 可以让你在使用 ref 时自定义暴露给父组件的实例值。通常与forwardRef一起使用,暴露之后父组件就可以通过selectFileModalRef.current?.xxx();来调用子组件的暴露方法。
通过这种方式解决了在弹窗调试时带来的热更新问题,这种声明式控制弹窗的方式在项目中持续了挺长时间,体验感还不错,但也有一定问题,比如说存在嵌套弹窗时:
- 弹窗层级问题:当使用声明式弹窗时,每个弹窗都依赖于一个状态变量来控制显示和隐藏。如果在一个弹窗中打开另一个弹窗,可能会导致层级混乱或无法正确管理多个弹窗的显示状态。
- 状态管理复杂性:使用声明式弹窗时,需要手动管理每个弹窗的状态变量,并确保它们在正确的时间进行更新。当存在多个嵌套的弹窗时,状态管理会变得更加复杂和容易出错。
- 弹窗交互逻辑问题:在某些情况下,嵌套的弹窗可能需要进行交互或传递数据。使用声明式弹窗时,处理这种交互逻辑可能会变得困难,并且需要额外的代码来处理数据传递和事件处理。
命令式弹窗的尝试
在leader的推荐下尝试了一个新的第三方库:@ebay/nice-modal-react 主要用于实现模态对话框(Modal)功能。说实话在React中写命令式并不符合React官方推崇的声明式风格,声明式的方式也更容易理解和维护,但既然了解到了就应该尝试一下,毕竟可以不用写那么多useState了。
介绍
@ebay/nice-modal-react使用 react context 来全局保存 Modal 状态,以便可以通过 Modal 组件或 id 轻松显示/隐藏 Modal,可以与任意UI库一起使用(如Antd)
@ebay/nice-modal-react官方地址
安装
pnpm install @ebay/nice-modal-react
基本使用
1、在使用NiceModal之前必须使用Provider包裹组件,这是必须的一步,否则创建的Modal也会无效
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { App as AntdApp } from "antd";
import NiceModal from "@ebay/nice-modal-react";
import "@/styles/index.css";
import 'uno.css'
ReactDOM.createRoot(document.getElementById("root")!).render(
<AntdApp>
<NiceModal.Provider> // 包裹App组件
<App />
</NiceModal.Provider>
</AntdApp>
);
2、创建一个NiceModal
import NiceModal, { useModal } from "@ebay/nice-modal-react";
import { Modal, Button } from "antd";
export default NiceModal.create(() => {
const modal = useModal();
return (
<Modal
title="niceModal"
open={modal.visible}
onCancel={() => modal.hide()}
afterClose={() => modal.remove()}
footer={[
<Button type="primary" key="ok" onClick={wrapWithClose}>
知道了
</Button>,
]}
>
<h1>这是一个niceModal示例</h1>
</Modal>
);
});
3、在父组件调用Modal的显示与隐藏
import React, { useRef } from "react";
import { Button } from "antd";
import NiceModal, { useModal } from "@ebay/nice-modal-react";
import niceModal from "./components/niceModal";
export default function Router1() {
// const niceModalRef = useModal(niceModal);
function openNiceModal() {
NiceModal.show(niceModal)
}
// function openNiceModal() {
niceModalRef.current?.show({})
}
return (
<div>
<h1>@ebay/nice-modal-react实践</h1>
<Button onClick={openNiceModal} type="primary">
打开niceModal
</Button>
</div>
);
}
在父组件中有两种方式可以控制Modal的显示,一种是调用NiceModal的show方法,传入子组件niceModal;另外一种是通过它提供的useModal钩子创建ref,通过niceModalRef.current?.show()显示弹窗。
4、传参
简单的开关弹窗已经实现了,在日常的开发中更多时候需要向弹窗中传参实现一些业务逻辑,要实现niceModal的传参也比较简单,只需要在NiceModal.create方法中传递参数即可
// 父组件
import React, { useRef } from "react";
import { Button } from "antd";
import NiceModal, { useModal } from "@ebay/nice-modal-react";
import MyniceModal from "./components/niceModal";
export default function Router1() {
const obj = {
name: 'syx',
age: 25,
text: '测试一下'
}
function openNiceModal() {
NiceModal.show(MyniceModal, {obj}) // 传递参数
}
return (
<div>
<h1>@ebay/nice-modal-react实践</h1>
<Button onClick={openNiceModal} type="primary">
打开niceModal
</Button>
</div>
);
}
// 子组件
import NiceModal, { useModal } from "@ebay/nice-modal-react";
import { Modal, Button } from "antd";
// 在create方法中接收参数
export default NiceModal.create((props: any) => {
const modal = useModal();
return (
<Modal
title="niceModal"
open={modal.visible}
onCancel={() => modal.hide()}
afterClose={() => modal.remove()}
footer={[
<Button type="primary" key="ok" onClick={wrapWithClose}>
知道了
</Button>,
]}
>
<h1>这是一个niceModal示例</h1>
<p>{props.obj.name}</p>
</Modal>
);
});
5、 Use the modal by id
除了常见的通过组件引入调用modal的方式外,niceModal还提供了通过modal的id属性来控制弹窗,使用形式与组件用法类似,只需要多一步注册的步骤
//父组件
import MyniceModal from "./components/niceModal";
NiceModal.register('my-antd-modal', MyAntdModal)
// 调用show方法
NiceModal.show('my-antd-modal', {obj})
对于这种通过id注册modal的形式,官方推荐单独维护一个modals文件,在这个文件中注册所有的modal,在使用时只需要引入这个modals文件就可以控制其中所有注册的modal了。个人还是喜欢在对应的功能板块中的components文件夹放置modal组件,然后再通过组件引入的方式进行控制。毕竟在同一个文件中注册id很容易出现重复id,给id起名也是一件头疼的事。
Normally you create a modals.js file in your project and register all modals there.