前端框架vben页面过多导致的首页白屏时间过长问题分析

monorepo的vue架构下,大量菜单和vue页面造成的首页白屏时间过长问题分析

Posted by Gjx on 2024-11-13

问题现象:
系统登录成功后,在加载出首页和菜单前会有很长一段时间的白屏

下面的图片是在本地搭建环境直接访问的网络请求包,从图中可以看出一共有145个请求,加载了36.87分钟,这是在本地环境访问的情况下,如果在服务器上考虑到网络等因素会达到1分钟左右

在界面没有任何提示的情况下,且处于白屏状态,是个很糟糕的用户体验,当然可以考虑在加载时间内加上加载进度条,但这只能说是改善了用户体验,并没有解决根本的问题

image.png

下面我第一想到的是对网络请求进行分析,试图从网络请求中找到耗时比较长的请求,下图是根据网络请求耗时排序后的情况,在其中并没有找到非常耗时的请求,最大的请求耗时也才1.64秒(这里是菜单加载的接口,在我的另一篇文章中有对他的优化思路),即使去优化他也并不能解决问题

image.png

既然不能简单的找到问题,那么我们只能通过性能分析工具来尝试找到原因了,打开chrome的性能页签,点击开始录制,然后重新刷新页面
image.png

等界面加载完成后点击停止

image.png

下面是分析结果的情况,在概要中我们可以看到执行脚本占用了比较多的时间,整体耗时46.39

到了这里问题其实可以确定为js脚本执行慢导致的问题了

下面就具体分析下是什么地方的代码导致的慢
image.png

在由上至下页签可以看到耗时比较长的脚本,但是此处代码被混淆了,不容易判断到底是哪里的代码(如果对代码非常熟悉,直接看混淆后的代码也可以分析,我采用的是这种方法),为了方便查看代码情况,我们启动前端开发服务,来进行访问
image.png

下面是换成本地开发模式后的性能分析结果,总时长增加到了1.9分钟,不过看概要是空闲时间增加了,应该跟vite的开发模式和发布模式不一样有关,但是这里可以看到脚本执行了47秒钟的问题依然存在,所以我们的重点还是放在分析脚本耗时过多的原因
image.png

通过下面的分析结果已经很明确的定位到代码的位置了,是一个递归解析动态路由的代码
image.png

点击右侧对应的代码可以跳转到详细的代码信息,左侧还有执行耗时
image.png
通过代码分析的结果看有两处耗时比较长的地方

第一处
image.png

我们分析具体的代码,这里是一个递归调用,假如routes比较多的时候,调用次数会非常多

let dynamicViewsModules: Record<string, () => Promise<Recordable>>;

// Dynamic introduction
export function asyncImportRoute(routes: AppRouteRecordRaw[] | undefined) {
  dynamicViewsModules = dynamicViewsModules || import.meta.glob('../../views/**/*.{vue,tsx}');
  let subModules = import.meta.glob('../../../apps/**/src/index.{js,ts}', {
    eager: true,
    import: 'dynamicViewsModules',
  });
  for (const key in subModules) {
    if (Object.prototype.hasOwnProperty.call(subModules, key)) {
      const element = subModules[key];
      dynamicViewsModules = Object.assign({}, dynamicViewsModules, element);
    }
  }

  if (!routes) return;
  routes.forEach((item) => {
    if (!item.component && item.meta?.frameSrc) {
      item.component = 'IFRAME';
    }
    const { component, name } = item;
    const { children } = item;
    if (component) {
      const layoutFound = LayoutMap.get(component.toUpperCase());
      if (layoutFound) {
        item.component = layoutFound;
      } else {
        item.component = dynamicImport(dynamicViewsModules, component as string);
      }
    } else if (name) {
      item.component = getParentLayout();
      // 当前为末级菜单时,会造成其他菜单无法打开的bug,所以加下面代码处理
      if (!item.children) {
        item.component = EXCEPTION_COMPONENT;
      }
    }
    children && asyncImportRoute(children);
  });
}

image.png
在后台查询了下数据库中菜单的数量为6340,虽然有点多,但是还没到不能忍受的程序,我们继续分析上面的代码
image.png
在上面的调试中,可以看到dynamicViewsModules的数量是非常多的,有6151个,

第二处
image.png

