likes
comments
collection
share

前端通用组件开发笔记 - Drawer 抽屉、Modal 弹窗

作者站长头像
站长
· 阅读数 3

本文详解如何完整创造出功能较为齐全的 Drawer 抽屉组件、Modal 弹窗组件

组件开发采用 React 框架,组件功能参考 抽屉 Drawer - Ant Design对话框 Modal - Ant Design

Drawer 抽屉组件

目标

通过声明式调用方式生成 Drawer 抽屉,可以从四个位置方向弹出,并且抽屉显示和隐藏播放动画,可自定义标题、内容等,抽屉关闭有回调

前端通用组件开发笔记 - Drawer 抽屉、Modal 弹窗

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 弹窗,并且弹窗显示和隐藏带有动画,可自定义标题、底部按钮等,点击底部按钮和弹窗关闭有回调

前端通用组件开发笔记 - Drawer 抽屉、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>
...