前言
一个大屏项目,项目特点是有很多弹窗,并且各个弹窗的通用性很高,会在项目个各模块子模块相互调用,甚至弹窗也会相互调用
简单说明就是:
模块a ----> 弹窗b ---> 弹窗c ---->弹窗b
模块a ----> 弹窗c ---> 弹窗b
模块c ----> 弹窗b ---> 弹窗a
而且打开下一个弹窗的时候需要关闭下层弹窗,避免开的弹窗太多,观感不好
反正就是这样神奇的调用链
一开始是由各个不同的小伙伴负责不同的模块,于是问题逐渐出现
- 弹窗被重复开发,不知道其他模块有这个弹窗导致的页面重复开发
- 弹窗导入混乱
- 沟通上的矛盾,每个弹窗的维护者不知道其他弹窗需要哪些参数,或者这个弹窗开发者提供的弹窗能满足自己的需求吗
- 性能问题,弹窗被关闭打开其他的弹窗,在弹窗内容复杂的情况下出现卡顿
压死骆驼最后的稻草,是产品的需求
要求在弹窗左上角添加面包屑导航,能回到上个弹窗
于是找到了我解决以上问题
开始
以上需求,很明显需要用一个弹窗将所有弹窗管理起来,然后小伙伴们可以统一看到注册的弹窗,然后在全局任意的地方都可以调用弹窗,而不用去import 弹窗
重要的是将弹窗的逻辑和实际的业务解耦
开发全局弹窗
GlobalModalServices.tsx
tsx
import HncyModal from '@/components/HncyModal';
import { useBoolean } from 'ahooks';
import { Flex } from 'antd';
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from 'react';
import styles from './index.less';
import Registry, { ModalKey } from './registry';
import type {
GlobalModalConfig,
GlobalModalPrideAction,
ModalStackType,
} from './type';
let modalIndex = 0;
/** 获取弹窗内容 */
const getModalElement = (modalKey: ModalKey) => {
if (modalKey in Registry) {
return Registry[modalKey];
} else {
throw new Error(`Modal ${modalKey} not found`);
}
};
/** @module 全居弹窗服务 */
const GlobalModalServices: React.ForwardRefRenderFunction<
GlobalModalPrideAction,
any
> = (_, ref) => {
const [modalStack, setModalStack] = useState<ModalStackType[]>([]);
const [open, { setFalse, setTrue }] = useBoolean(false);
useEffect(() => {
if (modalStack.length === 0) {
setFalse();
} else {
setTrue();
}
}, [modalStack]);
/** @see GlobalModalServicesModalMethods.push */
const push = (modalKey: ModalKey, config?: GlobalModalConfig) => {
/** 生成弹窗id */
const modalId = `modal_${modalIndex++}`;
const { defaultConfig, defaultProps, render } = getModalElement(modalKey);
const newConfig = { ...defaultConfig, ...config };
const newProps = { ...defaultProps, ...(config?.props ?? {}) };
let modalElement: JSX.Element | undefined;
if (newConfig.keepalive) {
/**
* 保存在栈中,不重复渲染。此实现无法避免组件的卸载与状态保持,仅节约重复渲染时间
* 暂存 @see https://github.com/CJY0208/react-activation
* 等待有时间实现keepalive功能
* */
modalElement = render(newProps);
}
const newModalStack = {
...newConfig,
modalId,
props: newProps,
modalKey,
modalElement,
render,
};
setModalStack((pre) => [...pre, newModalStack]);
return modalId;
};
/** @see GlobalModalServicesModalMethods.go */
const go = (modalId: string) => {
const index = modalStack.findIndex((item) => item.modalId === modalId);
if (index === -1) {
throw new Error('modal not found');
} else {
setModalStack((pre) => pre.filter((_, i) => i <= index));
}
};
/** @see GlobalModalServicesModalMethods.remove */
const remove = (id: string) => {
setModalStack((prev) => {
return prev.filter((item) => item.modalId !== id);
});
};
/** @see GlobalModalServicesModalMethods.setOptions */
const setOptionsById = (id: string, config: any) => {
setModalStack((prev) => {
return prev.map((item) => {
if (item.modalId === id) {
return {
...item,
...config,
};
}
return item;
});
});
};
/** 设置options */
const setOptions = (options: Partial<GlobalModalConfig>) => {
setModalStack((prev) => {
return prev.map((item, index) => {
if (index === prev.length - 1) {
return {
...item,
...options,
};
}
return item;
});
});
};
useImperativeHandle(ref, () => ({
close,
setOptions,
setOptionsById,
push,
goBack,
remove,
go,
clear,
}));
/** @see GlobalModalServicesModalMethods.clear */
const clear = () => {
setModalStack([]);
};
/**
* @description modal返回
* */
const goBack = () => {
setModalStack((prev) => {
return prev.slice(0, prev.length - 1);
});
};
/** 获取当前弹窗的内容 */
const renderModal = () => {
const curModal = modalStack.at(-1);
/** 避免重复渲染 */
if (curModal?.modalElement) {
return curModal.modalElement;
}
return curModal?.render(curModal.props);
};
const modalOptions = useMemo(() => {
return modalStack.at(-1) ?? ({} as ModalStackType);
}, [modalStack]);
/** 标题渲染 */
const titleRender = () => {
const { headerLeft } = modalOptions;
return (
<Flex align="center">
<div className={styles.modalTitle}>
{modalStack?.length && (
<>
{modalStack.map((item, index: number) => (
<span
key={item.modalId}
style={{ cursor: 'pointer' }}
onClick={() => go(item.modalId)}
>
{item.title}
{modalStack.length - 1 !== index && (
<span style={{ margin: '0 5px' }}>/</span>
)}
</span>
))}
</>
)}
</div>
{headerLeft
? typeof headerLeft === 'function'
? headerLeft()
: headerLeft
: undefined}
</Flex>
);
};
const close = () => {
setFalse();
setTimeout(() => {
clear();
}, 300);
};
return (
<HncyModal
onCancel={close}
width={modalOptions.w ?? 200}
height={modalOptions.h ?? 200}
open={open}
titleRender={titleRender}
>
{open && renderModal()}
</HncyModal>
);
};
export default forwardRef(GlobalModalServices);
主要工作是提供对弹窗堆栈的管理方法,以及添加面包屑导航
方法主要下面这些
ts
import { ModalKey } from './registry';
export interface GlobalModalConfig<T = any> {
title?: string;
w?: number;
h?: number;
/** 显示标题栏 */
showHeader?: boolean;
/** 自定义标题栏左侧内容 */
headerLeft?: React.ReactNode | (() => React.ReactNode);
/** 自定义标题栏右侧内容 */
headerRight?: React.ReactNode;
// 是否保持弹窗状态,默认为false,弹窗关闭后会自动销毁
keepalive?: boolean;
props?: T;
}
export type ModalMapping = {
render: (props: GlobalModalConfig['props']) => JSX.Element;
defaultProps?: any;
defaultConfig?: Partial<GlobalModalConfig>;
};
/** 全局弹窗方法 */
export type GlobalModalServicesModalMethods = {
/**
* 推送一个弹窗
* @param modalKey 弹窗类型
* @param config 弹窗参数
* @param keepalive 是否保持弹窗状态,默认为false,弹窗关闭后会自动销毁
* @returns modalId 弹窗id,使用modalId进行后续操作
* */
push: (modalKey: ModalKey, config?: GlobalModalConfig) => string;
/**
* 关闭一个弹窗
* @params modalId 弹窗id
* @description 弹窗关闭后,会自动销毁
* */
remove: (modalId: string) => void;
/**
* 跳转一个弹窗
* @params modalId 弹窗id
* @description 跳转到指定的弹窗,如果弹窗不存在,则会自动创建
* */
go: (modalId: string) => void;
/**
* 清空所有弹窗
* */
clear: () => void;
/** 设置弹窗基础信息 */
setOptionsById: (modalId: string, config: GlobalModalConfig) => void;
};
export interface GlobalModalPrideAction
extends GlobalModalServicesModalMethods {
/** 返回上一页 */
goBack(): void;
/** 关闭弹窗 */
close(): void;
/** 设置弹窗参数 */
setOptions(options: Partial<GlobalModalConfig>): void;
}
export interface ModalStackType extends GlobalModalConfig, ModalMapping {
modalKey: string;
modalElement?: JSX.Element;
modalId: string;
}
modal 本身基于antd modal封装
tsx
import type { ModalProps } from 'antd';
import { Modal } from 'antd';
import styles from './index.less';
interface HncyModalProps extends ModalProps {
height?: number;
minHeight?: number;
padding?: string | number;
bgColor?: React.CSSProperties;
bodyStyle?: React.CSSProperties;
titleRender?: () => JSX.Element;
}
const HncyModal = (props: HncyModalProps) => {
const {
children,
title,
onCancel,
height,
minHeight,
style,
padding,
titleRender,
bodyStyle,
...reset
} = props;
return (
<Modal
onCancel={onCancel}
footer={null}
destroyOnClose
maskClosable
classNames={{
mask: styles.mask,
}}
{...reset}
style={{ pointerEvents: 'auto', ...style }}
wrapClassName="hncyModal"
>
<div
className={styles.modalWrap}
style={{ height, minHeight, padding, ...bodyStyle }}
>
<div className={styles.head}>
{titleRender ? titleRender() : title && <p>{title}</p>}
</div>
<div
className={styles.content}
style={{
height: title || titleRender ? 'calc(100% - 80px)' : '100%',
}}
>
{children}
</div>
</div>
</Modal>
);
};
export default HncyModal;
管理注册表
新增的弹窗往注册表一塞就完事儿
tsx
//@ts-nocheck
import React from 'react';
import DistrictIndex from './Modals/DistrictIndex';
import KeyArea from './Modals/KeyArea';
import KeyAreaDetail from './Modals/KeyAreaDetail';
import { ModalMapping } from './type';
export type ModalKey = keyof typeof Registry;
/** @see ModalMapping 弹窗注册在这里 */
const Registry = {
districtIndex: {
defaultConfig: {
w: 2756,
h: 846,
title: '行业指数',
},
defaultProps: {},
render: (props: any) => React.cloneElement(<DistrictIndex />, props),
},
keyArea: {
defaultConfig: {
w: 2771,
h: 846,
title: '重点区域',
keepalive: true,
},
defaultProps: {},
render: (props: any) => React.cloneElement(<KeyArea />, props),
},
KeyAreaDetail: {
defaultConfig: {
w: 2771,
h: 846,
title: '重点区域详情',
},
defaultProps: {},
render: (props: any) => React.cloneElement(<KeyAreaDetail />, props),
},
};
export default Registry;
添加提供者
tsx
import React, { PropsWithChildren, useEffect, useRef, useState } from 'react';
import GlobalModalServices from '.';
import type { GlobalModalPrideAction } from './type';
const GlobalModalContext = React.createContext(
{} as {
dispatch: GlobalModalPrideAction;
},
);
export const useGlobalModalServices = () =>
React.useContext(GlobalModalContext);
/** 全局弹窗服务提供者 */
const GlobalModalServicesProvider: React.FC<PropsWithChildren> = (props) => {
const [dispatch, setDispatch] = useState<GlobalModalPrideAction>();
const ref = useRef<GlobalModalPrideAction>(null);
useEffect(() => {
if (ref.current && !dispatch) {
setDispatch(ref.current);
}
}, [ref.current]);
return (
<GlobalModalContext.Provider
value={{
dispatch: dispatch!,
}}
>
<GlobalModalServices ref={ref} />
{props.children}
</GlobalModalContext.Provider>
);
};
export default GlobalModalServicesProvider;
在项目最外层包裹住提供者
tsx
//app.tsx
xxx
<GlobalModalServicesProvider>
<App
message={{ maxCount: 1 }}
style={{ width: '100%', height: '100%' }}
></App>
</GlobalModalServicesProvider>
xxx
在项目中使用
tsx
const { dispatch } = useGlobalModalServices();
//xxxx
<div
className={styles.moreBtn}
onClick={() => {
dispatch.push('districtIndex', {
title: selectDistrict.objectName + '运行情况',
props: {
data: selectDistrict,
},
});
}}
>
查看更多
</div>
//xxx
问题解决了,现在小伙伴们能更加专注业务了