🤔 使用 React Hook 实现 Modal 组件 API 调用两种方式

2,458 阅读7分钟

前言

紧接上篇文章 用 React Hook + ChakraUI 实现一个丝滑菜单组件,这篇文章来为大家介绍如何使用 React Hook 实现 Modal 组件 API 调用方式。

实现过程

Modal 组件封装

这里我使用的是 ChakraUI 提供的组件进行封装,实现的代码如下:

// Modal/index.tsx
import {
  Modal as ChakraModal,
  ModalOverlay,
  ModalContent,
  ModalFooter,
  ModalHeader,
  Box,
  Alert,
  AlertIcon,
  AlertDescription,
  Flex,
  ModalCloseButton,
  Button,
} from "@chakra-ui/react";
import { motion } from "framer-motion";

import ModalBody from "./ModalBody";
import { ModalProps } from "./types";
import StatusIcon from "./StatusIcon";

const Modal: React.FC<ModalProps> = ({
  isOpen,
  title = "",
  okText = "OK",
  status,
  cancelText = "Cancel",
  onClose,
  onOk,
  isLoading = false,
  showAlert = false,
  alertText = "",
  scrollBehavior = "inside",
  showCloseIcon = true,
  showCancel = true,
  okButtonProps = {},
  cancelButtonProps = {},
  alertProps = {},
  children,
  ...rest
}) => {
  return (
    <ChakraModal
      size="xl"
      isOpen={isOpen}
      onClose={onClose}
      closeOnOverlayClick
      isCentered
      scrollBehavior={scrollBehavior}
      trapFocus={false}
      {...rest}
    >
      <ModalOverlay backdropFilter="blur(5px)" />
      <ModalContent width="588px" borderRadius="4px" position="relative">
        <Flex align="center" justify="space-between" p="16px">
          <ModalHeader
            color="gray.700"
            fontWeight="medium"
            fontSize="lg"
            flex="1"
            p="0"
            display="flex"
            alignItems="center"
          >
            {status && <StatusIcon status={status} mr="3" />}

            {title}
          </ModalHeader>

          {showCloseIcon && (
            <ModalCloseButton
              className="chakra-modal__close-btn"
              onClick={onClose}
            />
          )}
        </Flex>

        {showAlert && (
          <motion.div
            animate={{ scale: [0, 1], y: 0 }}
            transition={{ duration: 0.3 }}
          >
            <Box>
              <Alert status="error" {...alertProps}>
                <AlertIcon boxSize="20px" />
                <AlertDescription color="gray.700">
                  {alertText}
                </AlertDescription>
              </Alert>
            </Box>
          </motion.div>
        )}
        <ModalBody scrollBehavior={scrollBehavior}>{children}</ModalBody>

        <ModalFooter h="73px">
          {showCancel && (
            <Button onClick={onClose} size="md" {...cancelButtonProps}>
              {cancelText}
            </Button>
          )}
          {onOk && (
            <Button
              ml="12px"
              onClick={onOk}
              size="md"
              isLoading={isLoading}
              colorScheme="purple"
              {...okButtonProps}
            >
              {okText}
            </Button>
          )}
        </ModalFooter>
      </ModalContent>
    </ChakraModal>
  );
};

export { default as useModal } from "./useModal";

export default Modal;

基本上就是将 ChakraUI 的多个 Modal 关联组件组合在一起,并根据需求补充了一些 props。

在上面的代码中引入了一个 ModalBody 组件,在这个组件中我实现了一个超出视图范围显示滚动提示的功能,下面直接放上实现的代码:

// Modal/ModalBody.tsx

import React, { useState, useRef, useMemo, SyntheticEvent } from "react";

import { Box, Icon, ModalBody as ChakraModalBody } from "@chakra-ui/react";
import { useSize, useThrottleFn } from "ahooks";
import { AnimatePresence, motion } from "framer-motion";
import { BiChevronDown } from "react-icons/bi";

const SCROLL_TIP_HEIGHT = 32;
const SCROLL_TIP_BOTTOM = 72;

