【Modal 模态框】:嵌套也不在话下

1,072 阅读8分钟

本篇文章给大家介绍如何实现一个 Modal 模态框。

首先“模态框”这个词的含义是什么呢?我第一次看也一脸懵逼,我对这一类可以弹出的元素一直称之为“对话框”,于是我问了下 chat-gpt

ok,意思就是打开之后会限制用户操作呗。

下面先露个脸,演示地址在【Modal】

需求分析

一个模态框最基本的需求就是:激活与关闭,在 React 中它就是一个“半受控组件”,激活时需要通过外部组件,而关闭时通常是自身来触发关闭操作。一般来说,模态框都会有一个遮罩层来覆盖整个网页视图,然后在这个遮罩层上展示模态框的内容;需要关闭时,可以点击右上角的关闭按钮,也可以点击空白的遮罩层来关闭。

此外,在有些业务场景下需要在当前弹出的模态框中再弹出一个模态框,比如二次确认的场景,这时就要求我们的模态框可以嵌套来调用。

好了,模态框的功能就这么简单,总体实现上一点也不难,不过模态框是一个很重要的组件,也是一个很基础的组件,基本上需要弹出层的功能,都可以基于模态框来开发,例如 Dialog 对话框(这个会面会说到),还有 Image 图片组件的预览模式等等。

模态框实现

首先,我们定义 Modal 组件的入参:

interface propsModal {
  visible: boolean; // 可见性
  mask?: boolean; // 是否启用背景遮罩效果
  top?: string; // 距离视口顶部的距离
  closeOnMaskClick?: boolean; // 点击遮罩层的回调
  containerClass?: string; // 模态框内容的 css 类名
  close: () => void; // 关闭模态框的回调
  onOpen?: () => any; // 模态框打开之后执行的回调
  onClose?: () => any; // 模态框关闭之后执行的回调
  children?: ReactNode; // 模态框的子节点(即模态框内容)
  fullscreen?: boolean; // 是否启用全屏显示模态框内容
}

以上定义的属性基本一看就懂了,这里还是提一下为什么要设置一个 containerClass,它的作用是定义模态框的内容的样式,默认情况下,模态框内容是一个 dom 元素,其宽高自适应,如果想给模态框内容加个阴影或者改变内容的最小尺寸,这时传入这个自定义类名然后在写样式就很方便了(后面在实现 Dialog 全屏模式时会提到)

我一直不喜欢文章里面只有干巴巴的代码,这样让人看了一头雾水,结合主题来展开一些有关的知识点倒是很有必要的。所以先来复习一下 React 中“受控组件”的概念,下面有请 chat-gpt 发言

由此可知,受控组件就是将自身的状态交给外部来控制。不仅仅是表单类元素,所有依赖外部输入的组件其实都可以归类为受控组件,在 Modal 中,控制可见性的visible变量就是接受外部控制的状态。

接下来定义一个 React 组件:

// css 类名缩写
const T = 'wdu-modal';

function Modal(props: propsModal) {
  const {
    mask,
    visible = false,
    containerClass = '',
    top,
    children,
    closeOnMaskClick = true,
    onOpen,
    onClose,
    close,
  } = props;

  const refModal = useRef<any>();

  const modal = (
    <div
      ref={refModal}
      className={T}
      style={{display: visible ? 'block' : 'none'}}
      onClick={(e: MouseEvent) => {
        e.stopPropagation();
        if (closeOnMaskClick) close();
      }}>
      <div
        className={`wdu-modal__container ${containerClass}`}
        onClick={(e) => e.stopPropagation()}
        style={{ marginTop: top }}>
        {children}
      </div>
    </div>
  );

  return ReactDOM.createPortal(modal, document.body);
}

此时我们已经拥有了一个可自由开关的模态框组件了,通过visible变量来控制模态框元素的显示与隐藏。

注意ReactDOM.createPortal(modal, document.body);这条语句,它的作用就是将我们定义的 Modal 组件直接挂在到body节点中,为什么要单独拎出来挂载到body中呢?这样是为了防止 Modal 与目标元素互相影响,假如 Modal 与目标元素同级,当目标元素连带着被父元素一同设置了display: none,则 Modal 也会不可见,这就是说 Modal 受到了目标元素的影响。所以,我们将 Modal 挂载到最外层。

ReactDOM.createPortal() 这个 API 大家一定有所耳闻了,它可以将元素渲染到指定的位置,它的参数一共有三个:children(被渲染的元素)、domNode(指定的位置,就是一个 dom 节点)、key (用于组件标识,触发更新时用的),本文只需要用到前两个参数即可。

