👨‍💻 BitPeng

📰 useModal 弹窗工具封装

创建于 2025-05-21 05:21:55

修改于 2025-05-21 05:21:55

useModal 是一个用于创建和管理 React 弹窗的实用 hook,支持基础弹窗和带出入场动画的弹窗,适合动态挂载组件的场景。


✅ 基础版:简单可控弹窗

适用于无动画需求,手动调用 show/hide 来控制弹窗的挂载与卸载。

useModal.ts

import React from "react";
import { createRoot, type Root } from "react-dom/client";

// 定义控制器类型
type ModalController<P> = {
  show: (props?: Partial<P>) => void;
  hide: () => void;
};

// 基础 useModal 函数
export function useModal<P>(
  Component: React.ComponentType<P & { onClose: () => void }>
): ModalController<P> {
  let root: Root | null = null;
  let container: HTMLDivElement | null = null;

  const show = (props: Partial<P> = {}) => {
    if (container) return;

    container = document.createElement("div");
    document.body.appendChild(container);
    root = createRoot(container);

    const onClose = () => hide();

    root.render(<Component {...(props as P)} onClose={onClose} />);
  };

  const hide = () => {
    if (root && container) {
      root.unmount();
      document.body.removeChild(container);
      root = null;
      container = null;
    }
  };

  return { show, hide };
}

示例弹窗组件 ShareProject.tsx

import { DefaultProjectThumb } from "@/config/defaultAssets";
import type React from "react";
import SvgClose from "@/assets/svgs/closeSmall.svg?react";
import QRCodeLib from "qrcode";
import { useEffect, useRef } from "react";

export type ShareProjectProps = {
  title: string;
  url: string;
  thumb?: string;
};

type Props = {
  onClose: () => void;
} & ShareProjectProps;

const ShareProject: React.FC<Props> = ({ onClose, ...props }) => {
  return (
    <div className="fixed top-0 left-0 h-screen w-screen bg-black_o40 flex flex-col justify-center items-center">
      <div className="w-80 rounded-md overflow-hidden">
        <div className="bg-white w-full py-5 text-center">
          <p className="text-base font-bold">{props.title}</p>
        </div>
      </div>

      <div className="flex items-center justify-center mt-6">
        <button
          onClick={onClose}
          className="bg-black_o40 text-white rounded-full p-0.5"
        >
          <SvgClose width={46} height={46} />
        </button>
      </div>
    </div>
  );
};

export default ShareProject;

使用方式

const Demo = () => {
  const { show } = useModal(ShareProject);

  return (
    <button onClick={() => show({ title: "测试弹窗" })}>
      显示弹窗
    </button>
  );
};

✨ 带出入场动画版(自管理

适用于“单次弹出 + 自动关闭”的弹窗。弹窗关闭后自动卸载,无需外部调用 hide

核心特点

  • 使用 framer-motion 实现出入场动画
  • 内部自行卸载,无需外部干预
import { AnimatePresence, motion } from "framer-motion";
import React, { useState } from "react";
import { createRoot, type Root } from "react-dom/client";

type ModalWrapperProps = {
  Component: React.ComponentType<any>;
  props: Record<string, any>;
  onClose: () => void;
  onExited: () => void;
};

const ModalWrapper = ({ Component, props, onExited }: ModalWrapperProps) => {
  const [visible, setVisible] = useState(true);

  const handleClose = () => {
    setVisible(false);
  };

  return (
    <AnimatePresence onExitComplete={onExited}>
      {visible && (
        <motion.div
          className="fixed inset-0 flex justify-center items-center bg-black/40"
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          onClick={handleClose}
        >
          <motion.div
            initial={{ scale: 0.8, opacity: 0 }}
            animate={{ scale: 1, opacity: 1 }}
            exit={{ scale: 0.8, opacity: 0 }}
            transition={{ duration: 0.2 }}
            onClick={(e) => e.stopPropagation()}
          >
            <Component {...props} onClose={handleClose} />
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  );
};

export function useModal<T extends object>(
  Component: React.ComponentType<T & { onClose: () => void }>
) {
  let root: Root | null = null;
  let container: HTMLDivElement | null = null;

  const handleUnmount = () => {
    if (root && container) {
      root.unmount();
      document.body.removeChild(container);
      root = null;
      container = null;
    }
  };

  const show = (props: Partial<T> = {}) => {
    if (container) return;

    container = document.createElement("div");
    document.body.appendChild(container);
    root = createRoot(container);

    root.render(
      <ModalWrapper
        Component={Component}
        props={props}
        onClose={handleUnmount}
        onExited={handleUnmount}
      />
    );
  };

  return { show };
}

🎛️ 带出入场动画版(可外部关闭

适合需要在外部控制弹窗关闭的场景,例如定时关闭、事件驱动等。

import { AnimatePresence, motion } from "framer-motion";
import React, { useState } from "react";
import { createRoot, type Root } from "react-dom/client";

type ModalWrapperProps = {
  Component: React.ComponentType<any>;
  props: Record<string, any>;
  onExited: () => void;
  closeRef: { current: () => void };
};

const ModalWrapper = ({
  Component,
  props,
  onExited,
  closeRef,
}: ModalWrapperProps) => {
  const [visible, setVisible] = useState(true);

  const handleClose = () => {
    setVisible(false);
  };

  closeRef.current = handleClose;

  return (
    <AnimatePresence onExitComplete={onExited}>
      {visible && (
        <motion.div
          className="fixed inset-0 flex justify-center items-center bg-black/40"
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          onClick={handleClose}
        >
          <motion.div
            initial={{ scale: 0.8, opacity: 0 }}
            animate={{ scale: 1, opacity: 1 }}
            exit={{ scale: 0.8, opacity: 0 }}
            transition={{ duration: 0.2 }}
            onClick={(e) => e.stopPropagation()}
          >
            <Component {...props} onClose={handleClose} />
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  );
};

export function useModal<T extends object>(
  Component: React.ComponentType<T & { onClose: () => void }>
) {
  let root: Root | null = null;
  let container: HTMLDivElement | null = null;

  const closeRef = { current: () => {} };

  const unmount = () => {
    if (root && container) {
      root.unmount();
      document.body.removeChild(container);
      root = null;
      container = null;
      closeRef.current = () => {};
    }
  };

  const show = (props: Partial<T> = {}) => {
    if (container) return;

    container = document.createElement("div");
    document.body.appendChild(container);
    root = createRoot(container);

    root.render(
      <ModalWrapper
        Component={Component}
        props={props}
        onExited={unmount}
        closeRef={closeRef}
      />
    );
  };

  const hide = () => {
    closeRef.current();
  };

  return { show, hide };
}

📝 总结

版本是否支持动画是否支持外部关闭适用场景
基础版快速简单的弹窗
动画版(自管理)自动关闭的提示类弹窗
动画版(可控制)可由外部触发关闭的弹窗