likes
comments
collection
share

我勒个去!modal组件这么难写你知道吗?

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

前言

简单看一下 modal 组件是什么(请查看以下 demo 链接):

印象中,modal 组件就是一个弹框居中,然后一个黑色蒙层 fixed 布局放在后面,感觉很简单啊,但是深入研究各个组件库的 modal 源码,发现技术细节有很多,不信你就接着看!保证没做过 modal 组件的同学不知道。

我简单列一下技术难点,你可以测试一下你用的组件库是否注意这些细节了,欢迎留言讨论!

技术难点 1:如何处理滚动条样式和嵌套 modal

为什么要处理滚动条样式?

滚动条样式当出现 modal 的时候,如果此时 window 窗口有滚动条,一般情况我们需要隐藏滚动条。其实就是在 body 上加一个样式overfolow: hidden。这样用户就会更专注于此时的弹框。

嵌套 modal 是什么?

嵌套 modal,也就是按钮弹出 modal 弹窗,弹窗里又有一个按钮,点击之后再之前的基础上又有一个 modal

技术难点在哪

一般情况,我们需要判断此时是否是最后一层 modal,如果是,才把 body 样式的滚动条隐藏,所以嵌套 modal 多余一个 modal 时,我们是不需要再次隐藏 body 样式的,而且最后一个 modal 关闭的时候,我们还需要把 body 原本的样式还原。

所以我们需要一个管理所有 modal 的管理器,去记录所有的 modal 目前有几个,正在打开的是第几个。

解决方案

这里的解决方案一般有两种,一种是例如 material ui,chakra, 小米公司的 modal,都是用一个数据结构保存所有 modal,然后每次关闭一个 modal,就去所有 modal 里找是否是最后一个 modal,如果是最后一个才回复 body 原本的 style.

第二采取了字节 arco design 的处理方法,还是比较巧妙的,

请看下面的 useOverflowHidden, 我们详细的看下如何处理隐藏 body 滚动条和恢复的时机和具体方法。

import { useEffect, useRef } from "react";
import { resetContainerStyle, setContainerStyle } from "../utils";

export function useOverflowHidden(
  getContainer: () => HTMLElement,
  hidden: boolean
) {
  const needResetContainerStyle = useRef < boolean > false;
  const originContainerStyle = useRef < Partial < CSSStyleDeclaration >> {};

  useEffect(() => {
    hidden
      ? setContainerStyle({
          needResetContainerStyle,
          originContainerStyle,
          getContainer,
        })
      : resetContainerStyle({
          needResetContainerStyle,
          originContainerStyle,
          getContainer,
        });
    return () => {
      resetContainerStyle({
        needResetContainerStyle,
        originContainerStyle,
        getContainer,
      });
    };
  }, [getContainer, hidden]);
}
  • getContainer 代码要挂载到 html 文档流哪个 dom 中,我们默认是 body 元素中

  • hidden 是指是否弹框的时候,我们需要黑色蒙层,也就是有时候我们传参不需要这个蒙层,也就意味着我们不想让 body 滚动条消失,所以我们在看到 modal 弹框的同时,也能滚动后面的页面,当然,我们这里大家可以看为是 true,我们是需要黑色蒙层的

  • needResetContainerStyle 用来记录是否重置 body style 样式,只有调用 setContainerStyle 方法后,并且是第一个触发的 modal 框,这个值才会是 true

我们马上看下 setContainerStyle,也就是设置 body 滚动条隐藏的函数:

import { getScrollBarWidth } from "./getScrollBarWidth";

/**
 * Hides the container's scroll bar
 */
export const setContainerStyle = ({
  getContainer,
  needResetContainerStyle,
  originContainerStyle,
}) => {
  const container = getContainer();
  if (container && container.style.overflow !== "hidden") {
    /**
     * @zh 记录container的style属性, 因为后续要将container.style.overflow设为hidden
     * @en Record the container's style property, because I'll set container.style.overflow to hidden later
     */
    const originStyle = container.style;

    /**
     * @zh 记录是否 container.style.overflow 被覆盖为hidden
     * @en Note whether container.style.overflow is overwritten as hidden
     */
    needResetContainerStyle.current = true;

    const containerScrollBarWidth = getScrollBarWidth(container);
    if (containerScrollBarWidth) {
      originContainerStyle.current.width = originStyle.width;
      container.style.width = `calc(${
        container.style.width || "100%"
      } - ${containerScrollBarWidth}px)`;
    }

    /**
     * @zh 设置container的overflow为hidden
     * @en Set container overflow to hidden
     */
    originContainerStyle.current.overflow = originStyle.overflow;
    container.style.overflow = "hidden";
  }
};

简单来说,使用 originContainerStyle 来记录原本的 body 元素上的 style.overflow 属性是什么,然后把 body 的 style.overflow 设置为 hidden

needResetContainerStyle 也记录下,已经设置过 body 的 styel 样式了

当然,如果滚动条有宽度,我们也要把滚动条宽度那部分减去,以防点开的时候,因为滚动条 overflow 为 hidden,宽度减小了,视觉上会有抖动,这是个很细的交互细节。

技术难点 2:如何锁定焦点

什么是锁定焦点吗?

当你打开 modal 的时候,在键盘上按下 tab 键,会将