模态框动画实现

上面已经实现了一个基本可用的 Modal ,但是有点“生硬”,完全通过 display: none 来实现显示隐藏没有任何过渡,接下来给 Modal 加上 css 动画,让它更加丝滑。这整个过程还是有点麻烦的,要动态控制很多的 css 类名。

首先要明确的是,控制 Modal 的可见性,其实就是控制最外层 Modal 元素的可见性,也就是遮罩层的可见性,接下来,定义显示以及隐藏状态下的 CSS 样式以及类名

.wdu-modal {
  
  /* 省略部分代码 */
  
  &__visible {
    display: block;
    opacity: 0;
    animation: modalMaskShow 0.2s ease 0s 1 normal forwards;

    .wdu-modal__container {
      animation: modalShow 0.2s cubic-bezier(0.32, 0.32, 0.19, 1) 0s 1
        normal forwards;
    }
  }

  &__hidden {
    opacity: 1;
    animation: modalMaskHide 0.2s ease 0.2s 1 normal forwards;

    .wdu-modal__container {
      animation: modalHide 0.2s cubic-bezier(0.44, 0.11, 0, 0.99) 0s 1
        normal forwards;
    }
  }
}

@keyframes modalMaskShow {
  from {
    opacity: 0;
  }

  to {
    opacity: 1;
  }
}

@keyframes modalMaskHide {
  from {
    opacity: 1;
  }

  to {
    opacity: 0;
  }
}

@keyframes modalShow {
  from {
    opacity: 0;
    margin-top: 10vh;
    height: 0;
  }

  to {
    opacity: 1;
    margin-top: 15vh;
    height: auto;
  }
}

@keyframes modalHide {
  from {
    opacity: 1;
    margin-top: 15vh;
  }

  to {
    opacity: 0;
    margin-top: 12vh;
  }
}

上面代码中定义了四段动画,分别用于modalMask 遮罩层modal内容区,接下来,将动画绑定到相关的元素上

const T = 'wdu-modal';
const classMap = {
  base: T,
  visible: `${T}__visible`,
  hidden: `${T}__hidden`,
};

const { addClassName, removeClassName, classList } =
  useCssClassManager(classMap);

const modal = (
  <div
    ref={refModal}
    className={classList}
    >
    <div
      className={`wdu-modal__container ${containerClass}`}
      >
      {children}
    </div>
  </div>
);

wdu-modal就是遮罩层元素,wdu-modal__container就是内容区元素。

接下来,重点部分到了,如何实现动画的控制,整个流程的文字描述如下:

  1. visibletrue时, 将 wdu-modal__visible这个类名应用到遮罩层元素上,此时遮罩层和内容区元素会展示显示状态的 CSS 动画
  2. visiblefalse时,将 wdu-modal__hidden这个类名应用到遮罩层元素上,此时遮罩层和内容区元素会展示隐藏状态的 CSS 动画
  3. Modal 元素被隐藏之后,还需要清除掉wdu-modal__hidden,使遮罩层的样式恢复到初始状态;这样,再次打开 Modal 才能重新开始应用我们定义好的 CSS 动画

控制元素可见性动画的任务交给函数handleVisibility来实现,清除遮罩层类名的任务交给resetClassList来实现:

// Modal 组件的根元素
const refModal = useRef<any>();

// 控制显示和隐藏动画的 css 样式
const handleVisibility = (visible: Boolean) => {
  if (visible) {
    addClassName("visible");
    onOpen && onOpen();
  } else {
    addClassName("hidden");
    resetClassList();
    onClose && onClose();
  }
};

// 清除样式,恢复到初始状态
const resetClassList = () => {
  const modal = refModal.current;

  const clear = () => {
    removeClassName("visible");
    removeClassName("hidden");
    // 记得移除掉事件监听
    modal.removeEventListener("animationend", clear);
  };

  if (modal) {
    modal.addEventListener("animationend", clear);
  }
};

这两个方法的触发完全取决于visible的变化:

const [firstLoad, setFirstLoad] = useState(false);
useEffect(() => {
  setFirstLoad(true);
}, []);

useEffect(() => {
  if (firstLoad) {
    handleVisibility(visible);
  }
}, [visible]);

这段代码得好好解释一下,首先firstLoad这个变量,顾名思义是用于标识组件是否挂载过,只有当组件挂载并渲染后,才去调用handleVisibility方法。为什么要分出一个组件是否初次挂载的阶段呢?因为 Modal 组件初始时默认不可见,即visible=false,如果直接在第二个useEffect中调用handleVisibility方法,就会调用到resetClassList方法清除所有类名,这是错误的。

