Modal的使用——由声明式弹窗向命令式弹窗的转变

872 阅读7分钟

前言

所接手的项目是一个后台管理系统,频繁使用的弹窗是永远避不开的一个话题,在使用弹窗的历程中也经历了多次改变,总的来说就是由声明式弹窗转变为命令式弹窗。

声明式弹窗与命令式弹窗

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

后面基于这个原因通过forwardRefuseImperativeHandle对声明式弹窗进行了优化以方便调试,示例如下:

// 子组件
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();来调用子组件的暴露方法。

通过这种方式解决了在弹窗调试时带来的热更新问题,这种声明式控制弹窗的方式在项目中持续了挺长时间,体验感还不错,但也有一定问题,比如说存在嵌套弹窗时:

  1. 弹窗层级问题:当使用声明式弹窗时,每个弹窗都依赖于一个状态变量来控制显示和隐藏。如果在一个弹窗中打开另一个弹窗,可能会导致层级混乱或无法正确管理多个弹窗的显示状态。
  2. 状态管理复杂性:使用声明式弹窗时,需要手动管理每个弹窗的状态变量,并确保它们在正确的时间进行更新。当存在多个嵌套的弹窗时,状态管理会变得更加复杂和容易出错。
  3. 弹窗交互逻辑问题:在某些情况下,嵌套的弹窗可能需要进行交互或传递数据。使用声明式弹窗时,处理这种交互逻辑可能会变得困难,并且需要额外的代码来处理数据传递和事件处理。

命令式弹窗的尝试

在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的显示,一种是调用NiceModalshow方法,传入子组件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.