React 模态框的设计(四)状态管理

最近忙的不可开交,每天恨不得把时间掰开使用,挣不到钱还没时间,有时候我在想我怎么混得这个样子。题外话不多说,从这节课开始,我把这个模态框的教程写完整。请看效果:

这个模态框功能相对比较完整,应该能满足大部分的使用场景了,相信看完这个系统的文章后,你应该就能开发出一个自己的模态框了。

其实看似简单,其实比较复杂的,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; //弹窗的最小宽度 

接下来就是弹窗的主体设计了。下回分解。

你可能感兴趣的:(react.js,前端,前端框架,javascript,Material,UI,Emotion)