我勒个去!modal组件这么难写你知道吗?

上图所示的 focus 状态,会在你按下回车键的时候触发这个按钮的 onClick 事件。而且你一直按 Tab 键,焦点只会在当前 Modal 框里,不会移除到 Modal 框外,这种 focus 状态锁定技术是需要解决的。

并且有些人并不知道 tabIndex,有兴趣的同学可以搜索一下,通过 tabIndex,我们可以让关闭按钮,也就是右上角的 x 也能获取焦点,我的组件库并没有处理这个细节,是因为按 ESC 键就可以关闭弹窗,这样做我感觉多此一举。

解决方案

锁定焦点,我使用了大多数组件库都会使用的库,react-focus-lock,它用于在可见区域内锁定焦点。它的原理是通过捕获焦点事件并将焦点限制在指定的容器内,以防止焦点离开该容器。

具体的原理我没有探究,本来我猜想是监听 focus 事件,然后用 event.preventDefault()阻止那些不在视觉焦点内的 focus 效果,但是我试了一下不行。

继续猜想,以下代码可行:

注:以下的 shift + tab 是指聚焦到上一个聚焦元素上,tab 键是聚焦到下一个聚焦元素上

function createFocusTrap(element) {
  const focusableElements = Array.from(
    element.querySelectorAll(
      'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
    )
  );

  let firstFocusableElement = focusableElements[0];
  let lastFocusableElement = focusableElements[focusableElements.length - 1];

  function handleKeyDown(event) {
    if (event.key === "Tab") {
      if (event.shiftKey && document.activeElement === firstFocusableElement) {
        event.preventDefault();
        lastFocusableElement.focus();
      } else if (
        !event.shiftKey &&
        document.activeElement === lastFocusableElement
      ) {
        event.preventDefault();
        firstFocusableElement.focus();
      }
    }
  }

  element.addEventListener("keydown", handleKeyDown);
  element.focus();
}

上面的代码是非常非常巧妙的,这才是前端的稍微有一点难度的东西,而不是仅仅用 ant 的 api。它强行创造了一个锁定聚焦区域的循环链。

首先,你传入 createFocusTrap 中一个 element,然后,先聚焦到这个 element,然后你按 tab 键的时候,就从这个聚焦的地方开始了,等在 tab 键按到最后一个聚焦的时候,我们又强行让他从聚焦区域第一个聚焦元素开始,这样就强行锁定到 element 元素中了

技术难点 3:API 设计:是否支持灵活的函数调用

modal 组件是否暴露了增删改查 modal 内容的能力,这样 modal 的灵活性就会大大增加,比如关闭 modal 框的时候,我们希望先请求后端的接口校验,此时 modal 的确定按钮处于 loading 状态,如果后端校验通过才关闭 modal,不通过就不关闭。

例如:

Modal.add 增加modal
Modal.remove 关闭modal
Modal.update 更新modal
Modal.removeAll 关闭所有modal

还有调用方法,而不是组件使用:

<Modal />

因为做过 b 端的同学都应该会有这个感受,一般弹框类的组件都是提示类的,提示类组件基本都是通过 onClick 触发,所以如果显示 Modal 也是函数调用就会非常方便,例如:

Modal.add({ ...xxx参数 });

所以我放弃了组件调用的方式,例如:

<Modal />

甚至我看到有些开源的平台,例如某国内知名的 k8s 开源平台,前端项目中,居然把组件调用的方式全部重写封装为函数调用,你就可以知道在 b 端是多么需要函数调用提示类弹框了。

技术难点 4:细节决定成败:动画系统 + 边界 case

后面会详细介绍一下我在组件库运用的动画系统,framer-motion,未来很多年后,肯定会有超过它的 react 动画系统,但目前,毫无疑问,它是最强的 react 动画系统。

大家可以去查看一下国内组件库主要用的动画系统,其中 acro design 和 tdesign 都是用的react-transition-group,这个组件库更适合以前用类组件的时候用,它对 hooks 的契合度总感觉很别扭(目前源码还是用的类组件实现)

还有一个就是它的性能真的很差,我们的项目多次因为这个动画系统卡顿,所以后来换到了 framer-motion,framer-motion 最大的缺点是包体积比较大,所以打包项目一定要做 tree shaking,这样在按需加载的技术下,包的体积会大大减小。

ant design 有一套自己的动画系统,没有深入研究,起码性能感觉还是不错的。

后续会有文章介绍国内组件库常用的动画系统以及 framer-motion 的入门教程。这里只是强调你在写组件库的时候,一定要慎重选择动画系统,一旦选择了,可能就会一直用下去。

你如果是新系统,framer-motion 我是第一推荐的。

边界 case 有很多,比如拖住 modal 框,然后鼠标移入到蒙层,最后松开鼠标,此时 modal 框不应该关闭,只有在蒙层完成一次点击才能算关闭,所以这个需要处理。

还有,需要注册一些键盘事件,例如 esc 就触发 onCancel 事件。

好了,最后说一点最重要的,modal,message,draw,Notification 组件,其实在我的组件库都用的一套 hooks,稍微改改 UI,就快速实现另一种组件了,这就是抽象数据层的意义,有兴趣的同学可以去看# 如何在代码质量上超过大多数 react ui 组件库 (拿 Message 组件举例