接下来,还有一个小功能,那就是全屏的模态框,这个很简单,只要将内容区的大小设置为填充满整个遮罩层即可,less 代码如下:

.wdu-modal {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  top: 0;
  z-index: 2000;
  display: none;
  overflow: auto;

  &__fullScreen {
    .wdu-modal__container {
      padding: 0;
      width: 100%;
      height: calc(100% - 20px) !important;
      margin: 0 !important;
    }
  }
}

这里之所以要用!important是因为内容区元素上应用了 CSS 动画,将其高度设置成了auto,此时如果在通过增加一个类名覆盖的方式是不生效的,所以使用了最后手段!important

此外,前面提到过模态框出现时会限制用户的操作,而我们这模态框又是一个比较严肃的模态框,所以顺便把模态框弹出时页面的滚动给禁用了吧,这里再加一个方法:

const handleFullScreen = (visible: Boolean) => {
  if (fullscreen) {
    if (visible) {
      document.body.style.overflow = 'hidden';
      addClassName('fullscreen');
    } else {
      // 恢复到没有值的状态
      document.body.style.overflow = '';
      removeClassName('fullscreen');
    }
  }
};

最后,整理一下代码:

function Modal(props: propsModal) {
  const {
    mask,
    visible = false,
    fullscreen = false,
    containerClass = "",
    top,
    children,
    closeOnMaskClick = true,
    onOpen,
    onClose,
    close,
  } = props;

  const refModal = useRef<any>();

  const classMap = {
    base: T,
    visible: `${T}__visible`,
    hidden: `${T}__hidden`,
  };
  const { addClassName, removeClassName, classList } =
    useCssClassManager(classMap);

  const resetClassList = () => {
    const modal = refModal.current;

    const clear = () => {
      removeClassName("visible");
      removeClassName("hidden");
      modal.removeEventListener("animationend", clear);
    };

    if (modal) {
      modal.addEventListener("animationend", clear);
    }
  };

  const handleVisibility = (visible: Boolean) => {
    if (visible) {
      addClassName("visible");
      onOpen && onOpen();
    } else {
      addClassName("hidden");
      resetClassList();
      onClose && onClose();
    }
  };

  const handleFullScreen = (visible: Boolean) => {
    if (fullscreen) {
      if (visible) {
        document.body.style.overflow = 'hidden';
        addClassName('fullscreen');
      } else {
        document.body.style.overflow = '';
        removeClassName('fullscreen');
      }
    }
  };

  const [firstLoad, setFirstLoad] = useState(false);
  useEffect(() => {
    setFirstLoad(true);
  }, []);

  useEffect(() => {
    if (firstLoad) {
      handleFullScreen(visible);
      handleVisibility(visible);
    }
  }, [visible]);

  const modal = (
    <div
      ref={refModal}
      className={classList}
      onClick={(e: MouseEvent) => {
        e.stopPropagation();
        if (closeOnMaskClick) close();
      }}
      >
      <div
        className={`wdu-modal__container ${containerClass}`}
        onClick={(e) => e.stopPropagation()}
        style={{ marginTop: top }}
        >
        {children}
      </div>
    </div>
  );

  return ReactDOM.createPortal(modal, document.body);
}

嵌套的模态框

所谓嵌套模态框就是在当前弹出的模态框的基础上,再弹出一个模态框,它的层级需要比当前这个模态框高即可,整体实现思路如下:

  1. 每个 Modal 组件在挂载后会检测window中是否存在一个变量WOOD_UI_MODAL_LEVEL,用它来表示当前最高的模态框的层级;如果不存在,则初始化并赋值 2000 (Modal 的默认 z-index值)
  2. 当 Modal 弹出后,获取WOOD_UI_MODAL_LEVEL的值然后加 1 ,关闭后再减 1
  3. 如果是嵌套弹出的模态框则默认启用 mask 遮罩颜色,方便区分

首先,来封装一个 hooks ,我们给它命名useTopLayer

import { useEffect, useState } from 'react';

// 默认的模态框层级,应该超过大部分元素的层级了
const TOP_INDEX = 2000;

