React 实现一个 Modal 组件

252 阅读3分钟

一直以来都在使用的是 UI 组件库的 Modal,最近一段时间都是在使用 headlessui 这个组件的 dialog 组件实现 Modal 和 Drawer 类的组件,但在使用过程中发现了两个严重的问题:

  1. 无法实现类似 clickOutSide 的功能,因为 dialog 会将 click 等事件阻止冒泡,导致 document 中监听不到,这样的话类似 Dropdown 这种组件点击外包关闭弹出层就无法实现,并且官方认为这不是一个问题不需要解决,这是相关 issue
  2. Dialog 在打开时,会为 html 增加 overflow: hidden; padding-right: 15px; 目的应该是减少滚动条消失时,页面产生抖动的问题,但实际体验下来,抖动问题依然明细, 反而是去除 padding-right 抖动会减轻很多

关于第一个问题我最初的解决办法是给 DialogPanel 加一个特定的 class,监听其 click 事件,然后再手动触发 clickOutSide 的相关逻辑,代码写的就太脏了


由于组件库马上需要投入使用了,遂下定决心优化这部分代码,并且动画库用 frame-motion 代替 headlessui 的 Transition 组件

实现目标

  1. 开发一个受控组件提供 open,onClose 等基础属性
  2. 额外实现类似 antd Modal.confirm({ ... }) 静态方式的调用
  3. 样式允许用户完全自定义组合,并提供一套默认样式的 Header Footer

效果展示

modal.gif

drawer.gif

实现思路:

Modal 受控组件

  1. 利用 createPortal 将 Modal 容器挂载 body 上,然后给容器增加一些 fixed 样式,背景蒙层样式
  2. 利用 framer-motion 为 Modal 进入和离开提供动画
  3. modal 核心代码仅提供基础功能,具体 Header Footer 通过 children 传进来,open/onClose 可通过 context 获取,因为 Modal 外层会包一层 Modal.Provider 状态都会挂在上面,所以 header footer能通过 useContext 获取到这些状态

代码简要示意如下 完整代码

export default function Modal({
  open,
  maskClosable = true,
  className,
  onClose,
  children,
}: TModalCoreProp) {
  return (
    <ModalContext.Provider value={{ onClose }}>
      <ModalCore maskClosable={maskClosable} open={open} onClose={onClose}>
        {children}
      </ModalCore>
    </ModalContext.Provider>
  );
}

// ModalCore
export default function ModalCore({open, maskClosable = true, onClose, children}: TModalCoreProp) {
  useEffect(() => {
    if (open) {
      document.body.style.overflow = 'hidden';
    } else {
      document.body.style.overflow = 'auto';
    }
  }, [open]);

  return createPortal(
    <AnimatePresence>
      {open && (
        <motion.div
          initial={{ opacity: 0 }}
          animate={{ opacity: 1, backdropFilter: 'blur(10px)' }}
          exit={{ opacity: 0, backdropFilter: 'blur(0px)' }}
          className='fixed inset-0 z-50'
        >
          <motion.div
            initial={{ opacity: 0 }}
            animate={{ opacity: 1, backdropFilter: 'blur(10px)' }}
            exit={{ opacity: 0, backdropFilter: 'blur(0px)' }}
            className="bg-mask"
            onClick={maskClosable ? onClose : () => {}}
          />
          <motion.div
            initial={{ opacity: 0, scale: 0.5, y: 40 }}
            animate={{ opacity: 1, scale: 1, y: 0 }}
            exit={{ opacity: 0, scale: 0.8 }}
          >
            <div className="h-fit mt-20 fixed inset-0 flex w-screen justify-center p-4">
              {children}
            </div>
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>,
    document.body,
  );
}

Modal 静态方法

将上一步实现好的 Modal 组件利用 createRoot(dom).render(Modal) 挂载到非 Root 另一个 dom 节点上,关闭时的动画需要特殊处理一下,要用 setTimeout 在动画结束后移除刚才挂载的节点

代码简要示意如下,完整代码

export default function open(
  component: (show: boolean, onClose: () => void) => ReactElement,
) {
  const domNode = document.createElement('div');
  domNode.classList.add('static-dialog-container');
  document.body.appendChild(domNode);
  const root = createRoot(domNode);

  const close = () => {
    setTimeout(() => {
      root.unmount();
      domNode.remove();
    }, 300);
  };

  const render = (show = true) => root.render(component(show, close));

  render();
  return { destroy: () => render(false) };
}

Drawer 代码原理其实是一样的,只不过样式和动画略要不同,详细代码

后续优化:当然现阶段这个 Modal 组件只实现最基础最重要的功能,后续如果有些定制化的功能可以随时往里面添加,比如:利用 ref 获取组件实例做一些操作,或者弹框的第一个输入的元素打开时自动聚焦等等