【前端工作log1】处理多接口串行并发导致的页面加载缓慢&加入缓存机制

95 阅读7分钟

前言

最近给新公司的项目【后台管理】改 Bug ,测试提了这样一个 问题 :每次进入页面加载 loading 都很久,期间用户操作不了,体验感不好。

由于有许多字典数据是在【平台端】定义,而当前的这一【后台管理】通过 接口 获取这些字典,并非使用单纯的前端定义——也即代码中定义常量。

比如说,在【平台端】通过前端页面,定义/增加以下字典:

[,    {value: "0", label: "男"},    {value: "1", label: "女"},]

接着,【后台管理】通过 字典接口 获取到以上相应的键值。

但在【后台管理】某一比较复杂的页面中,一旦 字典接口 调用过多,就可能会有以下结果:

  • 需要等待这些字典数据全部获取完毕,才会关闭 全屏loading ,允许用户操作。不然用户使用了某个 本该有却没有数据 的组件,这将被视为 事故

接着,我看了下浏览器的【网络面板】,发现在用户执行 增删改查 中的 增、改、查详情 功能时,每一次弹出的 Dialog 组件都会重新调用字典数据。

也就是说,对于用户而言,每次进行操作,都要等待 全屏loading 的消失。

实际上,这些数据是完全没必要 重新加载 的。

具体解决

在【后台管理】某一复杂页面中,页面结构如下:

--pageA 
---components
----B.vue
----C.vue
---index.vue
  1. 分页表格页面 A & 操作按钮。
  2. 增改查Dialog(子组件 B )。
  3. 嵌套孙组件 C

在这些子孙组件中都有各自的字典数据需要加载,按照 只需要加载一次 的逻辑,我将它们都放在 主页面A 中统一加载,按照父子、祖孙组件的传值方法,分别使用了 propprovide/inject

主页面A

<template>
    <B
       ref="onlyAddRef"
       :list-a="listA"
       :list-b="listB"
    />
​
    <C/>
</template>
​
<script setup>
import {provide, onMounted} from "vue";
// B组件需要数据
const listA = ref<OptionType[]>([]); 
const listB = ref<OptionType[]>([]); 
// C组件需要数据
const listC = ref<OptionType[]>([]); 
const listD = ref<OptionType[]>([]); 
......
const getOption = async () => {
  let res1 = await DictAPI.getOptions("type_A");
  listA.value = res1.data;
  let res2 = await DictAPI.getOptions("type_B");
  listB.value = res2.data;
  let res3 = await DictAPI.getOptions("type_C");
  listC.value = res3.data;
  let res4 = await DictAPI.getOptions("type_D");
  listD.value = res4.data;
  ......
};
    
// 祖孙组件通信,传递数据
provide("listC", listC);
provide("listD", listD);
    
onMounted(() => {
    getOption();
})
</script>

子组件B.vue

<template>
    <el-col :span="10">
        <el-form-item label="" prop="attributeId">
            <el-select filterable v-model="ruleForm.attributeId" clearable>
                <el-option :label="i.label" :value="i.value" v-for="(i, d) in props.listA" :key="d" />
            </el-select>
        </el-form-item>
    </el-col>
</template><script setup>
// 子组件B中定义传入的属性
const props = defineProps({
  listA: { type: Array<OptionType>, default: () => [] }, 
  listB: { type: Array<OptionType>, default: () => [] }, 
  ......
});
</script>

孙组件C.vue

<template>
    <el-col :span="10">
        <el-form-item label="" prop="attributeId">
            <el-select filterable v-model="ruleForm.attributeId" clearable>
                <el-option :label="i.label" :value="i.value" v-for="(i, d) in props.listC" :key="d" />
            </el-select>
        </el-form-item>
    </el-col>
</template><script setup>
import {inject} from "vue";
// 接收祖孙组件通信注入的数据
const listC = inject("listC");
const listD = inject("listD");
</script>

补充

以上就直接贴出代码,不过多解释了。

需要补充的是,对于 子组件B 而言,其实也是可以通过 依赖注入provide/inject 来实现数据传递的。

但我并没有这样,原因是对于某一个组件而言,比如 孙组件C,一旦组件层级过深,inject 的数据来源就会不清楚——它可能来自某一个父/祖组件,都有可能,那么就无法判断具体来自哪。虽然在以上实际情况中,无论是 子组件B 还是 孙组件C 都只是业务组件,而非通用组件,或许并不会被频繁使用,或许层级并不深。

我之前用 Vue2 中的 mixin ,就有类似的麻烦。

另外,简单补充下组件通信的集中方式:

  1. 父子通信 prop
  2. 祖孙通信 provide/inject
  3. 父子通信 ref:子组件暴露方法,父组件通过ref使用。
  4. 全局 storeVuex/Pinia
  5. 事件总线 EventBus:我还没用过。

优化:加入缓存机制

这样看起来,好像事情已经解决了:接口会在主页面统一加载一次,此后不再重复请求。

但不得不先提到一个词了:微前端。 简单来讲,就是一个主应用下有多个子应用,每个子应用(子项目)独立开发、独立技术栈、独立部署,互不干扰。

