模态框的优化及异常修复
经过一段时间的应用,前面的模态框设计在单体应用上没什么问题,但也暴露出了不少的问题,比如,在多模态框弹出的时候会出现context的数据污染的问题,比如在多模态的情况下注目动画有时会失效,这些都是context数据污染造成的。还有一些小问题等等,这个章节我对这个模态框组件进行了大量而重要的优化,调整了部分数据结构,应用上已经达到比较完美的结果。这一节的知识点比较多,都是经验啊。先看优化后的效果图:
遮罩的点击事件
之前的设计版本中,我在整个文档中添加了监听事件,以触发弹窗体的动画,这样做有个弊端,就是当有多个弹窗时,点击弹窗外部时所有的可见弹窗都会触发注目动画。这不是我想要的结果,我们要的是点击动作只针对当前活动的弹窗起作用,所以,我现在将_ModelMask.jsx 和 _Draggable.jsx文件进行合并,将mask的点击事件直接写在合并后的代码中,修改_Draggable.jsx的代码如下:
...
export default function Draggable(props) {
...
// 当点击Mask时,弹窗会有一个注目动画效果
const maskClick = () => {
setAttentionStyle(anim);
}
...
return (
<Box
css={css`
${maskCss};
${isVisible && showMaskCss}
`
}
onClick={maskClick}
>
<Box
ref={wrapperRef}
sx={{
// 这里采用了绝对定位,设置了top的百分比,也可以不用设置定位方式,这样,top和position都不用设置,默认情况下,弹窗会在页面的中间。
// position: "absolute",
// top: '25%',
transform: `translate(${position.x}px, ${position.y}px)`,
cursor: canDrag ? isDragging ? "grabbing" : "grab" : "default",
transition: isDragging ? null : `transform 200ms ease-in-out`,
}}
onMouseDown={handleMouseDown}
onMouseMove={onMouseMove}
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
>
<Box
sx={{
transform: `${isDragging ? "scale(1.03)" : "scale(1)"}`,
transition: `transform 200ms ease-in-out`,
}}
css={attentionStyle}
>
{
children
}
</Box>
</Box>
</Box>
);
}
模态框体的颜色优化
之前对模态框体的背景定义了多种颜色,分别对应 normal、error、warning、success、info等5种场景,实践证明,这一步是多余的。 对_ModelContainer.jsx修改如下:
/** @jsxImportSource @emotion/react */
import { css } from '@emotion/react'
import { useContext, useLayoutEffect, useRef, useState } from 'react';
import { Paper } from '@mui/material';
import { infoLevel } from './_ModelConfigure';
// 计算不同状态下的高度
const calHeight = (sizeMode, normalHeight) => {
switch (sizeMode) {
case 0:
return '45px';
case 1:
return normalHeight > 0 ? normalHeight + 'px' : 'auto';
case 2:
return '100vh';
default:
return 'auto';
}
}
// 最大化时的固定样式
const maxSizeCss = css`
width: 100vw;
height: 100vh;
top: 0;
left: 0;
`;
/**
* 弹窗容器,用于包裹弹窗的内容,ModelBody内部使用的组件
* @param {*} param0
* @returns
*/
const ModelContainer = ({ children, context }) => {
const modelState = useContext(context);
const {
stateMode, // 弹窗的状态,0: 最小化, 1: 正常, 2: 最大化
level, // 弹窗的类型(主要是颜色类型),选项有:default, error, warning, success, info
isDark, //是否是暗黑模式
} = modelState;
const [nomalSize, setNormalSize] = useState({ width: 0, height: 0 });
const containerRef = useRef(null);
// 获取容器的大小
useLayoutEffect(() => {
if (containerRef.current !== null) {
const rect = containerRef.current.getBoundingClientRect();
setNormalSize({
width: rect.width,
height: rect.height,
});
}
}, []);
return (
<Paper
ref={containerRef}
css={css`
border: 1px solid #A0A0A0;
border-radius: 5px;
width: ${ stateMode === 2 ? '100vw' : stateMode === 0 ? '300px' : '576px' };
height: ${ calHeight(stateMode, nomalSize.height) };
overflow: hidden;
max-width: 100%;
max-height: 100vh;
display: flex;
flex-direction: column;
border: 1px solid ${infoLevel[level].color};
${stateMode === 2 ? maxSizeCss : null};
transition: all 0.3s;
`}
>
{
children
}
</Paper>
);
};
export default ModelContainer;
关于数据污染的解决方案和思路
由于是采用的模块化设计的思路,所以就绕不开模块与模块之前的数据通信的问题,由其是父组件与子孙组件的数据通信问题,这种情况下Context应当是首先方案, 我也是这么做的,但是之前我只考虑到单模态的场景,而没有考虑到多模态的场景,由其是嵌套弹窗的问题。比如,在一个弹窗中的按钮点击事件中又弹出一个弹窗,这种情况下如何处理Context的数据, 如果在新弹窗弹出后没有关闭父弹窗,那么就不会出现数据污染的问题,因为每一层级的Context的数据都有一个弹窗进行消费,所以就正常,但是如果在弹出子弹窗(新弹窗)时关闭了父弹窗,那么这时的父弹窗的Context就会影响子弹窗,就形成了子弹窗的部分形态继承了父弹窗的状态,这就造成了数据混淆,即数据污染,如何解决呢, 我现行的方法是为每一个弹窗生成一个新的Context, 由于之前的Context的类型是固定的,而不是唯一的,这是造成数据污染的主要原因。然后用这个新的Context 对弹窗进行包裹:
/**
* 为每一个弹窗生成一个唯一的上下文,避免数据污染问题
* @returns
*/
function createDynamicContext() {
return createContext();
}
那么我们就要对弹窗管理器的数据结构做一个调整,
// 创建model
const model = {
id: modelId,
context: newContext,
body: (
<ModelBody
id={modelId}
onClose={() => closeModal(modelId)}
context={newContext}
{...props}
>
{content}
</ModelBody>
)
};
// 添加model
setModelStack((modelStack) => [...modelStack, model]);
我们增加一个 id,一个新的context, 这样,移除弹窗的时候可以通过 id 来进行处理。_ModelProvider.jsx的整个代码如下:
import React, { useContext, createContext, useState, useCallback } from "react";
import ModelStack from "./_ModelStack";
import ModelBody from "./_ModelBody";
import { randomString } from "../STools/Tools";
// 用于管理ModelProvider的状态,即modelStack和popModel
export const ModelProviderContext = createContext(null);
// 使用ModelProvider的状态
export const useModelProviderState = () => useContext(ModelProviderContext);
/**
* 为每一个弹窗生成一个唯一的上下文,避免数据污染问题
* @returns
*/
function createDynamicContext() {
return createContext();
}
/**
* 弹窗提供者,只要在需要弹窗的地方使用usePopModel即可
* @param {*} param0
* @returns
*/
function ModelProvider({ children }) {
const [modelStack, setModelStack] = useState([]);
// 关闭所有弹窗
const closeAll = () => {
setModelStack([]);
}
// 关闭指定弹窗
const closeModal = useCallback(
(modelId) => {
setModelStack((prevModals) => prevModals.filter((modal) => modal.id !== modelId));
},
[modelStack]
);
// 弹出一个弹窗
const popModel = useCallback((configure) => {
const config = {
sizeMode: "sm", //弹窗的大小
level: "default", // 弹窗的类型(主要是颜色类型),选项有:default, error, warning, success, info
title: "提示", //标题
content: null, //内容
enableDragging: true, // 是否允许拖拽
enableController: false, //是否显示控制按钮
attentionIndex: -1, //高亮按钮索引,-1表示没有高亮按钮
actions: [ //操作按钮
{
lable: "确定", //按钮标题
onClick: (setLoading, setDisable, onClose) => { onClose(); } //按钮回调
},
],//功能按钮
...configure
}
const { content, ...props } = config;
const newContext = createDynamicContext(null);
const modelId = randomString(32); // 生成随机key, 用于唯一标识model
// 创建model
const model = {
id: modelId,
context: newContext,
body: (
<ModelBody
id={modelId}
onClose={() => closeModal(modelId)}
context={newContext}
{...props}
>
{content}
</ModelBody>
)
};
// 添加model
setModelStack((modelStack) => [...modelStack, model]);
}, [])
// ModelProvider提供的上下文, 包含modelStack用于管理Model, popModel用来弹出model
return (
<ModelProviderContext.Provider value={{ modelStack, popModel, closeAll }}>
{
children
}
<ModelStack modelStack={modelStack} />
</ModelProviderContext.Provider>
)
}
export default ModelProvider;
由于数据结构发生了变化,那么对相应的组件也要进行修改:_ModelStack.jsx:
import React from 'react';
/**
* ModelProvider中的modelStack的管理组件,用于展示modelStack中的所有model
* @returns
*/
function ModelStack({ modelStack }) {
return (
<React.Fragment>
{
modelStack.map((modelObj, index) => {
return (
<React.Fragment key={index}>
{ modelObj.body }
</React.Fragment>
)
})
}
</React.Fragment>
)
}
export default ModelStack;
_ModelBody.jsx中我们要把context修改为我们生成的context, 并将这个 context 传递给每一个子组件,修改如下:
import React, { useState } from 'react';
import ModelHeader from './_ModelHeader';
import ModelContent from './_ModelContent';
import ModelActions from './_ModelActions';
import ModelContainer from './_ModelContianer';
import Draggable from './_Draggable';
import { useSTheme } from '../STheme/SThemeProvider';
/**
* 弹窗的主体组件
* @param {*} param0
* @returns
*/
function ModelBody({
id, //弹窗的id
context, //弹窗的上下文
sizeMode = "sm", //弹窗的大小
level = "default", // 弹窗的类型(主要是颜色类型),选项有:default, error, warning, success, info
title = "提示", //标题
onClose, //关闭弹窗的回调
enableDragging = true,
enableController = true, //是否显示控制按钮
children, //弹窗内容
attentionIndex = -1, //高亮按钮索引,-1表示没有高亮按钮
actions //功能按钮
}) {
const stheme = useSTheme();
const [stateMode, setStateMode] = useState(1); // 弹窗的状态,0: 最小化, 1: 正常, 2: 最大化
return (
<context.Provider value={{
stateMode, // 弹窗的状态,0: 最小化, 1: 正常, 2: 最大化
setStateMode, // 设置弹窗的状态
sizeMode, // 弹窗最大宽度
onClose, // 关闭弹窗的回调
isDark: stheme.isDark, //是否是暗黑模式
level, // 弹窗的类型(主要是颜色类型),选项有:normal, error, warning, success, info
}}>
<Draggable
enableDragging={enableDragging && stateMode !== 2}
enableHandler={true}
stateMode={stateMode}
>
<ModelContainer context={context}>
<ModelHeader
context={context}
// 拖拽的handler, 用于拖拽的区域,一定要加上这个class,否则无法拖拽, 这个类名在Draggable组件中定义的,可以自定义;
className=".model-handler"
title={title}
level={level}
onClose={onClose}
enableController={enableController}
/>
<ModelContent>
{
children
}
</ModelContent>
{
actions && actions.length > 0 &&
<ModelActions
context={context}
actions={actions}
attentionIndex={attentionIndex}
onClose={onClose}
/>
}
</ModelContainer>
</Draggable>
</context.Provider>
);
};
export default ModelBody;
这样大体上就大功告成了。最后就是针对应用,我设计了一些 hooks,以方便使用,如下所示(useAlert.jsx):
import { useModelProviderState } from "./ModelProvider";
/**
* 弹窗
* @returns
*/
export const useAlert = () => {
const { popModel } = useModelProviderState();
const alertInfo = (message, title = "提示") => {
popModel({
title,
level: "info",
content: message,
enableController: false,
enableDragging: true,
});
};
const alertWarning = (message, title = "提示") => {
popModel({
title,
level: "warning",
content: message,
enableController: false,
enableDragging: true,
});
};
const alertError = (message, title = "提示") => {
popModel({
title,
level: "error",
content: message,
enableController: false,
enableDragging: true,
});
};
const alertSuccess = (message, title = "提示") => {
popModel({
title,
level: "success",
content: message,
enableController: false,
enableDragging: true,
});
};
const alertNormal = (message, title = "提示") => {
popModel({
title,
level: "default",
content: message,
enableController: false,
enableDragging: true,
});
}
const alert = (message, title = "提示", level = "default") => {
popModel({
title,
level,
content: message,
enableController: false,
enableDragging: true,
});
}
return {
alertInfo,
alertWarning,
alertError,
alertSuccess,
alertNormal,
alert,
};
}
/**
* confirm弹窗
* @param {弹窗内容} message
* @param {确定按钮回调} onOk
* @param {取消按钮回调} onCancel
* onOk: (setLoading, setTitle, setDisable, onClose) => {}
* onCancel: (setLoading, setTitle, setDisable, onClose) => {}
*/
export const useConfirm = () => {
const { popModel } = useModelProviderState();
return (
title,
message,
actions = [
{
lable: "取消",
onClick: (setLoading, setDisable, onClose) => { onClose(); }
},
{
lable: "确定",
onClick: (setLoading, setDisable, onClose) => { onClose(); }
},
],
attentionIndex = -1,
) => {
popModel({
title,
level: "default",
content: message,
enableController: false,
enableDragging: true,
attentionIndex,
actions
});
};
}
/**
* 自定义弹窗
* @returns
*/
export const useModel = () => {
const { popModel } = useModelProviderState();
return (config = {}) => {
const defaultConfig = {
title: "提示",
content : null,
level:"default",
actions:[
{
lable: "确定",
onClick: (setLoading, setDisable, onClose) => { onClose(); }
},
],
attentionIndex: -1,
enableDragging: true,
enableController: true,
...config
};
popModel(defaultConfig);
}
}
上面给出了几乎所有场景的应用方式。应该足够用了。
我们来测试一下,测试组件如下所示,就和开头的动图一样,相当的完美(ModelTest.jsx)。
import React, { useState } from 'react';
import Stack from '@mui/material/Stack';
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import Masonry from '@mui/lab/Masonry';
import TextField from '@mui/material/TextField';
import ToggleThemeButton from '../../framework-kakaer/STheme/_TaggleThemeButton';
import Typography from '@mui/material/Typography';
import { useAlert, useConfirm, useModel } from '../../framework-kakaer/SModel/useAlert';
import { useModelProviderState } from '../../framework-kakaer/SModel/ModelProvider';
const longContent = `碧玉妆成一树高”,写柳树给人的总体印象。柳树的形象美在于它那曼长披拂的枝条,一年一度,它长出了嫩绿的新叶,丝丝下垂,在春风吹拂中,有着一种迷人的意态。这里的“碧玉”既可指真实的玉,又暗含“碧玉小家女”(《碧玉歌》)中“碧玉”之意,指小户人家出身的年轻秀美的女子。古典诗词常借用柳树的形象美来形容美人苗条的身段、婀娜的腰肢,但此诗别出新意,翻转过来,将柳树化身为美人。用“碧玉”来比柳实际上有两层意思:一是“碧玉”和柳的颜色有关,“碧”和下句的“绿”是互相生发、互为补充的;二是“碧玉”这个人在人们头脑中留下的是年轻的印象,在古代文学作品里,“碧玉”几乎成了年轻貌美的女子的泛称。用“碧玉”来比柳,人们就会想象到这美人还未到丰容盛鬋的年华,这柳也还是早春稚柳,没有到密叶藏鸦的时候,同时和下文的“细叶”“二月春风”又是有联系的。
“万条垂下绿丝绦”,具体描写那茂密并轻柔下垂的柳枝,它是柳树最具代表性的部分。有了上句的铺垫,这千条万缕的垂丝,也随之变成了美人的裙带。上句的“高”字,衬托出美人婷婷袅袅的风姿;下句的“垂”字,暗示出纤腰在风中款摆。诗中没有“杨柳”和“腰肢”字样,然而这早春的垂柳以及柳树化身的美人,却给写活了。《南史》说刘悛之为益州刺史,献蜀柳数株,“条甚长,状若丝缕”。齐武帝把这些杨柳种植在太昌云和殿前,玩赏不置,说它“风流可爱”。这里把柳条说成“绿丝绦”,可能是暗用这个关于杨柳的典故。但这里的化用,几乎看不出一点痕迹。
“不知细叶谁裁出,二月春风似剪刀。”这两句进一步细描细绘,刻画柳树的嫩叶。每一片树叶都造型别致,纹理细腻,仿佛都是精心裁剪而出。诗人由于惊叹不禁发问:这满树的细叶到底出自哪位高明的裁缝之手?接着找到了答案:原来是大自然的杰作,她手持二月春风这把大剪刀裁出了满树春色。绿叶好比美人衣裙上的花纹和图案,至此,那位美人便形神毕现地跃然纸上了。“二月春风似剪刀”这一新巧的比喻,把视之无形又不可捉摸的春风形象化地描绘出来。春风和剪刀,本来全不相干,它们的相同处只存在于诗人的想象之中。因此,“二月春风似剪刀”既新奇,又能唤起人们丰富的联想。
这首诗立意高远,比喻巧妙,先从大处着眼,然后分部描述,越写越细,把柳树的形神栩栩如生地表现了出来。题目是咏柳,但又不仅仅是咏柳,更是咏春,歌咏自然造化。全诗由“碧玉妆成”引出了“绿丝绦”,“绿丝绦”引出了“谁裁出”,最后,那视之无形的不可捉摸的“春风”,也被用“似剪刀”形象化地描绘了出来。这“剪刀”裁制出嫩绿鲜红的花花草草,给大地换上了新妆,它正是自然活力的象征,是春给予人们美的启示。从“碧玉妆成”到“剪刀”,可以看出诗人一系列艺术构思的过程。诗歌里出现的一连串的形象,是一环紧扣一环的。`;
const normalContent = "唐玄宗天宝三载(744),贺知章奉诏告老回乡,百官送行。他坐船经南京、杭州,顺萧绍官河到达萧山县城,越州官员到驿站相迎,然后再坐船去南门外潘水河边的旧宅。此时正是二月早春,柳芽初发,春意盎然,微风拂面。贺知章如脱笼之鸟回到家乡,心情自然格外高兴,即景写下了这首诗。";
const size = 100;
function Item(props) {
return (
<Box>
<Paper
sx={{
height: 100,
width: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: 1,
borderColor: 'primary.main',
}}
>
{props.children}
</Paper>
</Box>
);
}
function PopModelTest() {
const {
alertInfo,
alertWarning,
alertError,
alertSuccess,
} = useAlert();
const alertConfirm = useConfirm();
const alertModel = useModel();
const { closeAll } = useModelProviderState();
let userName = '张三';
let age = 20;
const InputCompoment = (
<Stack spacing={2} >
<TextField
id="standard-name"
label="用户名"
variant="standard"
defaultValue='张三'
onChange={(event) => {
userName = event.target.value;
}}
/>
<TextField
id="standard-age"
label="年龄"
variant="standard"
defaultValue={20}
onChange={(event) => {
age = event.target.value;
}}
/>
</Stack>
)
const switchTheme = (
<Stack>
<Typography variant="subtitle2" gutterBottom>
可以在弹窗内部切换主题,这是全局的。点击下面的按钮切换主题
</Typography>
<Box><ToggleThemeButton /></Box>
</Stack>
)
return (
<Box
sx={{
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
bgcolor: 'background.default',
}}
>
<Box>
<Masonry columns={4} spacing={2}>
<Item>
<Button onClick={() => alertInfo('这是提示信息,请注意', "提示")}>Info</Button>
</Item>
<Item>
<Button onClick={() => alertError('系统在操作过程中出现了异常!', "错误")}>Error</Button>
</Item>
<Item>
<Button onClick={() => alertWarning('您开在进行很危险的操作!!!', "警告")}>warining</Button>
</Item>
<Item>
<Button onClick={() => alertSuccess('操作成功,恭喜!', "成功")}>success</Button>
</Item>
<Item>
<Button onClick={
() => alertConfirm(
'提示',
normalContent,
[
{
lable: '取消',
onClick: (setLoading, setDisable, onClose) => { onClose(); }
},
{
lable: '完成',
onClick: (setLoading, setDisable, onClose) => {
onClose();
alertSuccess("操作成功", '成功');
}
}
]
)}
>询问弹窗</Button>
</Item>
<Item>
<Button onClick={() => alertModel({
title: "提示",
content: longContent,
level: "success",
enableController: true,
enableDragging: true,
actions: [
{
lable: "这个按钮可以关闭",
onClick: (setLoading, setDisable, onClose) => {
alertError("我是开玩笑的,其实我也无法关闭", '关闭失败');
}
},
{
lable: "这个按钮不可以关闭",
onClick: (setLoading, setDisable, onClose) => { setDisable(true) }
}
]
})}>受控弹窗</Button>
</Item>
<Item>
<Button onClick={() => alertModel({
title: "提示",
content: InputCompoment,
level: "success",
enableController: false,
enableDragging: true,
actions: [
{
lable: "取消",
onClick: (setLoading, setDisable, onClose) => { onClose(); }
},
{
lable: "确定",
onClick: (setLoading, setDisable, onClose) => {
onClose();
alertSuccess(
<Stack>
<Typography variant="subtitle2" gutterBottom>
你输入的信息如下:
</Typography>
<Typography variant="subtitle2" gutterBottom>
用户名:{userName}, 年龄:{age}
</Typography>
</Stack>, '成功');
}
}
]
})}>自定义内容组件的弹窗</Button>
</Item>
<Item>
<Button onClick={() => alertSuccess(switchTheme, "成功")}>切换主题</Button>
</Item>
<Item>
<Button onClick={() => alertModel({
title: "提示",
content: '这个示例可以异步请求数据后后执行操作',
level: "success",
enableController: false,
enableDragging: true,
actions: [
{
lable: "取消",
onClick: (setLoading, setDisable, onClose) => { onClose(); }
},
{
lable: "提交",
onClick: (setLoading, setDisable, onClose) => {
setLoading(true)
setTimeout(function () {
onClose();
alertModel({
title: "提示",
content: (
<Stack>
<Typography variant="subtitle2" gutterBottom>
你输入的信息如下:
</Typography>
<Typography variant="subtitle2" gutterBottom>
用户名:{userName}, 年龄:{age}
</Typography>
</Stack>
),
level: "success",
enableController: false,
enableDragging: true,
actions: [
{
lable: "关闭",
onClick: (setLoading, setDisable, onClose) => {
closeAll();
}
}
]
})
}, 3000)
}
}
]
})}>异步弹窗</Button>
</Item>
</Masonry>
</Box>
</Box>
)
}
export default PopModelTest;
OK, 至此,关于Model就完美收官了。