前言
最近给新公司的项目【后台管理】改 Bug ,测试提了这样一个 问题 :每次进入页面加载 loading 都很久,期间用户操作不了,体验感不好。
由于有许多字典数据是在【平台端】定义,而当前的这一【后台管理】通过 接口 获取这些字典,并非使用单纯的前端定义——也即代码中定义常量。
比如说,在【平台端】通过前端页面,定义/增加以下字典:
[, {value: "0", label: "男"}, {value: "1", label: "女"},]
接着,【后台管理】通过 字典接口 获取到以上相应的键值。
但在【后台管理】某一比较复杂的页面中,一旦 字典接口 调用过多,就可能会有以下结果:
- 需要等待这些字典数据全部获取完毕,才会关闭 全屏loading ,允许用户操作。不然用户使用了某个 本该有却没有数据 的组件,这将被视为 事故。
接着,我看了下浏览器的【网络面板】,发现在用户执行 增删改查 中的 增、改、查详情 功能时,每一次弹出的 Dialog 组件都会重新调用字典数据。
也就是说,对于用户而言,每次进行操作,都要等待 全屏loading 的消失。
实际上,这些数据是完全没必要 重新加载 的。
具体解决
在【后台管理】某一复杂页面中,页面结构如下:
--pageA
---components
----B.vue
----C.vue
---index.vue
- 分页表格页面 A & 操作按钮。
- 增改查Dialog(子组件 B )。
- 嵌套孙组件 C。
在这些子孙组件中都有各自的字典数据需要加载,按照 只需要加载一次 的逻辑,我将它们都放在 主页面A 中统一加载,按照父子、祖孙组件的传值方法,分别使用了 prop 和 provide/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 ,就有类似的麻烦。
另外,简单补充下组件通信的集中方式:
- 父子通信 prop。
- 祖孙通信 provide/inject。
- 父子通信 ref:子组件暴露方法,父组件通过ref使用。
- 全局 store:Vuex/Pinia。
- 事件总线 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 中的数据类型分为两种:
- 基本数据类型,比如:string、bool、number等
- 引用数据类型,比如:Object、Array、Set、Function等
对于缓存数据,引用数据类型一般常见的就是Object、Array两种数据类型。这里使用 instanceof 进行判断,而非 typeof,因为后者无法区分对象Object和数组Array,结果判断输出都是 object。
因此这个方法,无论存的键相应的值是何类型,都能判断它是否存在。
那么,回到目录【具体解决/主页面A】中,我们需要做三件事:
- 初始化数据字段:以便页面直接使用。
- 拦截请求:根据某种标识,判断是否要进行字典请求。
- 设置当前页面请求完成标识。
在步骤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);