几百行代码,优雅的管理弹窗

1,905 阅读7分钟

前言

一个大屏项目,项目特点是有很多弹窗,并且各个弹窗的通用性很高,会在项目个各模块子模块相互调用,甚至弹窗也会相互调用

反正就是这样神奇的调用链

一开始是由各个不同的小伙伴负责不同的模块,于是问题逐渐出现

  • 弹窗被重复开发,不知道其他模块有这个弹窗导致的页面重复开发
  • 弹窗导入混乱
  • 沟通上的矛盾,每个弹窗的维护者不知道其他弹窗需要哪些参数,或者这个弹窗开发者提供的弹窗能满足自己的需求吗
  • 性能问题,弹窗被关闭打开其他的弹窗,在弹窗内容复杂的情况下出现卡顿

压死骆驼最后的稻草,是产品的需求

要求在弹窗左上角添加面包屑导航,能回到上个弹窗

于是找到了我解决以上问题

后续更新

弹窗组件懒加载

将所有弹窗组件从静态导入改为 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

问题解决了,现在小伙伴们能更加专注业务了