前言
紧接上篇文章 用 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>
实现的效果如下:
上面的这种 Modal
组件封装是比较常用的一种方式,基本可以 cover 大部分场景。但是不知道大家是否遇到过这种场景:
图中的四个红框点击后都会弹出 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:
其实很多组件库中的 Modal
组件都是带有 API 调用的方式的,通过 API 来弹出一些简单的 Modal ,在实现功能时代码量更少,而且使用起来也会更加方便,例如下面这种调用方式:
showModal({
title: "Modal with Alert",
children: "Simple content",
okText: "OK",
onOk: () => {
console.log("onOk")
},
})
那么下面我们将以此为目标,在原有 Modal 组件的基础上,补充一个通过 API 弹出组件的方式。
API 实现
第一步我们要梳理下思路,要想不在页面中引入组件并挂载,那么我们就需要在调用方法时自动将组件挂载到页面上。其次是我们不需要自己去维护 Modal 的打开状态,那就需要将状态单独维护在一个地方,因此需求就是以下两点:
- 自动挂载组件
- 无需维护状态
实现自动挂载组件
我们先来实现第一点,要实现自动组件挂载,我们就需要在调用函数时执行 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.current
和 modalDom
都存在时,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
类型的参数,将 isOpen
和modalProps
的状态设置为 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>
实现效果:
调用 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
的回调函数中,我们为 onOK
和 onCancel
赋值 resovle
和 reject
,只有在用户点击 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
时判断下事件类型,传入不同参数即可。最终实现效果:
总结
两种使用方式可以根据使用习惯或是实际业务进行选择,使用起来都挺方便的。组件系列在后续还会接着出,欢迎关注。如果觉得文章对你有帮助的话,除了收藏外还可以为我点个赞 👍,respect!
本文正在参加「金石计划」
原文链接:https://juejin.cn/post/7217984635062370364 作者:oil欧哟