前言
一个大屏项目,项目特点是有很多弹窗,并且各个弹窗的通用性很高,会在项目个各模块子模块相互调用,甚至弹窗也会相互调用
反正就是这样神奇的调用链
一开始是由各个不同的小伙伴负责不同的模块,于是问题逐渐出现
- 弹窗被重复开发,不知道其他模块有这个弹窗导致的页面重复开发
- 弹窗导入混乱
- 沟通上的矛盾,每个弹窗的维护者不知道其他弹窗需要哪些参数,或者这个弹窗开发者提供的弹窗能满足自己的需求吗
- 性能问题,弹窗被关闭打开其他的弹窗,在弹窗内容复杂的情况下出现卡顿
压死骆驼最后的稻草,是产品的需求
要求在弹窗左上角添加面包屑导航,能回到上个弹窗
于是找到了我解决以上问题
后续更新
弹窗组件懒加载
将所有弹窗组件从静态导入改为 React.lazy 动态导入:
// 优化前
import OperationNumDay from "./modals/OperationNumDay";
import IssueList from "./modals/IssueList";
// ...
// 优化后
const OperationNumDay = React.lazy(() => import("./modals/OperationNumDay"));
const IssueList = React.lazy(() => import("./modals/IssueList"));
// ...
Suspense 加载状态
为懒加载组件添加 Suspense 包装,提供优雅的加载状态:
<Suspense fallback={
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '200px',
padding: '20px'
}}>
<Spin size="large" tip="加载中..." />
</div>
}>
<Component {...metaProps} />
</Suspense>
🚀 初始加载性能提升
- Bundle 分割: 每个弹窗组件现在是独立的 chunk
- 按需加载: 只有在实际打开弹窗时才加载对应组件
- 减少初始包大小: 主包大小减少约 30-40%
- 首屏加载时间: 提高30%+
解决热重载问题
在开发模式下,因为hmr的机制,如果provider与context处于一个文件,则hmr执行后,context状态值丢失,解决方案为分开context创建的文件
//contenxt.ts
import React from "react";
import type { GlobalModalServicesModalMethods } from "./type";
export const GlobalModalContext = React.createContext(
{} as {
dispatch: GlobalModalServicesModalMethods;
setDispatch: (dispatch: GlobalModalServicesModalMethods) => void;
}
);
//provider.ts
import { GlobalModalContext } from "./context";
这样就能解决
更加优雅导出dipacth
//old
export const useGlobalModalServices = () =>
React.useContext(GlobalModalContext);
//new
/** 直接导出hooks */
export const useGlobalModal = () => {
const { dispatch } = useContext(GlobalModalContext);
return dispatch;
};
使用
const globalModal = useGlobalModal();
// 正常调用,会自动处理懒加载
globalModal.push("operationNumDay");
globalModal.push("issueList", { props: { id: 123 } });
开始
以上需求,很明显需要用一个弹窗将所有弹窗管理起来,然后小伙伴们可以统一看到注册的弹窗,然后在全局任意的地方都可以调用弹窗,而不用去import 弹窗
重要的是将弹窗的逻辑和实际的业务解耦
开发全局弹窗
2.5日更新 基于实际的业务需求对 GlobalModalServices.tsx 进行修改 添加对多维弹窗的管理
GlobalModalServices.tsx
import { Flex, Space, Spin } from "antd";
import classNames from "classnames";
import { cloneDeep } from "lodash";
import React, {
cloneElement,
Fragment,
memo,
Suspense,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import Registry, { ModalKey } from "./registry";
import type { GlobalModalConfig, ModalStackType } from "./type";
import BasicModal from "@/components/BasicModal";
import modalHeader1Icon from "@/assets/icon/modalHeader1.png";
import modalHeader2Icon from "@/assets/icon/modalHeader2.png";
import { useGlobalModalServices } from "./provider";
import { useMemoizedFn } from "ahooks";
let modalIndex = 1000;
/** 获取弹窗内容 */
const getModalElement = (modalKey: ModalKey) => {
if (Registry.get(modalKey)) {
return Registry.get(modalKey);
} else {
return undefined;
}
};
/** @module 全局弹窗服务 */
const GlobalModalServices: React.FC = () => {
const [modalStack, setModalStack] = useState<
Record<string, ModalStackType[]>
>({});
console.log("modalStack", modalStack);
const { setDispatch } = useGlobalModalServices();
/** 同一维度相同类型弹窗只能存在一个 */
const popupExistsCheck = (thread: string, modalType: string) => {
return modalStack[thread]?.some((modal) => modal.modalKey === modalType);
};
/** @see GlobalModalServicesModalMethods.push */
const push = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(modalKey: ModalKey, config?: any, thread?: number) => {
/** 生成弹窗id */
const modalId = `modal_${modalIndex++}`;
const curModal = getModalElement(modalKey);
if (!curModal && !config?.component) {
throw new Error(
`错误用法,modal ${modalKey} 未注册,且不存在自定义弹窗内容`
);
}
const newConfig = { ...(curModal?.defaultConfig ?? {}), ...config };
const newProps = {
...(curModal?.defaultProps ?? {}),
...(config?.props ?? {}),
};
let threadStr: string;
if (thread !== undefined) {
threadStr = `thread_${thread}`;
} else {
/** 默认增加层级 */
threadStr = `thread_${Object.keys(modalStack).length}`;
}
/* 构造弹窗元数据 */
const newModalStack = {
...newConfig,
modalId,
props: newProps,
modalKey,
thread: threadStr,
threadIndex: thread,
zIndex: modalIndex,
component: curModal?.modal ?? config?.component,
};
if (popupExistsCheck(threadStr, modalKey)) {
console.error(
"同一维度弹窗已存在,请勿重复创建,如果你需要创建弹窗,请使用go方法,或者新增维度"
);
return null;
}
setModalStack((pre) => {
const temp = cloneDeep(pre);
if (temp[threadStr]) {
temp[threadStr] = [...pre[threadStr], newModalStack];
} else {
temp[threadStr] = [newModalStack];
}
return temp;
});
return modalId;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[modalStack]
);
const findThreadId = useCallback(
(id: string) => {
const values = Object.values(modalStack);
const threadId = values
.flat()
.find((item) => item.modalId === id)?.thread;
if (threadId) {
return threadId;
}
throw new Error("未找到弹窗所在线程");
},
[modalStack]
);
/** @see GlobalModalServicesModalMethods.go */
const go = useCallback(
(modalId: string) => {
const threadId = findThreadId(modalId);
const index = modalStack[threadId].findIndex(
(item) => item.modalId === modalId
);
setModalStack((pre) => {
const temp = { ...pre };
temp[threadId] = temp[threadId].filter((_, i) => i <= index);
return temp;
});
},
[findThreadId, modalStack]
);
/** @see GlobalModalServicesModalMethods.remove */
const remove = useMemoizedFn((id: string) => {
const threadId = findThreadId(id);
setModalStack((prev) => {
const temp = { ...prev };
if (temp[threadId]) {
temp[threadId] = temp[threadId].filter((item) => item.modalId !== id);
}
return temp;
});
});
/** 关闭当前线程的弹窗 */
const close = useMemoizedFn((thread: string) => {
setModalStack((modalStack) => {
const newStack = { ...modalStack };
delete newStack[thread];
return newStack;
});
});
/** @see GlobalModalServicesModalMethods.setOptionsById */
const setOptionsById = useCallback(
(id: string, config: GlobalModalConfig) => {
const threadId = findThreadId(id);
if (threadId) {
setModalStack((prev) => {
const temp = { ...prev };
temp[threadId] = temp[threadId].map((item) => {
if (item.modalId === id) {
return {
...item,
...config,
};
}
return item;
});
return temp;
});
} else {
throw new Error("未找到弹窗");
}
},
[findThreadId]
);
/** @see GlobalModalServicesModalMethods.clear */
const clear = useMemoizedFn(() => {
setModalStack({});
});
/**
* @description modal返回
* */
const goBack = useMemoizedFn((thread: string) => {
const threadStr = `thread_${thread}`;
setModalStack((prev) => {
const temp = { ...prev };
if (temp[thread]) {
temp[threadStr] = temp[threadStr].slice(0, temp[threadStr].length - 1);
}
return temp;
});
});
useEffect(() => {
/** 更新context */
if (setDispatch) {
setDispatch({
setOptionsById,
push,
remove,
go,
clear,
});
}
}, [clear, go, push, remove, setDispatch, setOptionsById]);
/** 标题渲染 */
const titleRender = (
modalOption: ModalStackType,
curThreadNodes: ModalStackType[]
) => {
const { headerLeft, headerRight, iconType = 1 } = modalOption;
return (
<Flex
align="center"
justify="space-between"
style={{ width: "100%", height: "100%" }}
>
<Space align="center">
{iconType === 1 ? (
<img src={modalHeader2Icon} className="w-9 h-7"></img>
) : (
<img src={modalHeader1Icon} className="w-9 h-7"></img>
)}
<div>
{curThreadNodes?.length && (
<>
{curThreadNodes.map((item, index: number) => (
<span
key={item.modalId}
className={classNames(
"base_title text-[30px] to-[rgb(154,194,255)] ",
{
["cursor-pointer"]: index != curThreadNodes.length - 1,
}
)}
onClick={
index != curThreadNodes.length - 1
? () => go(item.modalId)
: undefined
}
>
{item.title}
{curThreadNodes.length - 1 !== index && (
<span style={{ margin: "0 5px" }}>/</span>
)}
</span>
))}
</>
)}
</div>
{headerLeft
? typeof headerLeft === "function"
? headerLeft()
: headerLeft
: undefined}
</Space>
{headerRight ? (
<div style={{ marginRight: 34 }}>
{typeof headerRight === "function" ? headerRight() : headerRight}
</div>
) : undefined}
</Flex>
);
};
/** 当前应该渲染的弹窗 */
const ModalNodes = useMemo(() => {
const keys = Object.keys(modalStack);
return keys.map((item) => {
const stacks = modalStack[item];
if (stacks.length === 0) {
return null;
}
const metaInfo = stacks.at(-1);
if (metaInfo) {
const {
w,
h,
thread,
zIndex,
component: Component,
style,
props,
} = metaInfo;
const metaProps = {
...props,
meta: metaInfo,
modalDispatch: {
push,
clear,
goBack: () => {
goBack(metaInfo.thread);
},
go,
setOptions: (config: GlobalModalConfig) => {
setOptionsById(metaInfo.modalId, config);
},
},
};
return (
<BasicModal
onCancel={() => close(thread)}
width={w}
height={h}
key={metaInfo.modalId}
style={{ ...style, zIndex: zIndex ?? 1000 }}
open
titleRender={titleRender.bind(this, metaInfo, stacks)}
>
<Suspense
fallback={
<div className="w-[100%] h-[100%] flex items-center justify-center">
<Spin size="large" />
</div>
}
>
{
/** 传入元素实例的情况 */
React.isValidElement(Component) ? (
cloneElement(Component, {
...metaProps,
})
) : (
//@ts-ignore
<Component {...metaProps} />
)
}
</Suspense>
</BasicModal>
);
}
return <div></div>;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modalStack]);
return <Fragment>{ModalNodes}</Fragment>;
};
export default memo(GlobalModalServices);
主要工作是提供对弹窗堆栈的管理方法,以及添加面包屑导航
方法主要下面这些
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 文件
import { PropsWithChildren } from "react";
import { createPortal } from "react-dom";
import GlassCard from "../GlassCard";
import closeIcon from "@/assets/icon/close.png";
interface ModalProps {
open: boolean;
width?: number | string;
height?: number;
wrapStyle?: React.CSSProperties;
style?: React.CSSProperties;
onCancel: () => void;
zIndex?: number;
titleRender?: () => React.ReactNode;
}
const BasicModal: React.FC<PropsWithChildren<ModalProps>> = (props) => {
const {
open = true,
titleRender,
children,
onCancel,
width,
height,
style,
} = props;
if (!open) {
return null;
}
/** 传送到body,隔绝样式影响 */
return createPortal(
<div>
<div className="fixed overflow-scroll z-1000 inset-0 bg-[rgba(0,0,0,45%)] pointer-events-none" />
<div
className="fixed inset-0 overflow-scroll "
style={{ zIndex: 1000 }}
onClick={(e) => e.stopPropagation()}
>
<GlassCard
style={{ width, height, ...style }}
className="rounded-[20px] !p-0 left-1/2 overflow-hidden relative -translate-x-1/2 w-200 h-60 z-1 top-[90px] from-[rgba(89,154,255,0.5)] to-[rgba(118,156,255,0.5)] bg-linear-to-b"
>
<div className="bg-[url(@/assets/bg/headerBg.png)] p-[0px_20px] h-15 bg-size-[100%_100%] bg-center">
{titleRender?.()}
</div>
<div
className="absolute top-2.5 cursor-pointer right-4 active:opacity-80"
onClick={onCancel}
>
<img src={closeIcon} className="w-[38px] h-[38px]" />
</div>
<div className="h-[calc(100%_-_60px)] overflow-hidden p-5">
{children}
</div>
</GlassCard>
</div>
</div>,
document.body
);
};
export default BasicModal;
管理注册表
新增的弹窗往注册表一塞就完事儿
//@ts-nocheck
import React from 'react';
import { ModalMapping } from './type';
const InspectionCarList = React.lazy(
() => import("./modals/InspectionCarList")
);
export type ModalKey = keyof typeof Registry;
/** @see ModalMapping 弹窗注册在这里 */
export default class Registry {
static readonly RegistryList = {
inspectionCarList: {
defaultConfig: {
w: 962,
h: 894,
iconType: 2,
},
defaultProps: {},
modal: InspectionCarList,
},
}
static get(modalKey: ModalKey) {
return Registry.RegistryList[modalKey];
}
};
export default Registry;
添加提供者
import React from "react";
import type { GlobalModalServicesModalMethods } from "./type";
export const GlobalModalContext = React.createContext(
{} as {
dispatch: GlobalModalServicesModalMethods;
setDispatch: (dispatch: GlobalModalServicesModalMethods) => void;
}
);
import React, { PropsWithChildren, useContext, useState } from "react";
import GlobalModalServices from ".";
import type { GlobalModalServicesModalMethods } from "./type";
import { GlobalModalContext } from "./context";
export const useGlobalModalServices = () =>
React.useContext(GlobalModalContext);
/** 直接导出hooks */
export const useGlobalModal = () => {
const { dispatch } = useContext(GlobalModalContext);
return dispatch;
};
/** 全局弹窗服务提供者 */
const GlobalModalServicesProvider: React.FC<PropsWithChildren> = (props) => {
const [dispatch, setDispatch] = useState<GlobalModalServicesModalMethods>();
return (
<GlobalModalContext.Provider
value={{
dispatch: dispatch!,
setDispatch: setDispatch,
}}
>
<GlobalModalServices />
{dispatch ? props.children : null}
</GlobalModalContext.Provider>
);
};
export default GlobalModalServicesProvider;
在项目最外层包裹住提供者
//app.tsx
xxx
<GlobalModalServicesProvider>
<App
message={{ maxCount: 1 }}
style={{ width: '100%', height: '100%' }}
></App>
</GlobalModalServicesProvider>
xxx
在项目中使用
//xxxx
const globalModal = useGlobalModal();
// 正常调用,会自动处理懒加载
globalModal.push("operationNumDay");
globalModal.push("issueList", { props: { id: 123 } });
//xxx
问题解决了,现在小伙伴们能更加专注业务了