function useTopLayer(visible: boolean, onceFlag: boolean) {
  const [topIndex, setTopIndex] = useState(TOP_INDEX);

  useEffect(() => {
    if (!window.WOOD_UI_MODAL_LEVEL) {
      window.WOOD_UI_MODAL_LEVEL = TOP_INDEX;
    }
  }, []);

  useEffect(() => {
    // 同样是首次次加载后运行
    if (onceFlag) {
      if (visible) {
        // 将当前最高的模态框层级 +1
        window.WOOD_UI_MODAL_LEVEL += 1;
      } else {
        // 将当前最高的模态框层级 -1
        window.WOOD_UI_MODAL_LEVEL -= 1;
      }

      setTopIndex(window.WOOD_UI_MODAL_LEVEL);
    }
  }, [visible]);

  return { topIndex };
}

export { useTopLayer, TOP_INDEX };

接下来应用这个 hooks ,并把z-index应用到 Modal 元素上

const { topIndex } = useTopLayer(visible, firstLoad);
useEffect(() => {
  if (visible) {
    // 嵌套弹出的模态框则启用 mask 遮罩颜色
    const applyMask = topIndex - 1 > TOP_INDEX ? true : mask;
    applyMask && addClassName('mask');
  }
}, [topIndex]);

const modal = (
  <div
    ref={refModal}
    className={classList}
    style={{ zIndex: topIndex }}>
    <div
      className={`wdu-modal__container ${containerClass}`}
      >
      {children}
    </div>
  </div>
);

此时就可以无限嵌套模态框了

基于模态框实现 Dialog 对话框组件

有了模态框之后,所有类似的组件都可以基于 Modal 来实现了,例如 Dialog 对话框、全屏的图片弹出预览等等,本文只介绍 Dialog 对话框的实现,图片预览这个会放到后面单独的一篇 Image 图像组件的文章中。

首先,定义 Dialog 组件的入参,它与 Modal 的入参非常相似。

interface propsDialog {
  visible: boolean;
  title?: string; // 对话框的标题
  header?: JSX.Element | string; // 对话框的顶部
  footer?: ReactElement; // 对话框底部
  children?: any;
  mask?: boolean; // 是否开启遮罩层颜色
  showClose?: boolean; // 是否展示关闭按钮
  closeOnMaskClick?: boolean;
  close: () => void; // 关闭对话框的方法
  onOpen?: () => void; // 激活对话框后的回调 
  onClose?: () => void; // 关闭对话框后的回调 
}

下面是 Dialog 组件结构:

import { Modal } from './Modal';

function Dialog(props: propsDialog) {
  const {
    visible,
    title,
    header,
    footer,
    mask = false,
    showClose = true,
    children,
    closeOnMaskClick = true,
    close,
    onOpen,
    onClose,
  } = props;

  return (
    <Modal
      visible={visible}
      close={close}
      mask={mask}
      closeOnMaskClick={closeOnMaskClick}
      onClose={onClose}
      onOpen={onOpen}>
      <div className='wdu-dialog'>
        {/* 自定义的 header ,不传则渲染 title 文字 */}
        {header ? (
        <div className='wdu-dialog__header'>{header}</div>
      ) : (
        <div className='wdu-dialog__header'>
          {title && (
          <p className='wdu-dialog__header-title'>{title}</p>
        )}
        </div>
      )}

        {/* 关闭按钮 */}
        {showClose && (
        <i
          className='wdu-dialog__close'
          onClick={(e: MouseEvent) => {
            e.stopPropagation();
            close();
          }}></i>
      )}

        {/* 对话框内容 */}
        <div className='wdu-dialog__body'>{children}</div>

        {/* 自定义的 footer */}
        {footer && <div className='wdu-dialog__footer'>{footer}</div>}
      </div>
    </Modal>
  );
}

SO EASY 有没有!完全就是个套壳 Modal 组件,只不过多了三个部分:可自定义的头部、底部、关闭按钮,以及一些自定义事件。上文中展示的所有弹框就是 Dialog 组件。

总结

本文介绍了如何实现一个 Modal 模态框组件,主要通过React.createPortal方法将模态框直接渲染到body元素下,避免了与其目标元素相互影响;又通过动态切换类名的方式,给模态框加上了动画使得模态框开启和关闭时的视觉效果更加丝滑;最后,基于 Modal 提供的所有能力,在此基础上实现了 Dialog 对话框组件。

本文的 Modal 组件在功能和 API 上可能没有考虑的那么周全,不过这些都是可以在后期来迭代的,本文的主要目的就是通过手动编写一个模态框来了解这一类组件到底是如何实现的,这样如果后期在实际项目中遇到了需要定制模态框的需求就能立刻用上这一部分的实践经验——这也是这个专栏的目的,即手动实现一遍市面上常见的 UI 组件,最终做到在前端 UI 以及交互实现方面能够从容应对。

本文源码:【Modal】模态框【Dialog】对话框