前端通用组件开发笔记 - Drawer 抽屉、Modal 弹窗
本文详解如何完整创造出功能较为齐全的 Drawer 抽屉组件、Modal 弹窗组件
组件开发采用 React 框架,组件功能参考 抽屉 Drawer - Ant Design、对话框 Modal - Ant Design
Drawer 抽屉组件
目标
通过声明式调用方式生成 Drawer 抽屉,可以从四个位置方向弹出,并且抽屉显示和隐藏播放动画,可自定义标题、内容等,抽屉关闭有回调
1. 规定参数及其类型、含义
type DrawerOptionsT = {
visible?: boolean; // 抽屉是否显示
position?: 'bottom' | 'top' | 'left' | 'right'; // 指定弹出位置
title?: React.ReactNode; // 标题
children?: React.ReactNode; // 内容
closable?: boolean; // 是否显示右上角的关闭按钮
closeIcon?: React.ReactNode; // 自定义关闭图标
mask?: boolean; // 是否展示遮罩
maskClosable?: boolean; // 是否允许背景点击
destroyOnClose?: boolean; // 抽屉不可见时卸载内容
maskClassName?: string; // 遮罩类名,不会覆盖组件原本样式,加 !important 可覆盖
maskStyle?: React.CSSProperties; // 遮罩样式,会覆盖组件原本样式
bodyClassName?: string; // 内容区域类名,不会覆盖组件原本样式,加 !important 可覆盖
bodyStyle?: React.CSSProperties; // 内容区域样式,会覆盖组件原本样式
onClose?: () => void; // 关闭时触发
};
2. 难点1:如何在抽屉关闭后不卸载内容
drawer.tsx
/** 默认配置 */
const defaultOptions = {
visible: false,
position: 'bottom',
closable: false,
mask: true,
maskClosable: true,
destroyOnClose: false,
}
/** 全局配置 */
const configOptions = {};
/** 动画时间,单位(ms) */
const animationTime = 300;
/** 淡入动画效果 */
const appearAnimation = [
{ opacity: 0 },
{ opacity: 1 }
];
/** 淡出动画效果 */
const disappearAnimation = [
{ opacity: 1 },
{ opacity: 0 }
];
const Drawer: React.FC<DrawerOptionsT> = (props) => {
const [firstLoaded, setFirstLoaded] = useState(false); // 是否第一次加载了
const drawerMaskRef = useRef(null);
useEffect(() => {
if (!drawerMaskRef.current) return;
const maskDom = drawerMaskRef.current as HTMLDivElement;
if (options.visible) {
maskDom.style.visibility = 'inherit'; // 抽屉显示
} else {
maskDom.style.visibility = 'hidden'; // 抽屉隐藏
if (options.destroyOnClose) {
maskDom.remove(); // 抽屉不可见时卸载内容
}
}
}, [options.visible]);
useEffect(() => {
if (!firstLoaded && options.visible) {
setFirstLoaded(true);
}
}, [options.visible])
if (!firstLoaded && !options.visible) {
return null;
}
return (
<div
ref={drawerMaskRef}
className={classNames(css.mask, options.maskClassName)}
style={{
...options.mask ? {} : { background: 'transparent' },
...options.maskStyle,
}}
onClick={() => { options.maskClosable ? options.onClose?.() : () => { } }}
>
<div
className={classNames(css.drawer, options.bodyClassName)}
style={{ ...options.bodyStyle }}
onClick={(e) => { e.stopPropagation() }}
>
...
</div>
</div>
)
};
外部传入参数,通过改变 visible
的值来控制抽屉的显示与隐藏,drawer.tsx 中使用 useEffect
监听 visible
值的变化。为了保证抽屉关闭时不卸载内容,所以通过改变抽屉 dom 节点的 CSS 属性 visibility
来控制抽屉显示隐藏。
但是这样页面首次加载时会加载抽屉内容, 所以需要加一个变量 firstLoaded
来辅助标记是否首次加载,如果是首次加载则函数组件 Drawer 返回 null。
如果传入参数 destroyOnClose
为 true(抽屉关闭时卸载内容),则使用 Element.remove() 方法将抽屉 dom 节点卸载
drawer.less
.mask {
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
background: var(--mask-background);
}
.drawer {
background: #fff;
pointer-events: auto;
position: absolute;
&-top-line {
width: 100%;
height: var(--top-line-height);
padding: 0 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #eee;
position: relative;
}
&-title {
width: 100%;
color: #000;
font-size: 20px;
font-weight: bold;
}
&-close-icon-box {
width: 30px;
height: 30px;
cursor: pointer;
}
&-close-icon {
width: 30px;
height: 30px;
}
&-content {
padding: 20px;
height: calc(100% - var(--top-line-height));
}
&-custom-content {
height: 100%;
overflow-y: auto;
white-space: pre-wrap;
line-height: 1.5;
}
}
global.less
:root {
--animation-duration: 300ms; // 动画时长
--drawer-default-wh: 378px; // 抽屉默认宽高
}
3. 难点2:如何控制四个位置的显示
只需要通过改变 CSS 类名来控制位置的显示
drawer.tsx
<div
className={
classNames(
css.drawer,
css[`drawer-${options.position}`], // 用传入的参数 position 来做控制
options.bodyClassName
)
}
style={{ ...options.bodyStyle }}
onClick={(e) => { e.stopPropagation() }}
>
...
</div>
drawer.less
.drawer-bottom {
width: 100%;
height: var(--drawer-default-wh);
inset: auto auto 0px;
max-height: 80vh; // 设置最大高度
}
.drawer-top {
width: 100%;
height: var(--drawer-default-wh);
inset: 0px auto auto;
max-height: 80vh; // 设置最大高度
}
.drawer-left {
width: var(--drawer-default-wh);
height: 100%;
inset: auto auto auto 0px;
max-width: 80vw; // 设置最大宽度
}
.drawer-right {
width: var(--drawer-default-wh);
height: 100%;
inset: auto 0px auto auto;
max-width: 80vw; // 设置最大宽度
}
4. 难点3:如何针对四个位置播放不同的动画
也是通过改变 CSS 类名来控制动画的播放
drawer.tsx
const [action, setAction] = useState<'appear' | 'disappear'>('appear');
useEffect(() => {
if (options.visible) {
setAction('appear');
} else {
setAction('disappear');
}
}, [options.visible]);
<div
className={
classNames(
css.drawer,
css[`drawer-${options.position}`],
css[`drawer-${options.position}-${action}`], // 用参数 position 配合 action 变量
options.bodyClassName
)
}
style={{ ...options.bodyStyle }}
onClick={(e) => { e.stopPropagation() }}
>
...
</div>
drawer.less
@keyframes bottom-appear {
from { bottom: calc(var(--drawer-default-wh) * -1); }
to { bottom: 0; }
}
@keyframes bottom-disappear {
from { bottom: 0; }
to { bottom: calc(var(--drawer-default-wh) * -1); }
}
@keyframes top-appear {
from { top: calc(var(--drawer-default-wh) * -1); }
to { top: 0; }
}
@keyframes top-disappear {
from { top: 0; }
to { top: calc(var(--drawer-default-wh) * -1); }
}
@keyframes left-appear {
from { left: calc(var(--drawer-default-wh) * -1); }
to { left: 0; }
}
@keyframes left-disappear {
from { left: 0; }
to { left: calc(var(--drawer-default-wh) * -1); }
}
@keyframes right-appear {
from { right: calc(var(--drawer-default-wh) * -1); }
to { right: 0; }
}
@keyframes right-disappear {
from { right: 0; }
to { right: calc(var(--drawer-default-wh) * -1); }
}
.drawer-bottom {
...
&-appear { animation: bottom-appear var(--animation-duration); }
&-disappear { animation: bottom-disappear var(--animation-duration); }
}
.drawer-top {
...
&-appear { animation: top-appear var(--animation-duration); }
&-disappear { animation: top-disappear var(--animation-duration); }
}
.drawer-left {
...
&-appear { animation: left-appear var(--animation-duration); }
&-disappear { animation: left-disappear var(--animation-duration); }
}
.drawer-right {
...
&-appear { animation: right-appear var(--animation-duration); }
&-disappear { animation: right-disappear var(--animation-duration); }
}
5. 最终完整代码
drawer.tsx
const Drawer: React.FC<DrawerOptionsT> = (props) => {
const [firstLoaded, setFirstLoaded] = useState(false); // 是否第一次加载了
const [action, setAction] = useState<'appear' | 'disappear'>('appear');
const options: DrawerOptionsT = mergeOptions(defaultOptions, configOptions, props);
const drawerMaskRef = useRef(null);
useEffect(() => {
if (!drawerMaskRef.current) return;
const maskDom = drawerMaskRef.current as HTMLDivElement;
if (options.visible) {
setAction('appear');
document.body.style.overflow = 'hidden';
maskDom.style.visibility = 'inherit';
maskDom.animate(appearAnimation, animationTime);
} else {
setAction('disappear');
maskDom.animate(disappearAnimation, animationTime).onfinish = () => {
document.body.style.overflow = 'auto';
maskDom.style.visibility = 'hidden';
if (options.destroyOnClose) {
maskDom.remove(); // 抽屉不可见时卸载内容
}
};
}
}, [options.visible]);
useEffect(() => {
if (!firstLoaded && options.visible) {
setFirstLoaded(true);
}
}, [options.visible])
if (!firstLoaded && !options.visible) {
return null;
}
return (
<div
ref={drawerMaskRef}
className={classNames(css.mask, options.maskClassName)}
style={{
...options.mask ? {} : { background: 'transparent' },
...options.maskStyle,
}}
onClick={() => { options.maskClosable ? options.onClose?.() : () => { } }}
>
<div
className={
classNames(
css.drawer,
css[`drawer-${options.position}`],
css[`drawer-${options.position}-${action}`],
options.bodyClassName
)
}
style={{
// @ts-ignore
'--top-line-height': (props.title || props.closable) ? '50px' : '0px',
...options.bodyStyle,
}}
onClick={(e) => { e.stopPropagation() }}
>
{/* 标题栏 */}
<div
className={css['drawer-top-line']}
style={{ borderWidth: (props.title || props.closable) ? '1px' : '0px', }}
>
{/* 标题栏 */}
<div className={css['drawer-title']}>
{options.title}
</div>
{/* 右上角关闭按钮 */}
{options.closable && (
options.closeIcon ? options.closeIcon : (
<div className={css['drawer-close-icon-box']} onClick={() => { options.onClose?.() }}>
<img className={css['drawer-close-icon']} src={closeIcon} />
</div>
)
)}
</div>
{/* 内容 */}
<div className={css['drawer-content']}>
<div className={css['drawer-custom-content']}>{options.children}</div>
</div>
</div>
</div>
)
};
export default Drawer;
6. 使用
import React from 'react'
import Drawer from '@/components/drawer/drawer';
export default () => {
const [showModal, setShowModal] = useState(false);
return (
<>
<div onClick={() => { setShowDrawer(true) }}>底部弹出</div>
<Drawer
visible={showDrawer}
position={'bottom'}
onClose={() => { setShowDrawer(false) }}
>
{...}
</Drawer>
</>
)
}
Modal 弹窗组件
目标
通过声明式、指令式两种调用方式生成 Modal 弹窗,并且弹窗显示和隐藏带有动画,可自定义标题、底部按钮等,点击底部按钮和弹窗关闭有回调
1. 规定参数及其类型
声明式调用对应参数
type ModalOptionsT = {
visible?: boolean; // 弹窗是否显示
children?: React.ReactNode; // 弹窗内容
destroyOnClose?: boolean; // 弹窗不可见时卸载内容
/** 以下参数与【指令式调用】相同 */
title?: React.ReactNode; // 标题
titleCentered?: boolean; // 标题是否居中
okText?: string; // 确认按钮文字
confirmLoading?: boolean; // 确认按钮 loading
cancelText?: string; // 取消按钮文字
footer?: React.ReactNode; // 底部按钮区域,当不需要默认底部按钮时,可以将 footer 设为 null
footerCentered?: boolean; // 底部按钮是否居中,默认居右
closable?: boolean; // 是否显示右上角的关闭按钮
closeIcon?: React.ReactNode; // 自定义关闭图标
mask?: boolean; // 是否展示遮罩
maskClosable?: boolean; // 是否允许背景点击
width?: number | string; // 弹窗宽度
onOk?: () => void; // 点击确定回调
onCancel?: () => void; // 点击遮罩层或右上角叉或取消按钮的回调
onClose?: () => void; // 关闭时触发
};
指令式调用对应参数
type ModalFuncOptionsT = {
content?: React.ReactNode; // 弹窗内容
// 其余参数与【声明式调用】相同
};
值得注意的是:
- 声明式调用使用
visible
来控制弹窗显示隐藏;指令式调用通过show
方法打开弹窗- 声明式调用下使用
children
来写弹窗内容;指令式调用使用content
来写弹窗内容- 声明式调用下
destroyOnClose
默认值为 false,弹窗不可见时不会卸载内容;而指令式调用会自动销毁弹窗
2. 内部组件 InternalModal 及其样式
/** 内部 Modal 组件 */
const InternalModal: React.FC<ModalOptionsT | ModalFuncOptionsT> = (props) => {
let content: React.ReactNode;
if ('children' in props) content = props.children;
if ('content' in props) content = props.content;
return (
<div
className={css.mask}
style={props.mask ? {} : { background: 'transparent' }}
onClick={() => { props.maskClosable ? props.onCancel?.() : () => { } }}
>
<div
className={css.modal}
style={
props.width ?
{ width: typeof props.width === 'number' ? `${props.width}px` : props.width }
:
{}
}
onClick={(e) => { e.stopPropagation() // 阻止点击穿透 }}
>
{/* 标题 */}
{props.title && (
<div
className={css['modal-title']}
style={{ textAlign: props.titleCentered ? 'center' : 'left' }}
>{props.title}</div>
)}
{/* 右上角关闭按钮 */}
{props.closable && (
props.closeIcon ? props.closeIcon : (
<div className={css['modal-close-icon-box']} onClick={() => { props.onClose?.() }}>
<img className={css['modal-close-icon']} src={closeIcon} />
</div>
)
)}
{/* 内容 */}
<div className={css['modal-content']}>
{content}
</div>
{/* 底部内容 */}
<div
className={css['modal-footer']}
style={{ justifyContent: props.footerCentered ? 'center' : 'flex-end' }}
>
{props.footer === undefined ? (
<>
<div
className={classNames(css['modal-footer-cancel-button'], 'button')}
onClick={() => { props.onCancel?.() }}
>
{props.cancelText}
</div>
{props.confirmLoading ? (
<div
className={css['modal-footer-ok-button'], css['modal-footer-ok-button-disabled']}
>
<LoadingIcon className={css['modal-footer-ok-button-loading']} size={20} />
<span>{props.okText}</span>
</div>
) : (
<div
className={css['modal-footer-ok-button']}
onClick={() => { props.onOk?.() }}
>
<span>{props.okText}</span>
</div>
)}
</>
) : props.footer}
</div>
</div>
</div>
)
}
Modal.less
.mask {
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
display: flex;
justify-content: center;
align-items: center;
background: var(--mask-background);
}
.modal {
background: #fff;
border-radius: 6px;
padding: 20px;
width: 80vw; // 设置最小宽度
max-width: 800px; // 设置最大宽度
pointer-events: auto;
position: relative;
&-title {
color: #000;
font-size: 20px;
font-weight: bold;
}
&-close-icon-box {
width: 30px;
height: 30px;
position: absolute;
top: 10px;
right: 10px;
cursor: pointer;
}
&-close-icon {
width: 30px;
height: 30px;
}
&-content {
margin: 20px 0;
max-height: 50vh;
overflow-y: auto;
white-space: pre-wrap;
line-height: 1.5;
}
&-footer {
display: flex;
&-ok-button {
margin-left: 10px;
display: flex;
align-items: center;
}
&-ok-button-disabled {
cursor: not-allowed;
opacity: 0.8;
}
&-ok-button-loading {
margin-right: 5px;
}
}
}
如何同时支持声明式调用和指令式调用两种方式,这是关键步骤
3. 将 InternalModal 组件嵌入到 Modal 组件里
const Modal: React.FC<ModalOptionsT> = (props) => {
const options = mergeOptions(defaultOptions, configOptions, props); // 合并参数
const [firstLoaded, setFirstLoaded] = useState(false); // 是否第一次加载了
const ref$ = useRef(null);
useEffect(() => {
if (!ref$.current) return;
const dom = ref$.current as HTMLDivElement;
if (options.visible) {
dom.style.visibility = 'inherit'; // 弹窗显示
dom.animate(appearAnimation, animationTime);
} else {
dom.animate(disappearAnimation, animationTime).onfinish = () => {
dom.style.visibility = 'hidden'; // 弹窗隐藏
if (options.destroyOnClose) {
dom.remove(); // 弹窗不可见时卸载内容
}
};
}
}, [options.visible]);
useEffect(() => {
if (!firstLoaded && options.visible) {
setFirstLoaded(true);
}
}, [options.visible])
if (!firstLoaded && !options.visible) {
return null; // 未曾打开过弹窗返回 null
}
return (
<div ref={ref$}>
<InternalModal {...options} />
</div>
)
};
代码解读:
- 通过
firstLoaded
变量来控制【未曾打开过弹窗返回 null】,保证页面首次加载不加载弹窗资源 - 通过改变弹窗 dom 节点
style.visibility
的值来控制弹窗的显示隐藏,弹窗关闭时就不会卸载内容 - 通过
dom.animate()
方法来做弹窗的显示隐藏的动画 - 如果参数
destroyOnClose
的值为 true,则通过dom.remove()
方法将弹窗内容卸载掉
4. 通过 show 方法渲染 InternalModal 组件
const show = (props: ModalFuncOptionsT) => {
const options = mergeOptions(defaultOptions, configOptions, props);
const close = renderModalInBody(
<InternalModal
{...{
...options,
onClose: () => {
close();
options.onClose?.();
},
onCancel: () => {
close();
options.onCancel?.();
options.onClose?.();
},
}}
/>
);
};
renderModalInBody 函数代码
/** 将 Modal 组件渲染到 body 里面 */
const renderModalInBody = function (component: JSX.Element) {
document.body.style.overflow = 'hidden';
const modalDom = document.createElement('div');
modalDom.id = 'modal-body';
document.body.appendChild(modalDom);
modalDom.animate(appearAnimation, animationTime);
const root = ReactDOMClient.createRoot(modalDom);
root.render(component); // 将 React 组件渲染到 modalDom 里面
return () => {
modalDom.animate(disappearAnimation, animationTime).onfinish = function () {
document.body.style.overflow = 'auto';
root.unmount();
if (document.body.contains(modalDom)) document.body.removeChild(modalDom);
};
};
}
5. 通过 clear 方法关闭当前 Modal
/** 关闭当前显示中的 Modal */
const clear = () => {
removeModalInBody();
}
/** 移除当前的 Modal 组件 */
const removeModalInBody = function () {
const modalDom = document.getElementById('modal-body');
if (modalDom && document.body.contains(modalDom)) {
modalDom.animate(disappearAnimation, animationTime).onfinish = function () {
document.body.removeChild(modalDom);
};
}
}
6. 通过 config 方法进行全局配置
/** 全局配置 */
const configOptions = {};
/** 全局配置 */
const config = (options: ModalFuncOptionsT) => {
Object.assign(configOptions, options);
}
这样在使用 Modal.show
之前调用 Modal.config
方法进行全局配置,可以减少重复配置的代码
7. 默认配置
/** 默认配置 */
const defaultOptions = {
visible: false,
titleCentered: true,
okText: '确定',
confirmLoading: false,
cancelText: '取消',
footerCentered: false,
closable: true,
mask: true,
maskClosable: true,
destroyOnClose: false,
}
在 Modal 组件和 show 函数中的逻辑:将默认配置和全局配置合并到传入的 option 当中
ops = mergeOptions(defaultOptions, configOptions, ops);
/** 合并配置参数 */
export function mergeOptions(...options: any) {
let res: any = {};
options.forEach((option: any) => {
res = Object.assign(res, option);
})
return res;
}
8. 导出 Modal 对象
声明 show、clear、config 函数后将它们绑定到 Modal 这个函数组件对象上
/** Modal 弹窗,支持指令式调用 */
Modal.show = show;
Modal.clear = clear;
Modal.config = config;
export default Modal;
相应的对 Modal 组件的类型加点修改
const Modal: React.FC<ModalOptionsT> & {
show: typeof show;
clear: typeof clear;
config: typeof config;
} = (props) => {
...
};
这样 Modal 组件就能同时支持声明式调用和指令式调用了
使用
自此,一个功能较为齐全的 Modal 组件已经完成,使用示例:
// 声明式调用
const [showModal, setShowModal] = useState(false);
<div className={css.button} onClick={() => { setShowModal(true) }}>超长文本</div>
<Modal
visible={showModal}
title='用户须知协议'
footerCentered={true}
footer={
<div
className={css['modal-custom-footer-button']}
onClick={() => { setShowModal(false) }}
>
我知道了
</div>
}
onCancel={() => { setShowModal(false) }}
onClose={() => { setShowModal(false) }}
>
{...}
</Modal>
// 指令式调用
<div className={classNames(css.button, 'button')} onClick={() => {
Modal.show({
title: '提示',
content: 'Modal 弹窗',
width: 300,
})
}}>最简单的弹窗</div>
...