const ModalBody: React.FC<{
  children: React.ReactNode;
  scrollBehavior?: "outside" | "inside";
}> = ({ children, scrollBehavior = "inside" }) => {
  const [scrollTop, setScrollTop] = useState(0);
  const [showScrollTip, setShowScrollTip] = useState(true);
  const contentRef = useRef(null);
  const contentSize = useSize(contentRef);

  const bodyRef = useRef(null);
  const bodySize = useSize(bodyRef);

  // Determine if the current container is scrollable
  const isScroll = useMemo(() => {
    if (
      contentSize?.height &&
      bodySize?.height &&
      contentSize.height > bodySize.height + 20
    ) {
      return true;
    }
    return false;
  }, [contentSize?.height, bodySize?.height]);

  const { run: handleScroll } = useThrottleFn(
    (e: SyntheticEvent<HTMLDivElement>) => {
      const {
        scrollHeight,
        scrollTop: currentScrollTop,
        offsetHeight,
      } = e.target as HTMLDivElement;
      if (isScroll) {
        // Triggered when scrolling to SCROLL_TIP_HEIGHT from the bottom
        if (
          scrollHeight - currentScrollTop - offsetHeight <=
          SCROLL_TIP_HEIGHT
        ) {
          setShowScrollTip(false);
          // Triggered when scrolling up
        } else if (currentScrollTop < scrollTop) {
          setShowScrollTip(true);
        }
      }
      setScrollTop(currentScrollTop);
    },
    { wait: 200, leading: true }
  );

  return (
    <>
      <ChakraModalBody
        maxHeight={scrollBehavior === "inside" ? "596px" : "auto"}
        p="24px"
        ref={bodyRef}
        onScroll={handleScroll}
      >
        <Box ref={contentRef}>{children}</Box>
      </ChakraModalBody>
      <AnimatePresence>
        {isScroll && showScrollTip && (
          <>
            <motion.div
              style={{
                display: "flex",
                justifyContent: "center",
                alignItems: "center",
                position: "absolute",
                bottom: SCROLL_TIP_BOTTOM,
                left: "0",
                backgroundImage:
                  "linear-gradient(to bottom, rgba(255,255,255,0.6), rgba(255,255,255,1))",
                width: "100%",
              }}
              initial={{ opacity: 0, y: 10 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: 10 }}
              transition={{ duration: 0.2 }}
            >
              <Icon
                as={BiChevronDown}
                width={`${SCROLL_TIP_HEIGHT}px`}
                height={`${SCROLL_TIP_HEIGHT}px`}
                color="gray.500"
                fontWeight="200"
              />
            </motion.div>
          </>
        )}
      </AnimatePresence>
    </>
  );
};
export default ModalBody;

具体代码的解析和实现的过程可以看我的这篇文章: 🤔提升用户体验,给你的模态弹窗加个小细节

在使用组件时,我们需要维护 isOpen 的状态,手动控制 Modal 组件的打开和关闭。

const {
    isOpen: normalIsOpen,
    onClose: onNormalClose,
    onOpen: onNormalOpen,
  } = useDisclosure();
  
<Button onClick={onNormalOpen}>Open Modal</Button>
<Modal
    title="Normal Modal"
    isOpen={normalOpen}
    onClose={onNormalClose}
    onOk={() => {
      console.log("onOk");
    }}
    >
    {faker.lorem.paragraphs(10)}
</Modal>

实现的效果如下:

image.png

上面的这种 Modal 组件封装是比较常用的一种方式,基本可以 cover 大部分场景。但是不知道大家是否遇到过这种场景:

image.png

图中的四个红框点击后都会弹出 Modal ,在实现页面时我们就需要维护四份 Modal 状态:

const {
    isOpen: aIsOpen,
    onClose: onAClose,
    onOpen: onAIOpen,
  } = useDisclosure();
  
  const {
    isOpen: bIsOpen,
    onClose: onBClose,
    onOpen: onBOpen,
  } = useDisclosure();
  
const {
    isOpen: cIsOpen,
    onClose: onCClose,
    onOpen: onCOpen,
  } = useDisclosure();
  
  const {
    isOpen: dIsOpen,
    onClose: onDClose,
    onOpen: onDOpen,
  } = useDisclosure();
  
<Modal isOpen={aIsOpen}>A Modal</Modal>
<Modal isOpen={bIsOpen}>B Modal</Modal>
<Modal isOpen={cIsOpen}>C Modal</Modal>
<Modal isOpen={dIsOpen}>D Modal</Modal>

而除去一些包含复杂表单的 Modal,其他的 Modal 基本都具有相似的逻辑,且整体的样式也比较简单,例如下图这种确认 Modal:

image.png

其实很多组件库中的 Modal 组件都是带有 API 调用的方式的,通过 API 来弹出一些简单的 Modal ,在实现功能时代码量更少,而且使用起来也会更加方便,例如下面这种调用方式:

showModal({
  title: "Modal with Alert",
  children: "Simple content",
  okText: "OK",
  onOk: () => {
    console.log("onOk")
  },
})

那么下面我们将以此为目标,在原有 Modal 组件的基础上,补充一个通过 API 弹出组件的方式。

API 实现

第一步我们要梳理下思路,要想不在页面中引入组件并挂载,那么我们就需要在调用方法时自动将组件挂载到页面上。其次是我们不需要自己去维护 Modal 的打开状态,那就需要将状态单独维护在一个地方,因此需求就是以下两点:

  1. 自动挂载组件
  2. 无需维护状态

实现自动挂载组件

我们先来实现第一点,要实现自动组件挂载,我们就需要在调用函数时执行 react 中相关的渲染方法,这里我是将 showModal 方法写在 useModal 这个 hook 中。当执行 hook 时就将 Modal 挂载到页面上, 实现代码如下

const useModal = () => {
  const root = useRef<Root>();

  useEffect(() => {
    const modalDom = document.createElement("div");
    document.body.appendChild(modalDom);
    modalDom.id = "modal-portal";

    if (!root.current) {
      root.current = createRoot(modalDom);
    }

    return () => {
      root.current?.unmount();
      document.removeChild(modalDom);
    };
  }, []);

  useEffect(() => {
    const modalDom = document.getElementById("modal-portal");
    if (root.current && modalDom) {
      root.current.render(
        <ChakraProvider>
          <Modal
            isOpen
          />
        </ChakraProvider>
      );
    }
  }, [root.current]);
};

export default useModal;

在上面的代码中,第一个 useEffect 函数用于创建 Modal 的根元素,并在组件卸载时移除该元素。createRoot 是用于在根节点上渲染 React 组件的 API。在这里,它创建了一个新的根元素 modalDom,并将其附加到 document.body 上。

第二个 useEffect 函数则用于在模态框的 props 变化时更新模态框。当 root.currentmodalDom都存在时,root.current.render 会将 <Modal> 组件渲染到 modalDom 根元素中。

这样当我们在组件中调用 useModal hook 时就会将 <Modal> 组件自动挂载到页面中了,且组件卸载时也会将 Modal 移除。

维护 Modal 状态

接下来我们在 useModal 补充一下 Modal 的状态,实现代码如下:

import { useEffect, useRef, useState } from "react";
import Modal from "./index";
import { ChakraProvider, useBoolean, useDisclosure } from "@chakra-ui/react";
import { Root, createRoot } from "react-dom/client";
import { ModalProps } from "./types";

type UseModalProps = Omit<
  ModalProps,
  "isOpen" | "onClose" | "onOpen" | "isLoading" | "onOk"
> & {
  onOk?: () => Promise<void> | void;
};