function dynamicImport(
  dynamicViewsModules: Record<string, () => Promise<Recordable>>,
  component: string,
) {
  const keys = Object.keys(dynamicViewsModules);
  const matchKeys = keys.filter((key) => {
    const k = key.replace('../../views', '');
    const startFlag = component.startsWith('/');
    const endFlag = component.endsWith('.vue') || component.endsWith('.tsx');
    const startIndex = startFlag ? 0 : 1;
    const lastIndex = endFlag ? k.length : k.lastIndexOf('.');
    return k.substring(startIndex, lastIndex) === component;
  });
  if (matchKeys?.length === 1) {
    const matchKey = matchKeys[0];
    return dynamicViewsModules[matchKey];
  } else if (matchKeys?.length > 1) {
    warn(
      'Please do not create `.vue` and `.TSX` files with the same file name in the same hierarchical directory under the views folder. This will cause dynamic introduction failure',
    );
    return;
  } else {
    warn('在src/views/下找不到`' + component + '.vue` 或 `' + component + '.tsx`, 请自行创建!');
    return EXCEPTION_COMPONENT;
  }
}

综合这两处代码,我们可以发现,asyncImportRoute是一个递归调用,他的执行次数跟routes的深度和数量有关,通过查询数据库了解到菜单的数量是6340,它和routes是一一对应的关系,可以大致的判断asyncImportRoute的递归次数不会少,并且在asyncImportRoute中还调用了
item.component = dynamicImport(dynamicViewsModules, component as string);代码对dynamicViewsModules进行了遍历处理,它的数据量也是比较大的,所以一层递归加一层大数据量的循环下来,执行速度应该是快不起来的

那么解决问题思路可以从两方面入手:一方面是可以尽量减少菜单的数量,同时减少dynamicViewsModules的数量;另一方面,在菜单和dynamicViewsModules都无法减少的情况下,看看代码是否可以优化执行效率

经过代码的分析,不知道是幸运还是不幸,我发现了代码的优化空间,原来是有一处对象重复克隆导致了很大的耗时

let dynamicViewsModules: Record<string, () => Promise<Recordable>>;

