一直以来都在使用的是 UI 组件库的 Modal,最近一段时间都是在使用 headlessui 这个组件的 dialog 组件实现 Modal 和 Drawer 类的组件,但在使用过程中发现了两个严重的问题:
- 无法实现类似 clickOutSide 的功能,因为 dialog 会将 click 等事件阻止冒泡,导致 document 中监听不到,这样的话类似 Dropdown 这种组件点击外包关闭弹出层就无法实现,并且官方认为这不是一个问题不需要解决,这是相关 issue
- Dialog 在打开时,会为 html 增加
overflow: hidden; padding-right: 15px;目的应该是减少滚动条消失时,页面产生抖动的问题,但实际体验下来,抖动问题依然明细, 反而是去除 padding-right 抖动会减轻很多
关于第一个问题我最初的解决办法是给 DialogPanel 加一个特定的 class,监听其 click 事件,然后再手动触发 clickOutSide 的相关逻辑,代码写的就太脏了
由于组件库马上需要投入使用了,遂下定决心优化这部分代码,并且动画库用 frame-motion 代替 headlessui 的 Transition 组件
实现目标
- 开发一个受控组件提供 open,onClose 等基础属性
- 额外实现类似 antd
Modal.confirm({ ... })静态方式的调用 - 样式允许用户完全自定义组合,并提供一套默认样式的 Header Footer
效果展示
实现思路:
Modal 受控组件
- 利用 createPortal 将 Modal 容器挂载 body 上,然后给容器增加一些 fixed 样式,背景蒙层样式
- 利用 framer-motion 为 Modal 进入和离开提供动画
- 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 获取组件实例做一些操作,或者弹框的第一个输入的元素打开时自动聚焦等等