我上述所说的 问题和方法 是基于其中一个子应用,每当子项目的 路由 进行切换时,都会触发项目的重新加载,进而导致每当进入一个页面,都是初次加载。——这可能和微前端的设置相关,我后面要了解下。

那么,现在就有一个问题了,每进入一个页面,4、5个甚至6、7个 字典接口 都将 串行并发 (同一段时间内,多个接口同步请求) ,依然会造成首屏加载时间过长。即便当前页面 已加载过再次进入 仍然有这个问题。

既然暂时没法从路由配置上找解决办法——比如设置了keepAlive好像也没生效,那么有没有一种方法,能够做出进一步的优化呢?

现在需求是希望减少 重复 的字典请求,而之前所做的事情是:对于每个页面无论组件层级,字典接口都提升到主页面中进行。 那么是不是,将这些请求的字典数据缓存起来,下次在请求前判断是否已有该缓存数据就可以了?

Pinia缓存仓库:CacheData

import { defineStore } from "pinia";
import piniaPersistConfig from "@/stores/helper/persist";
​
export const useCacheDataStore = defineStore({
  id: "cache-data",
  state: () => ({
    cacheData: {} as Record<string, any>
  }),
  actions: {
    setData(key: string, value: any) {
      this.cacheData[key] = value;
    },
    getData(key: string): any {
      return this.cacheData[key];
    },
    removeData(key: string) {
      this.cacheData[key] = null;
      delete this.cacheData[key];
    },
    isExistInCache(key: string) {
      if (!this.cacheData[key]) return false;
      const value = this.getData(key);
      if (value instanceof Array) return !!value.length;
      else if (value instanceof Object) return !!Object.keys(value).length;
      else return !!value;
    }
  }
});

以上设置的了一个仓库,cacheData 就是我们要关注的缓存数据,键为字符串string,值为任意类型any,接着四个方法,对应了缓存数据的增、取、删、查(是否存在)。

对于 isExistInCache 方法,已知 js 中的数据类型分为两种:

  1. 基本数据类型,比如:string、bool、number等
  2. 引用数据类型,比如:Object、Array、Set、Function等

对于缓存数据,引用数据类型一般常见的就是Object、Array两种数据类型。这里使用 instanceof 进行判断,而非 typeof,因为后者无法区分对象Object和数组Array,结果判断输出都是 object

因此这个方法,无论存的键相应的值是何类型,都能判断它是否存在。

那么,回到目录【具体解决/主页面A】中,我们需要做三件事:

  1. 初始化数据字段:以便页面直接使用。
  2. 拦截请求:根据某种标识,判断是否要进行字典请求。
  3. 设置当前页面请求完成标识。

在步骤2、3间,就是我们正常的接口请求。


import { storeToRefs } from "pinia";
import { useCacheDataStore } from "@/stores/modules/cacheData";
const cacheDataStore = useCacheDataStore();
const { isExistInCache, setData } = cacheDataStore;
const { cacheData } = storeToRefs(cacheDataStore);
​
// 初始化字段
const listA = reactive(cacheData.value["type_A"]); 
​
const getOption = async () => {
  // 拦截请求
  if (isExistInCache("pageA")) return;
    
  let res1 = await DictAPI.getOptions("type_A");
  Object.assign(listA, res1.data);
  ...略...
    
  // 缓存数据
  setData("type_A", listA);
  // 标识当前页的字典接口已加载完毕
  setData("pageA", true);
};
​
    
onMounted(() => {
    getOption();
})

优化组件对共享数据的使用

之前说,字典数据通过 prop 传入子组件,通过 provide/inject 传给孙组件。 而此时既然加入了共享仓库 store 的概念,那么直接在子孙组件中使用就好了。

这样能够避免繁琐的导入,比如子组件,既要声明 prop 类型、必填等信息,又要在父组件的 html 结构中导入数据。

子组件B.vue:

<template>
    <-- 使用listA数据 -->
</template><script setup>
import { computed } from "vue";
import { storeToRefs } from "pinia";
import { useCacheDataStore } from "@/stores/modules/cacheData";
const cacheDataStore = useCacheDataStore();
const { isExistInCache, setData } = cacheDataStore;
const { cacheData } = storeToRefs(cacheDataStore);
    
const listA = computed(() => cacheData.value["type_A"]);
</script>

更新取数据方式

后来转念一想,似乎还可以再优化一些,比如缓存数据的取值方法。 getDiction 是封装的公共接口,用于请求字典数据,以下逻辑就是判断 字段 是否已经在仓库中存在,有则直接取,无则请求后既存又取。

缓存仓库CacheData.ts:

async getData(key: string) {
    if (this.cacheData[key]) {
        return this.cacheData[key];
    } else {
        const res = await getDiction(key);
        this.cacheData[key] = res.data;
        return res.data;
    }
},  

然后,对于某一个组件或页面,可以直接从缓存中读取数据,不管它是否存在,通过这个方法,程序一定可以得到一个结果:字典数据。

import { useCacheDataStore } from "@/stores/modules/cacheData";
const cacheDataStore = useCacheDataStore();
const { getData } = cacheDataStore;  
​
const listA = reactive([])
const data = await getData("type_A");
Object.assign(listA, data);
​
​