最近忙的不可开交,每天恨不得把时间掰开使用,挣不到钱还没时间,有时候我在想我怎么混得这个样子。题外话不多说,从这节课开始,我把这个模态框的教程写完整。请看效果:
这个模态框功能相对比较完整,应该能满足大部分的使用场景了,相信看完这个系统的文章后,你应该就能开发出一个自己的模态框了。
其实看似简单,其实比较复杂的,react
有其天生的缺陷,如何绕开它的缺点,我们就要多思考,把React
文档看透才行,多练习,多实践,多做笔记,这样我们才能少走弯路。
我的文章都有关联性,学习这个之前你最好先看看我之前的Rect的相关的文章,这样才能更好的理解本系列的内容。对于一个复杂的功能组件我们要把它细分成多个小部分,然后通过状态整合在一起,这样设计和管理起来都比较好。
模态框分为几个部分:遮罩、弹窗主体、状态、事件,其事主体又可分为多小组件:标题栏、控制区、内容区、功能区。所以你看,能把这么多的组件及功能整合在一起也是一件不简单事哦。我们先从第一个遮罩开始:
遮罩就是背景那个阴暗的部分。它是一个div
,黑色的带透明度的全屏组件。还有一点说明,我所有的样式都是用@emotion/react
书写的。
_ModelMask.jsx
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react'
import { useState, useEffect } from 'react';
import Box from '@mui/material/Box';
const maskCss = css`
position: fixed;
background-color: rgba(0,0,0,0.4);
border-radius: 5px;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
overflow: hidden;
z-index: 999;
opacity: 0;
transition: opacity 200ms ease-in-out;
display: flex;
justify-content: center;
align-items: center;
`;
const showMaskCss = css`
opacity: 1;
`;
/**
* 弹窗遮罩
* @returns
*/
function ModelMask({ children }) {
const [isVisible, setIsVisible] = useState(false); // 开启渐显动画
// 弹窗的动画监听
useEffect(function () {
setIsVisible(true);
}, []);
return (
<Box
css={css`
${maskCss};
${isVisible && showMaskCss}
`
}
>
{
children
}
</Box>
);
};
export default ModelMask;
很简单, useEffect
的作用是在组件挂载后开启渐显动画。就是透明度从 0 变化到 1。css
部分大家应该都能理解。
看前面的效果图中,当点击遮罩时弹窗是有个动作的,我们要么把弹窗关闭,要么让弹窗有个动态效果提示用户注意,也就是注目效果
。总之,这个事件要能分别点击点是遮罩还是弹窗主体。如何我们把事件写在ModelMask里,那么就意味着这个事件要层层传递,直到要使用它的子组件中。 如果不想通过层层传递的方式 ,我们可使用的方法也有很多,比如redux的方式、Context的方式等等。各种方法都有利弊。我比较倾向于hook的方式,咱就是图个方便。
_useOutsideClick.jsx
import { useEffect } from "react";
/**
* element.addEventListener(event, function () { }, false);
* addEventListener()基本上有三个参数,
* 「事件名称 string」,指定事件的名称。如「click」、「mousedown」、「touchstart」等。
* 「事件的处理程序」(事件触发时执行的function)
* 「Boolean」值,由这个Boolean决定事件是以「捕获」还是「冒泡」机制执行,若不指定则预设为「冒泡」。
* 那么事件是先捕获再冒泡的
* 捕获(true):从启动事件的元素节点开始,逐层往下传递
* 冒泡(false):逐层向上依序被触发
*/
/**
* 点击组件外部事件,用于弹窗关闭
* @param { 要排除的组件节点 } ref
* @param { 组件外点击事件 } onOutsideClick
*/
export const useOutsideClick = (ref, onOutsideClick) => {
useEffect(() => {
function handleClickOutside(event) {
if (ref.current && !ref.current.contains(event.target)) {
onOutsideClick && onOutsideClick();
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [ref, onOutsideClick]);
};
我们向document添加了一个mousedown的事件监听,判断鼠标当下是不是ref对象就可以了,这样就能监听到组件外是否点击了。记住,我们向document添加的监听事件一定要在组件卸载时移除。
当然,同样的事情我们也以用组件包裹的方式实现。
_OutsideClick.jsx
import React, { useEffect, useRef } from 'react';
import Box from '@mui/material/Box';
/**
* 检测点击元素是否在ref元素外部,如果在外部则执行onOutsideClick
* @param { 当点击外部时要执行的事件 } onOutsideClick
* @returns
*/
export default function OutsideClickCheck({ children, onOutsideClick }) {
const ref = useRef();
useEffect(() => {
const listener = (event) => {
if (ref.current && !ref.current.contains(event.target)) {
onOutsideClick && onOutsideClick();
}
};
document.addEventListener("click", listener, true);
return () => {
document.removeEventListener("click", listener, true);
};
}, [ref, onOutsideClick]);
return (
<Box ref={ref}>
{
children
}
</Box>
);
}
同样的原理我们换了一种方式来实现。可根据不同场景来选择使用。
至于这个弹窗如何呈现现,我在前面的章节中已经说明了,也同样有多种方法,Provider
的方式、root.Render
的方式、createPortal
的方式等等等等。这里我采用ReactRender
的方式。我再来设计一个Hook,用于弹窗的渲染。我认为这种方式最简单最粗暴,这一波我必须逼格满满。
_useModel.jsx
import React, { useContext } from 'react';
import ReactDOM from 'react-dom/client';
import { useSTheme } from '../STheme/useToggleThemeHook';
export const ModelContext = React.createContext(null);
export const useModelState = () => useContext(ModelContext);
/**
*
* @param {弹窗标题} title
* @param {弹窗的类型, 可选, } level
* @param {是否显示控制按钮组} enableController
* @returns
*/
export default function useModel(configure) {
const theme = useSTheme(); //获取主题
const config = {
sizeMode:"sm", //弹窗的大小
level: "default", // 弹窗的类型(主要是颜色类型),选项有:normal, error, warning, success, info
title: "提示", //标题
enableDragging: false, // 是否允许拖拽
enableController: false, //是否显示控制按钮
content: "暂无弹窗内容", //弹窗内容
actions : [ //操作按钮
{
title: "确定", //按钮标题
attention: false, //是否为操作按钮
onClick: (setLoading, setTitle, setDisable, onClose) => { onClose(); } //按钮回调
},
],
...configure
}
return (Component) => {
const { children, ...others } = config;
// const Component = component || null;
// 创建一个div容器,作为弹窗的根节点
const modelContainer = document.createElement("div");
// 将div容器添加到body中
document.body.appendChild(modelContainer);
// 创建一个根节点
const modelRoot = ReactDOM.createRoot(modelContainer);
// 卸载事件
const unmountEvent = () => {
modelContainer.remove();
modelRoot.unmount();
}
const setContent = (content) => {
config.content = content;
};
modelRoot.render(
<Component
{...others}
onClose={unmountEvent}
setContent={setContent}
isDark={ theme.mode === "dark" ? true : false } // 是否是暗黑模式
/>
);
}
}
关于useSTheme
主题的设计请参考我布局菜单系列的文章,那里讲得很通透了。配置参数已经很明了了,我都作了说明。使用的时候这样使用就行了:
const alert = useModel({...});
...
// 然后在事件里直接调用
alert(Model);
那么这个Model 就是我们的弹窗主体了。设计如下:
_Model.jsx
/** @jsxImportSource @emotion/react */
import { css, jsx, keyframes } from '@emotion/react'
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { ModelContext } from './_useModel';
import SThemeProvider from '../STheme/SThemeProvider';
import { useSTheme } from '../STheme/useToggleThemeHook';
function Model(props) {
const {
sizeMode = "sm", //弹窗的大小
level = "default", // 弹窗的类型(主要是颜色类型),选项有:normal, error, warning, success, info
title = "提示", //标题
isDark,
onClose, //关闭弹窗后的回调
enableDragging = true,
enableController = true, //是否显示控制按钮
content = "暂无弹窗内容", //弹窗内容
actions = [ //操作按钮
{
title: "确定", //按钮标题
attention: false, //是否为操作按钮
onClick: (setLoading, setTitle, setDisable, onClose) => { onClose(); } //按钮回调
},
],//功能按钮
} = props;
const [stateMode, setStateMode] = useState(1); // 弹窗的状态,0: 最小化, 1: 正常, 2: 最大化
const theme = useSTheme(); //获取主题
console.log(`theme => ${theme}`);
return (
<SThemeProvider isDark={isDark}>
<ModelContext.Provider value={{
stateMode, // 弹窗的状态,0: 最小化, 1: 正常, 2: 最大化
setStateMode, // 设置弹窗的状态
sizeMode, //弹窗最大宽度
onClose, //关闭弹窗的回调
isDark, //是否是暗黑模式
level, // 弹窗的类型(主要是颜色类型),选项有:normal, error, warning, success, info
}}>
...
</ModelContext.Provider>
</SThemeProvider>
);
};
export default Model;
我们在这个组件里用了一个主题provider
,这是因为我们弹窗是通过Render的方式显示的,是一个独立的React根组件,所以这个主题必须从Model内部手动管理。我们把还把Model相关的一些功能放到别一个Provider中了,这样子组件就可以直接使用相关的功能而不需要通过Props来传递了。
我这里还写了一个整个组件的配置文件:
_ModelConfigure.jsx
import InfoIcon from '@mui/icons-material/Info';
import TaskAltOutlinedIcon from '@mui/icons-material/TaskAltOutlined';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import WarningAmberOutlinedIcon from '@mui/icons-material/WarningAmberOutlined';
import ErrorOutlineOutlinedIcon from '@mui/icons-material/ErrorOutlineOutlined';
import pink from '@mui/material/colors/pink';
import orange from '@mui/material/colors/orange';
import green from '@mui/material/colors/green';
import blue from '@mui/material/colors/blue';
import grey from '@mui/material/colors/grey';
//弹窗的大小选项
export const widthType = {
sm: 576,
md: 768,
lg: 992,
xl: 1200,
xxl: 1400,
};
//弹窗的图标大小
export const iconSize = 25;
//弹窗的图标类型、颜色
export const infoLevel = {
info: { color: blue[50], divider: blue[70], level: "info", Icon: InfoOutlinedIcon, iColor: blue[500]},
error: { color: pink[50], divider: pink[70], level: "error", Icon: ErrorOutlineOutlinedIcon, iColor: pink[500]},
warning: { color: orange[50], divider: orange[70], level: "warning", Icon: WarningAmberOutlinedIcon, iColor: orange[500]},
success: { color: green[50], divider: green[70], level: "success", Icon: TaskAltOutlinedIcon, iColor: green[500]},
default: { color: grey[50], divider: grey[70], level: "primary", Icon: InfoIcon , iColor: grey[500]}
}
export const minHeight = 45; //弹窗的最小高度
export const minWidth = 300; //弹窗的最小宽度
接下来就是弹窗的主体设计了。下回分解。