实现一个简单的Modal组件

1,273 阅读8分钟

Modal对话框组件在我们的开发中经常会用到,比较知名的有antd组件库中的Modal。这次我们来基于antd Modal组件的原理实现一个简单的Modal组件。由于是为了理解antd Modal的核心原理,我们对Modal组件做如下简化:

  • Modal只有open、children、onCancel、afterClose四个参数,open为true时就挂载在document.body上,open为false时就卸载。参数类型定义如下:
interface ModalCommonProps {
  onCancel?: () => void;
  afterClose?: () => void
}

export interface ModalProps extends ModalCommonProps {
  open: boolean;
  children: React.ReactNode;
}
  • 函数唤起Modal的方式只支持Modal.info,且Modal.info没有返回值。Modal.info的入参类型定义如下:
export interface ModalFuncProps extends ModalCommonProps {
  content: React.ReactNode;
}
  • Modal的样式如下图所示

image.png

Modal组件部分的实现

在不考虑动画以及组件挂载位置的情况下,Modal的实现还是比较简单的。我们暂时不考虑afterClose的实现,则实现代码如下:

export const Modal = (props: ModalProps) => {
  const { children, open, onCancel } = props;

  return open ? (
    <div>
      <div className="modal-mask" />
      <div className="modal-wrap">
        <div className="modal-content">
          <div>{children}</div>
          <div className="modal-btn-container">
            <button onClick={onCancel}>close</button>
          </div>
        </div>
      </div>
    </div>
  ) : null;
};
.modal-mask {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  top: 0;
  background-color: rgba(55, 55, 55, 0.6);
}

.modal-wrap {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  top: 0;
}

.modal-content {
    position: relative;
    top: 100px;
    margin: 0 auto;
    width: 416px;
    max-width: 100vw;
    background-color: #ffffff;
    border-radius: 8px;
    padding: 20px 24px;
}

.modal-btn-container {
    text-align: right;
}

效果如下图所示

20240628165328_rec_.gif

但这样Modal并不是挂载在document.body下的。antd是通过React的createPortal来实现这一功能的,并抽象出了一个Portal组件(位于@rc-component/portal包)。我们也实现一个简单的Portal组件如下:

interface PortalProps {
  open: boolean;
  children: React.ReactNode;
}

export const Portal = (props: PortalProps) => {
  const { open, children } = props;

  const container = useMemo(() => {
    const elem = document.createElement("div");
    return elem;
  }, []);

  function append() {
    if (!container.parentElement) {
      document.body.appendChild(container);
    }
  }

  function cleanup() {
    container.parentElement?.removeChild(container);
  }

  useLayoutEffect(() => {
    if (open) {
      append();
    } else {
      cleanup();
    }

    return cleanup;
  }, [open]);

  return createPortal(children, container);
};

Portal组件将children通过createPortal渲染在其创建的div元素中,然后根据open参数的变化将该div元素在document.body上进行挂载/卸载。我们再将Modal组件改为基于Portal来实现:

export const Modal = (props: ModalProps) => {
  const { open, onCancel, children } = props;

  return (
    <Portal open={open}>
      <div>
        <div className="modal-mask" />
        <div className="modal-wrap">
          <div className="modal-content">
            <div>{children}</div>
            <div className="modal-btn-container">
              <button onClick={onCancel}>close</button>
            </div>
          </div>
        </div>
      </div>
    </Portal>
  );
};

Modal.info的实现

antd中Modal.info的实现原理为:创建一个并不在DOM树中的DocumentFragment,然后通过React的root.render将Modal组件渲染在该DocumentFragment中。由于我们是通过createPortal实现的Modal,虽然这个DocumentFragment并不在DOM树中,但Modal依然能够被挂载在document.body上。在关闭Modal时,将Modal入参中的open改为false,并再次调用root.render。重复的root.render调用相当于是进行一次重渲染。最后在动画效果结束以后调用root.unmount完成Modal的卸载。

参考这个原理,在不考虑动画效果和afterClose的情况下我们可以按如下方式简单实现一个Modal.info:

const MARK = "__rc_react_root__";

function reactRender(
  node: React.ReactNode,
  container: DocumentFragment & { [MARK]?: Root }
) {
  if (!container[MARK]) {
    container[MARK] = createRoot(container);
  }

  container[MARK]?.render(node);
}