// Dynamic introduction
export function asyncImportRoute(routes: AppRouteRecordRaw[] | undefined) {
  // 虽然这里的代码只执行了一次
  dynamicViewsModules = dynamicViewsModules || import.meta.glob('../../views/**/*.{vue,tsx}');
  // 但是这下面的代码会重复执行在每次递归调用中
  let subModules = import.meta.glob('../../../apps/**/src/index.{js,ts}', {
    eager: true,
    import: 'dynamicViewsModules',
  });
  for (const key in subModules) {
    if (Object.prototype.hasOwnProperty.call(subModules, key)) {
      const element = subModules[key];
      // 在性能分析结果中可以发现这一行代码的耗时是最多的,占了20115毫秒
      // Object.assign的执行效率是比较低的,而且这里还进行了重复的执行,因为他完全可以只执行一次(这里算是开发人员留下的一个bug了)
      dynamicViewsModules = Object.assign({}, dynamicViewsModules, element);
    }
  }

所以我的优化思路是改为下面的代码
asyncImportRoute方法体

let dynamicViewsModules: Record<string, () => Promise<Recordable>>;

// Dynamic introduction
export function asyncImportRoute(routes: AppRouteRecordRaw[] | undefined) {
  // 这里判断一下,只有dynamicViewsModules没有值的时候才执行下面的代码,保证下面的数组合并操作只执行一次
  if (!dynamicViewsModules) {
    dynamicViewsModules = import.meta.glob('../../views/**/*.{vue,tsx}');
    let subModules = import.meta.glob('../../../apps/**/src/index.{js,ts}', {
      eager: true,
      import: 'dynamicViewsModules',
    });
  
    const modulesToMerge = Object.values(subModules).filter(Boolean);
    // 将Object.assign合并为执行一次,避免重复执行造成的耗时
    dynamicViewsModules = { ...dynamicViewsModules, ...Object.assign({}, ...modulesToMerge) };
  }

dynamicImport方法体

function dynamicImport(
  dynamicViewsModules: Record<string, () => Promise<Recordable>>,
  component: string,
) {
  // 预处理 component 字符串
  const componentKeys = new Set([
    `../../views${component}.vue`,
    `../../views${component}.tsx`,
    `../../views/${component}.vue`,
    `../../views/${component}.tsx`
  ])

  for (const key of componentKeys) {
    if (key in dynamicViewsModules) {
      return dynamicViewsModules[key];
    }
  }
  // 先不考虑这种情况的提示,来优化性能
  // else if (Object.keys(dynamicViewsModules).some((key) => key.includes(component))) {
  //   warn(
  //     'Please do not create `.vue` and `.TSX` files with the same file name in the same hierarchical directory under the views folder. This will cause dynamic introduction failure',
  //   );
  //   return;
  // } 
  warn('在src/views/下找不到`' + component + '.vue` 或 `' + component + '.tsx`, 请自行创建!');
  return EXCEPTION_COMPONENT;
}

优化后的asyncImportRoute耗时
image.png

优化后的dynamicImport,这里耗时并没有完全
image.png

image.png
image.png

image.png
可以看到优化后,页面可以在14.68秒完成加载,这已经是一个不小的提升,针对代码的提升就先进行到这。

剩余的问题就是考虑菜单是否有冗余数据,以及views目录下是否有冗余代码

菜单的冗余得业务上配合检查了,但是views下面扫描到的代码的情况,我们可以打断点看一下
image.png

我们复制Object.keys(dynamicViewsModules)的值出来观察一下,这里可以发现好多的componentsdata.ts也被扫描到了,这样是不合理的,我们可以尝试修改import.meta.glob匹配规则,也可以规范开发人员的开发方式来解决,这里就不多做处理了

  [
    "../../views/cockpit/components/CardTitle.vue",
    "../../views/cockpit/components/CockpitSettingModal.vue",
    "../../views/cockpit/components/CompanyInfo.vue",
    "../../views/cockpit/components/Header.vue",
    "../../views/cockpit/components/Map.vue",
    "../../views/cockpit/components/SideView.vue",
    "../../views/cockpit/components/viewCards/CommonCard.vue",
    "../../views/cockpit/index.vue",
    "../../views/commmod/messTmpl/index.vue",
    "../../views/commmod/messTmpl/messTmplDetail.vue",
    "../../views/commmod/messTmpl/messTmplModal.vue",
    "../../views/dashboard/analysis/components/FacilitiesOverview.vue",
    "../../views/dashboard/analysis/components/GrowCard.vue",
    "../../views/dashboard/analysis/components/MainProject.vue",
    "../../views/dashboard/analysis/components/ProductionStatistics.vue",
    "../../views/dashboard/analysis/components/SalesProductPie.vue",
    "../../views/dashboard/analysis/components/SiteAnalysis.vue",
    "../../views/dashboard/analysis/components/ThreeLists.vue",
    "../../views/dashboard/analysis/components/VisitAnalysis.vue",
    "../../views/dashboard/analysis/components/VisitAnalysisBar.vue",
    "../../views/dashboard/analysis/components/VisitRadar.vue",
    "../../views/dashboard/analysis/components/VisitSource.vue",
    "../../views/dashboard/analysis/components/announcements.vue",
    "../../views/dashboard/analysis/components/carousel.vue",
    "../../views/dashboard/analysis/components/reservation.vue",
    "../../views/dashboard/analysis/home.vue",
    "../../views/dashboard/analysis/index.vue",
    "../../views/dashboard/helper/index.vue",
    "../../views/dashboard/workbench/components/CommonFunction.vue",
    "../../views/dashboard/workbench/components/CommonLink.vue",
    "../../views/dashboard/workbench/components/DynamicInfo.vue",
    "../../views/dashboard/workbench/components/ModuleCard.vue",
    "../../views/dashboard/workbench/components/QuickNav.vue",
    "../../views/dashboard/workbench/components/SaleRadar.vue",
    "../../views/dashboard/workbench/components/WorkList.vue",
    "../../views/dashboard/workbench/components/WorkListDetail.vue",
    "../../views/dashboard/workbench/components/WorkbenchHeader.vue",
    "../../views/dashboard/workbench/components/schedule.vue",
    "../../views/dashboard/workbench/index.vue",
    "../../views/dbcockpit/components/CardTitle.vue",
    "../../views/dbcockpit/components/ContentBox.vue",
    "../../views/dbcockpit/components/Dashboard.vue",
    "../../views/dbcockpit/components/Header.vue",
    "../../views/dbcockpit/components/ProgressBar.vue",
    "../../views/dbcockpit/components/QualityItem.vue",
    "../../views/dbcockpit/components/viewCards/DashboardCard.vue",
    "../../views/dbcockpit/components/viewCards/DataGatherCard.vue",
    "../../views/dbcockpit/components/viewCards/DataStorageCard.vue",
    "../../views/dbcockpit/components/viewCards/GatherDutyCard.vue",
    "../../views/dbcockpit/components/viewCards/GatherDutyMonitorCard.vue",
    "../../views/dbcockpit/components/viewCards/GatherDutyRateCard.vue",
    "../../views/dbcockpit/components/viewCards/QualityCheckCard.vue",
    ... 此处省略5000++
    ]