const useModal = () => {
  const root = useRef<Root>();
  const [modalProps, setModalProps] = useState<UseModalProps>({
    title: "",
    children: null,
  });

  const onOk = useRef<() => void>();
  const { isOpen, onOpen, onClose } = useDisclosure();
  const [isLoading, { on: showLoading, off: hideLoading }] = useBoolean();

  useEffect(() => {
    const modalDom = document.createElement("div");
    document.body.appendChild(modalDom);
    modalDom.id = "modal-portal";

    if (!root.current) {
      root.current = createRoot(modalDom);
    }

    return () => {
      root.current?.unmount();
      document.removeChild(modalDom);
    };
  }, []);

  useEffect(() => {
    const modalDom = document.getElementById("modal-portal");
    if (root.current && modalDom) {
      root.current.render(
        <ChakraProvider>
          <Modal
            {...modalProps}
            isOpen={isOpen}
            onClose={onClose}
            onOk={onOk.current}
            okButtonProps={{
              isLoading,
            }}
          />
        </ChakraProvider>
      );
    }
  }, [root.current, modalProps, isOpen, onClose, onOk, isLoading]);

export default useModal;

基于前面自动挂载 Modal 的基础上,我增加了 onClose, onOk, isOpen, isLoading 等状态,并且通过这个工具类型 Omit 将原本需要传入的 props 给忽略了,避免使用 Hook 时重复传入导致错误。

实现 showModal

最后我们再补充上最关键的 showModal 方法:

  const showModal = (props: UseModalProps) => {
    onOpen();
    setModalProps(props);

    if (props.onOk) {
      onOk.current = async () => {
        showLoading();
        if (
          typeof props.onOk === "function" &&
          props.onOk.constructor.name === "AsyncFunction"
        ) {
          await props.onOk();
        } else {
          props.onOk?.();
        }
        hideLoading();
        onClose();
      };

      onClose();
    }
  };

showModal 函数是用于显示 Modal 的函数。它接收一个UseModalProps 类型的参数,将 isOpenmodalProps 的状态设置为 true 和传入的参数。

onOk 回调函数中,通过 showLoading() 函数显示加载动画,然后执行 onOk 属性指定的回调函数。如果回调函数是一个异步函数,则使用 await 关键字等待函数返回,否则直接调用回调函数。最后,通过 hideLoading() 隐藏加载动画,并将 isOpen 状态设置为 false

hook 的使用方式:

const showModal = useModal();

<Button
  onClick={() =>
    showModal({
      title: "Modal with Alert",
      children: "Simple content",
      okText: "OK",
      onOk: async () => {
        return new Promise((res) => {
          setTimeout(() => {
            showToast({
              title: "onOK",
              status: "success",
              position: "top-right",
            });
            res();
          }, 2000);
        });
      },
    })
  }
>
  Open Modal with async API
</Button>

实现效果:

temp.gif

调用 showModal 时,我们直接将组件的 props 传入函数,比较特别的是 onOk 函数,它可以是一个异步函数,当它是异步函数时,Modal 组件会自动修改 loading 状态。

改造 showModal 为异步函数

当然,这只是实现方法之一,不知道大家是否有用过小程序的 showModal 函数,它的实现方式是将 showModal 作为一个异步函数,返回一个 Promise 值:

wx.showModal({
  title: '提示',
  content: '这是一个模态弹窗',
}).then((res)=>{
    if (res.confirm) {
      console.log('用户点击确定')
    } else if (res.cancel) {
      console.log('用户点击取消')
    }
})

使用这种方式,用户不需要传入 onOK 函数了,而是在点击 Modal 的确定或取消后,自己在 .then 中做处理,我们要做的就是将用户点击的结果在 Promise 中返回。

如果我们也想实现这种效果该怎么做呢,直接上代码:


type UseModalProps = Omit<
  ModalProps,
  "isOpen" | "onClose" | "onOpen" | "isLoading" | "onOk"
>;

const useModal = () => {
  const onOk = useRef<() => void>();
  const onCancel = useRef<() => void>();
  const { isOpen, onOpen, onClose } = useDisclosure();
  const [isLoading, { on: showLoading, off: hideLoading }] = useBoolean();

  const showModal: (props: UseModalProps) => Promise<{
    showLoading: () => void;
    hideLoading: () => void;
    onClose: () => void;
  }> = (props) => {
    onOpen();
    setModalProps(props);

    return new Promise((resolve, reject) => {
      onOk.current = () => {
        resolve({
          showLoading,
          hideLoading,
          onClose,
        });
      };

      onCancel.current = () => {
        reject();
        onClose();
      };
    });
  };

  return showModal;
};

export default useModal;

在上面的代码中,我将 showModal 改造为一个异步函数,调用时会先将 Modal 打开并传入 props,接下来返回一个 Promise, 在 Promise 的回调函数中,我们为 onOKonCancel 赋值 resovlereject ,只有在用户点击 OK 或是 Cancel 时这个异步函数才会执行结束。

这里我还为 resovle 传入了 showLoading, hideLoading, onClose 三个函数,方便我们在使用 showModal 时实现一些异步操作。

有一个注意点是之所以使用 useRef 存储函数,是因为使用 useState 存储函数比较麻烦,还有些坑,这里不展开讲。

改造后的 showModal 使用方式如下:

<Button
  onClick={() =>
    showModal({
      title: "Modal with Alert",
      children: "Simple content",
      okText: "OK",
    })
      .then(({ showLoading, hideLoading, onClose }) => {
        showLoading();
        setTimeout(() => {
          showToast({
            title: "onOK",
            status: "success",
            position: "top-right",
          });
          hideLoading();
          onClose();
        }, 2000);
      })
      .catch(() => {
        console.log("onClose");
      })
  }
>
  Open Modal with async API
</Button>

当然了,如果你不想在 catch 处理关闭逻辑,你也可以都在 .then 里处理,只需要在 resolve 时判断下事件类型,传入不同参数即可。最终实现效果:

temp.gif

总结

两种使用方式可以根据使用习惯或是实际业务进行选择,使用起来都挺方便的。组件系列在后续还会接着出,欢迎关注。如果觉得文章对你有帮助的话,除了收藏外还可以为我点个赞 👍,respect!

本文正在参加「金石计划」