export function info(config: ModalFuncProps) {
  const container = document.createDocumentFragment() as DocumentFragment & {
    [MARK]?: Root;
  };

  let currentConfig = { ...config, close, open: true } as any;
  let timeoutId: ReturnType<typeof setTimeout>;

  function render(props: any) {
    clearTimeout(timeoutId);

    timeoutId = setTimeout(() => {
      const { close, content, ...rest } = props;

      reactRender(
        <Modal
          {...rest}
          onCancel={() => {
            close();
          }}
        >
          {content}
        </Modal>,
        container
      );
    });
  }

  function close() {
    currentConfig = { ...currentConfig, open: false };
    render(currentConfig);

    setTimeout(() => {
      currentConfig.onCancel?.();
      container[MARK]?.unmount();
    });
  }

  render(currentConfig);
}

Modal动画效果的实现

antd的Modal存在两个动效,一个是mask透明度的变化(fade in/fade out),另一个是对话框大小的变化(zoom in/zoom out),并且对话框是以点击位置为中心来放大、缩小的:

20240628173020_rec_.gif

我们先来实现对话框大小变化的动画,且不考虑变化的中心位置。一个简单的实现如下所示:

.modal-content {
    position: relative;
    top: 100px;
    margin: 0 auto;
    width: 416px;
    max-width: 100vw;
    background-color: #ffffff;
    border-radius: 8px;
    padding: 20px 24px;

    &-enter {
      animation-name: zoomIn;
      animation-duration: 0.3s;
      animation-timing-function: ease-out;
      animation-fill-mode: both;
    }

    &-leave {
      animation-name: zoomOut;
      animation-duration: 0.3s;
      animation-timing-function: ease-in-out;
      animation-fill-mode: both;
    }
}

@keyframes zoomIn {
  from {
    transform: scale(0.2);
    opacity: 0;
  }

  to {
    transform: scale(1);
    opacity: 1;
  }
}

@keyframes zoomOut {
  from {
    transform: scale(1);
    opacity: 1;
  }

  to {
    transform: scale(0.2);
    opacity: 0;
  }
}
export const Modal = (props: ModalProps) => {
  const { open, onCancel, children } = props;

  return (
    <Portal open={open}>
      <div>
        <div className="modal-mask" />
        <div className="modal-wrap">
          <div
            className={classNames("modal-content", {
              "modal-content-enter": open,
              "modal-content-leave": !open,
            })}
          >
            <div>{children}</div>
            <div className="modal-btn-container">
              <button onClick={onCancel}>close</button>
            </div>
          </div>
        </div>
      </div>
    </Portal>
  );
};

其效果如下所示:

20240628173721_rec_.gif

可以发现组件关闭时的动画并没有播放,这是因为Portal在关闭动画刚开始时就把组件卸载了。因此我们需要监听animationEnd事件,在该事件的回调中通知Portal卸载组件,也就是让Portal在关闭动画播放完毕之后再卸载。

export const Modal = (props: ModalProps) => {
  const { open, onCancel, children, afterClose } = props;

  const [animatedVisible, setAnimatedVisible] = useState(false)

  useEffect(() => {
    if (open) {
      setAnimatedVisible(true)
    }
  }, [open])

  return (
    <Portal open={open || animatedVisible}>
      <div>
        <div className="modal-mask" />
        <div className="modal-wrap">
          <div
            className={classNames("modal-content", {
              "modal-content-enter": open,
              "modal-content-leave": !open,
            })}
            onAnimationEnd={() => {
              if (open) {
                return
              }

              console.warn('animation end')
              setAnimatedVisible(false)
              afterClose?.()
            }}
          >
            <div>{children}</div>
            <div className="modal-btn-container">
              <button onClick={onCancel}>cancel</button>
            </div>
          </div>
        </div>
      </div>
    </Portal>
  );
};

我们在上面的代码中也补上了afterClose的实现。在antd Modal中afterClose是在对话框关闭动画效果结束之后调用的,这里我们也在相同的时机进行调用。在之前的Modal.info的实现中我们尚未考虑afterClose,这里我们也把它补上:

const MARK = "__rc_react_root__";

function reactRender(
  node: React.ReactNode,
  container: DocumentFragment & { [MARK]?: Root }
) {
  if (!container[MARK]) {
    container[MARK] = createRoot(container);
  }

  container[MARK]?.render(node);
}

