React造轮子系列--仿Antd的Modal模态框组件(Typescript+Hooks)

103 阅读3分钟

基于Typescript和Hooks写法实现一个Modal弹窗组件

捋清楚实现需求和实现思路

需求(可根据自己喜好定制)
  1. 弹窗的标题,主体,底部都可以自定义替换
  2. 遮罩层可以设置是否点击可关闭
  3. 点击确定或取消按钮执行回调

定义组件Props

interface ModalProps {
  visible: boolean; // 组件是否可见
  footer?: null | ReactElement[]; // 底部按钮,不传则显示默认按钮,亦可传入元素数组替换
  maskClosable?: boolean; // 遮罩层是否可点击
  okText?: string; // 确定按钮文本
  onOkClick?: React.MouseEventHandler; // 点击确定按钮的回调函数
  cancelText?: string; // 取消按钮文本
  onCancelClick?: React.MouseEventHandler; // 点击取消按钮的回调函数
  title?: string; // 弹窗标题
}

主体内容利用props.children展示,类型上可以自己定义多一个childre?: ReactNode,也可以利用PropsWithChildren这个类型把ModalProps当泛型传入,最终组件的基本定义如下

const Modal: FC<PropsWithChildren<ModalProps>> = (props) => {
  const {children, visible, footer, maskClosable, okText, onOkClick, cancelText, onCancelClick, title} = props;
  // ... 省略
  return (<></>);
}

继续完善内容,做这个效果可以利用一个非常方便的Api

介绍一下ReactDOM.createPortal

函数第一个参数需要传入节点内容,第二个参数传入当成容器的元素,一般选在挂到document.body上,编写如下,此时引用并展示这个组件,元素就会挂到body上而不是当前引入的元素节点树上

const Demo = ()=>{
    return ReactDOM.createPortal(<div>123</div>,document.body);
}

组件实现完整代码如下,IconButton是我写好的组件,自行替换即可

import React, {FC, MouseEventHandler, PropsWithChildren, ReactElement, useRef} from 'react';
import ReactDOM from 'react-dom';
import './modal.less';
import Icon from '../icon/icon';
import Button from '../button/button';

function classes(...names: (string | undefined)[]) {
  return names.filter(Boolean).join(" ");
}

interface ModalProps {
  visible: boolean;
  footer?: null | ReactElement[];
  maskClosable?: boolean;
  okText?: string;
  onOkClick?: React.MouseEventHandler;
  cancelText?: string;
  onCancelClick?: React.MouseEventHandler;
  title?: string;
}

const Modal: FC<PropsWithChildren<ModalProps>> = (props) => {
  const {children, visible, footer, maskClosable, okText, onOkClick, cancelText, onCancelClick, title} = props
  const ele = useRef<HTMLDivElement>(null);
  
  // props传入maskClosable为true,则点击遮罩后调用传入的取消事件(一般这个事件要控制visible的变化要关闭弹窗)
  const onMaskClickHandler: MouseEventHandler = (e) => {
    maskClosable && onCancelClickHandler(e);
  };

  const onCancelClickHandler: MouseEventHandler = (e) => {
    if (onCancelClick) {
      ele.current!.className = ele.current!.className += ` lm-modal-fade-out`
      setTimeout(() => {
        onCancelClick(e);
      }, 200)
    }
  }

  const onOkClickHandler: MouseEventHandler = (e) => {
    ele.current!.className = ele.current!.className += ` lm-modal-fade-out`
    setTimeout(() => {
      onOkClick && onOkClick(e);
    },)
  }

  const instance = visible && (<>
    <div className={classes('lm-modal-mask')} onClick={onMaskClickHandler}/>
    <div className={'lm-modal'} ref={ele}>
      <header className={classes('lm-modal-header')}>
        <div>{title ? title : "Modal"}</div>
        <div className={'lm-modal-close'} onClick={onCancelClickHandler}>
          <Icon name={'close'} className={'lm-modal-close-icon'}/>
        </div>
      </header>
      <main className={classes('lm-modal-content')}>{children}</main>
      {footer !== null
        && (<footer className={classes('lm-modal-footer')}>
          {footer
            ? footer.map((eleItem, index) => React.cloneElement(eleItem, {key: index}))
            : <div>
              <Button onClick={onCancelClickHandler}>{cancelText ? cancelText : '取消'}</Button>
              <Button style={{marginLeft: 8}} onClick={onOkClickHandler}
                      type={'primary'}>{okText ? okText : '确定'}</Button>
            </div>}
        </footer>)}
    </div>
  </>);
  return ReactDOM.createPortal(instance, document.body);
};

export default Modal;

Modal.less样式代码如下,不用less的自行改成其他语言

@border-color: #f1eff0;

.lm-modal {
  z-index: 2;
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  border-radius: 8px;
  min-width: 400px;
  min-height: 200px;
  background: white;
  display: flex;
  flex-direction: column;
  animation: fade 500ms;

  &-fade-out {
    animation: fade-out 300ms;
  }

  &-mask {
    z-index: 1;
    background: fade(black, 35%);
    width: 100vw;
    height: 100vh;
    position: fixed;
    left: 0;
    top: 0;
  }

  &-header {
    display: flex;
    position: relative;
    height: 48px;
    padding: 8px 16px;
    border-bottom: 1px solid @border-color;
    line-height: 48px;
    align-items: center;
  }

  &-close {
    height: 48px;
    width: 48px;
    position: absolute;
    right: 0;
    top: 0;
    display: flex;
    justify-content: center;
    align-items: center;

    &:hover {
      .lm-modal-close-icon {
        color: #de3131;
        height: 1.2em;
        width: 1.2em;
      }

      cursor: pointer;
    }
  }

  &-content {
    flex: 1;
    padding: 16px;
  }

  &-footer {
    border-top: 1px solid @border-color;
    height: 48px;
    display: flex;
    align-items: center;
    justify-content: flex-end;
    padding-right: 16px;
  }

}

@keyframes fade {
  0% {
    opacity: 0;
    transform: scale(0) translate(-50%, -50%);

  }
  100% {
    opacity: 1;
    transform: scale(100%) translate(-50%, -50%);
  }
}

@keyframes fade-out {
  0% {
    opacity: 1;
    transform: scale(100%) translate(-50%, -50%);
  }
  100% {
    background: white;
    transform: scale(0) translate(-50%, -50%);
    opacity: 0;
  }
}

React组件中使用Modal组件,主要在于一个布尔值来控制弹窗是否显示,在点击确定和关闭事件中把布尔值设置为false

const App = () => {
  const [visible, setVisible] = useState(false);

  return (
    <>
      <Modal visible={visible} maskClosable onCancelClick={() => setVisible(false)} onOkClick={() => setVisible(false)}>
        <div>123</div>
      </Modal>
      <Button onClick={() => setVisible(true)}>Click!</Button>
    </>
  );
};

最后的演示效果

QQ20230214-001840-HD.gif

实现挺简单的,主要是搞清楚思路以及细节点即可,有些api没有演示,按照类型自行测试即可
完:)