export function info(config: ModalFuncProps) {
  const container = document.createDocumentFragment() as DocumentFragment & {
    [MARK]?: Root;
  };

  let currentConfig = { ...config, close, open: true } as any;
  let timeoutId: ReturnType<typeof setTimeout>;

  function render(props: any) {
    clearTimeout(timeoutId);

    timeoutId = setTimeout(() => {
      const { close, content, ...rest } = props;

      reactRender(
        <Modal
          {...rest}
          onCancel={() => {
            close();
          }}
        >
          {content}
        </Modal>,
        container
      );
    });
  }

  function destroy() {
    config.onCancel?.();
    container[MARK]?.unmount();
  }

  function close() {
    currentConfig = {
      ...currentConfig,
      open: false,
      afterClose: () => {
        config.afterClose?.();
        destroy();
      },
    };
    render(currentConfig);
  }

  render(currentConfig);
}

此时结束动画可以正常播放了:

20240628175753_rec_.gif

此时的动画还是以对话框本身为中心进行放大、缩小的,那么如何实现像antd一样的以点击位置为中心呢?基本原理就是通过修改transformOrigin实现,需要将对话框的transformOrigin从默认值(对话框中心)修改为鼠标点击的位置。由于transformOrigin的值是相对于元素本身来定位的,我们需要获取鼠标点击的坐标以及对话框在无动画时的坐标。但目前的实现方式中,Modal的open参数变为true时,对话框的放大动画就会立即开始,这种情况下无法获取到对话框在无动画时的坐标。我们需要在open变为true时先暂停动效,将对话框正常渲染(但设置为完全透明),并获取对话框的坐标,根据此坐标设置transformOrigin之后,再启动动效。至于鼠标点击的坐标,antd的实现方式是在HTML根元素上绑定click事件来进行记录。实现代码如下:

let mousePosition: { x: number; y: number } | null;

document.documentElement.addEventListener(
  "click",
  (e) => {
    mousePosition = {
      x: e.clientX,
      y: e.clientY,
    };

    setTimeout(() => {
      mousePosition = null;
    }, 100);
  },
  true
);

export const Modal = (props: ModalProps) => {
  const { open, onCancel, children, afterClose } = props;

  const [animatedVisible, setAnimatedVisible] = useState(false);
  const [originGot, setOriginGot] = useState(false)
  const [transformOrigin, setTransformOrigin] = useState('center')

  useEffect(() => {
    if (open) {
      setAnimatedVisible(true);
    } else {
      setOriginGot(false)
    }
  }, [open]);

  const contentRef = useRef<null | HTMLDivElement>(null)
  useLayoutEffect(() => {
    if (open) {
      const rect = contentRef.current?.getBoundingClientRect()
      console.warn('pos', rect)

      setTransformOrigin(mousePosition === null || !rect ? 'center' : `${mousePosition.x - rect.left}px ${mousePosition.y - rect.top}px`)
      setOriginGot(true)
    }
  }, [open])

  return (
    <Portal open={open || animatedVisible}>
      <div>
        <div className="modal-mask" />
        <div className="modal-wrap">
          <div
            ref={contentRef}
            className={classNames("modal-content", {
              "modal-content-enter": open,
              "modal-content-enter-prepare": open && !originGot,
              "modal-content-enter-active": open && originGot,
              "modal-content-leave": !open,
            })}
            style={{
              transformOrigin,
            }}
            onAnimationEnd={() => {
              if (open) {
                return;
              }

              console.warn("animation end");
              setAnimatedVisible(false);
              afterClose?.();
            }}
          >
            <div>{children}</div>
            <div className="modal-btn-container">
              <button onClick={onCancel}>cancel</button>
            </div>
          </div>
        </div>
      </div>
    </Portal>
  );
};

此时实际效果如下所示

20240628192204_rec_.gif

如果我们要实现一个组件库,像这种需要先暂停动效进行一些处理然后再启动的场景是比较常见的,如果在各个组件中分别进行处理则不易于维护。antd抽象出了一个CSSMotion组件(位于rc-motion包),将动效划分为不同的阶段,并赋予不同的类名,使用者只需要实现各个类名下的样式、利用各个阶段的回调进行相应处理即可。具体来说,CSSMotion将一个拥有动效的元素的状态分为4种:none、appear、enter和leave,其中none代表当前元素处于静止状态,没有动效;appear和enter代表元素正在发生从无到有的动效(例如对话框的打开过程);leave代表元素正在发生从有到无的动效(例如对话框的关闭过程)。对于appear、enter和leave,CSSMotion又将动画过程划分为多个阶段,包括prepare、start、active和end。阶段的转换是通过requestAnimationFrame实现的,每隔两帧就进入下一阶段。每个阶段都会触发相应的回调。CSSMotion会将状态和阶段拼接成类名给到子组件。这里我也仿照它的原理自己实现了一个简单的CSSMotion组件,将状态简化为none、enter和leave,将阶段简化为prepare和active,具体实现可以看这里

这样我们就可以基于CSSMotion来实现Modal的动效了,对于上面的场景,可以在enter-prepare阶段暂停动效并在回调中获取对话框坐标,然后在enter-active阶段启动动效。与此同时,我们也把mask的动效增加上去。最终Modal组件的实现如下:

let mousePosition: { x: number; y: number } | null;

document.documentElement.addEventListener(
  "click",
  (e) => {
    mousePosition = {
      x: e.clientX,
      y: e.clientY,
    };

    setTimeout(() => {
      mousePosition = null;
    }, 100);
  },
  true
);

export const Modal = (props: ModalProps) => {
  const { open, onCancel, children, afterClose } = props;

  const [animatedVisible, setAnimatedVisible] = useState(false);
  const [transformOrigin, setTransformOrigin] = useState("center");

  useEffect(() => {
    if (open) {
      setAnimatedVisible(true);
    }
  }, [open]);

  const contentRef = useRef<null | HTMLDivElement>(null);

  return (
    <Portal open={open || animatedVisible}>
      <div>
        <CSSMotion motionName="modal-mask" visible={open}>
          {(classname, ref) => {
            return <div ref={ref} className={classname} />;
          }}
        </CSSMotion>
        <div className="modal-wrap">
          <CSSMotion
            motionName="modal-content"
            visible={open}
            onVisibleChanged={(newVisible) => {
              if (newVisible) {
                return;
              }

              setAnimatedVisible(false);
              afterClose?.();
            }}
            onEnterPrepare={() => {
              const rect = contentRef.current?.getBoundingClientRect();
              setTransformOrigin(
                mousePosition === null || !rect
                  ? "center"
                  : `${mousePosition.x - rect.left}px ${
                      mousePosition.y - rect.top
                    }px`
              );
            }}
            ref={contentRef}
          >
            {(classname, ref) => {
              return (
                <div
                  ref={ref}
                  className={classname}
                  style={{
                    transformOrigin,
                  }}
                >
                  <div>{children}</div>
                  <div className="modal-btn-container">
                    <button onClick={onCancel}>cancel</button>
                  </div>
                </div>
              );
            }}
          </CSSMotion>
        </div>
      </div>
    </Portal>
  );
};
.modal-mask {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  top: 0;
  background-color: rgba(55, 55, 55, 0.6);

  &-enter {
    animation-name: fadeIn;
    animation-duration: 0.3s;
    animation-timing-function: linear;
    animation-fill-mode: both;
  }

  &-leave {
    animation-name: fadeOut;
    animation-duration: 0.3s;
    animation-timing-function: linear;
    animation-fill-mode: both;
  }
}

.modal-wrap {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  top: 0;
}

.modal-content {
    position: relative;
    top: 100px;
    margin: 0 auto;
    width: 416px;
    max-width: 100vw;
    background-color: #ffffff;
    border-radius: 8px;
    padding: 20px 24px;

    &-enter {
      animation-duration: 0.3s;
      animation-timing-function: ease-out;
      animation-fill-mode: both;

      &-prepare {
        animation-play-state: paused;
        transform: none;
        opacity: 0;
      }

      &-active {
        animation-name: zoomIn;
        animation-play-state: running;
      }
    }

    &-leave {
      animation-name: zoomOut;
      animation-duration: 0.3s;
      animation-timing-function: ease-in-out;
      animation-fill-mode: both;
    }
}

.modal-btn-container {
    text-align: right;
}

@keyframes fadeIn {
  from {
    opacity: 0;
  }

  to {
    opacity: 1;
  }
}

@keyframes fadeOut {
  from {
    opacity: 1;
  }

  to {
    opacity: 0;
  }
}

@keyframes zoomIn {
  from {
    transform: scale(0.2);
    opacity: 0;
  }

  to {
    transform: scale(1);
    opacity: 1;
  }
}

@keyframes zoomOut {
  from {
    transform: scale(1);
    opacity: 1;
  }

  to {
    transform: scale(0.2);
    opacity: 0;
  }
}

最终效果如下

20240628211102_rec_.gif

项目的完整代码可以在这里看到