场景介绍:
最近做后台管理系统业务的时候,有个实现多页签的需求,ui如上图。做之前,项目场景是这样的,整个后台管理系统使用qiankun搭建,一个admin基座和12个子应用,基座和子应用的技术栈都是vue2.0 + element UI。在这个基础上,增加多页签的需求,刚开始以为挺简单,keep-alive就完事,后面发现并不简单,keep-alive只能缓存单个应用中的页面,而qiankun框架下切换不同的子应用,还需要缓存每个子应用的状态,而qiankun本身并不支持多页签功能,所以啥都得自己来写,网上扒了很多相关资料都写得不清不楚的,自己一点点踩坑才搞定这个需求,所以这里记录一下。
方案选择:
多页签缓存,核心便在于缓存页面状态,实现了页面状态的缓存,便可以在下次访问时,恢复该页面在跳转之前的状态,那么如何缓存?
如果是单应用的话,仅通过keep-alive便能实现,那在基于qiankun的多个子应用下呢?则不仅每个子应用都需要缓存自己应用内部的页面状态,还需要实现缓存每个子应用的状态,这样才能实现在不同的子应用之间切换页面时,还能恢复目标子应用的目标页面的状态。
这样就产生了两个必须要做的事情:
1、每个子应用都必须通过keep-alive来缓存页面状态
2、缓存每个子应用的状态
第一件事情很简单,这里主要说第二件事情,第二件事情的实现基于qiankun框架来说,其实有两种方案:
1、多个子应用同时存在。
实现思路:
在qiankun子应用生命周期钩子unmount中,不调用instance.$destroy(),不卸载子应用即能实现当前子应用的所有页面状态保持,在切换子应用时,通过display:none来控制子应用的显示和隐藏。
优缺点:
这个方案有个缺点就是,子应用切换时不卸载会导致DOM节点和事件监听过多,严重时会造成页面卡顿,而且多个子应用存在时,css样式会相互影响,特别是在body节点下的各种全局弹框样式不好控制,样式会重叠影响,qiankun自带的样式隔离存在很多问题。
2、同一时间仅加载一个子应用,同时保存其他应用的状态。
实现思路:
在qiankun子应用生命周期钩子unmount中,将当前子应用实例中的虚拟dom、router等数据缓存到基座中后再卸载。这样每次切换子应用,都能保存上一个子应用的虚拟dom、router等数据,方便在下次进入该子应用时,可以判断基座缓存中是否存在该子应用缓存的虚拟dom相关数据,存在则通过基座中缓存的虚拟dom来渲染当前子应用的实例并挂在到容器中,以此来实现恢复当前子应用的所有页面状态。
优缺点:
同一时间,只是展示一个子应用的active页面,可减少DOM节点数;非active子应用卸载时同时会卸载DOM及不需要的事件监听,可释放一定内存。但是目前没有现有的API可以快速实现,需要自己管理子应用缓存,实现较为复杂。
方案选择:
这里我们选中的是第二种方案,也是目前问题最少,最容易解决的方案。
代码实现:
在admin基座中:
1、基座中使用loadMicroApp来加载子应用。至于为什么不用registerMicroApps,经过踩坑发现,一是registerMicroApps不支持动态路由,也就是说路由一但确定,并调用start之后,再更改路由,不会加载新增的子应用;二是loadMicroApp能更方面的主动控制子应用的加载时机和卸载时机。loadMicroApp不仅提供子应用的加载,还可以向子应用传递数据,这里我们将加载子应用的逻辑抽成deployMicroApp.js。
// deployMicroApp.js
class DeployMicroApp {
constructor() {
const initState = {
localDebugEntryUrlConfig: entryConfig,
defaultDebugEntryUrl: undefined,
microApp: null,
microAppName: "",
};
Object.assign(this, initState);
}
async initMicroApp(appName) {
if(!appName) return;
// 加载新的子应用之前,卸载之前的子应用
await this.unmountMicroApp();
// 从store中获取每个子应用的入口url
const { appMenuList: menuTree } = store?.state?.user;
if (!Array.isArray(menuTree)) return;
const targetAppData = menuTree.filter(
(item) => item.resCode === appName
)[0];
const { resCode, resUrl } = targetAppData || {};
const devEntryUrl = this.getLocalDebugEntryUrl(resCode) || resUrl;
// 本地开发时,子应用入口url设置为本地地址
const entryUrl = this.isDev() ? devEntryUrl : resUrl;
const props = {
token: getToken(),
mainService,
shares,
createMainRouterView,
};
const _app = {
name: resCode,
entry: entryUrl,
container: `#${appName}`,
props,
};
this.microAppName = appName;
this.microApp = await loadMicroApp(_app);
}
async unmountMicroApp() {
if (this.microApp) {
await this.microApp?.unmount();
this.microApp = null;
this.microAppName = "";
}
}
async unmountAllMicroApp() {
await this.unmountMicroApp();
// 清除基座所有子应用虚拟dom等相关数据缓存
mainService.clearCacheMap();
}
getLocalDebugEntryUrl(code) {
return this.localDebugEntryUrlConfig[code] || this.defaultDebugEntryUrl;
}
isDev() {
return process.env.NODE_ENV === "development";
}
}
export const new DeployMicroApp();
2、基座路由配置,在基座中,一般我们的路由除了login页面、首页、个人信息等页面,剩下的便是各个子应用的页面配置,子应用的路由配置都是使用通配符配置,而子应用的component都指向容器页面microAppContainer。
// 路由大致配置
export const baseRoutes = [
{
path: "/login",
name: "login",
component: () => import("@/views/login/index"),
},
{
path: "/",
redirect: "/home",
component: Layout,
name: "basePage",
children: [
{
name: "home",
path: "/home",
component: () => import("@/views/home/index"),
},
{
name: "microApp-01",
path: "/microApp-01/*",
component: () => import("@/views/microAppContainer/index.vue"),
meta: { microAppName: "microApp-01" },
},
{
name: "microApp-02",
path: "/microApp-02/*",
component: () => import("@/views/microAppContainer/index.vue"),
meta: { microAppName: "microApp-02" },
},
{
name: "microApp-03",
path: "/microApp-03/*",
component: () => import("@/views/microAppContainer/index.vue"),
meta: { microAppName: "microApp-03" },
},
......
],
};
];
3、在microAppContainer容器页面中,在watch中监控$route的变化,判断否切换了子应用,加载新的子应用。
// microAppContainer/index.vue
watch: {
$route: {
handler(newValue, oldValue) {
const newMicroName = newValue?.meta?.microAppName;
const oldMicroName = oldValue?.meta?.microAppName;
// 子应用相互切换
if (newMicroName && newMicroName !== oldMicroName)
deployMicroApp.initMicroApp(newMicroName);
},
immediate: true,
deep: true,
},
},
4、在mainService.js中的MainService类中实现unmountCache方法,通过loadMicroApp下发到各个子应用中,在子应用的mount中调用getCache方法获取基座缓存并将此子应用添加到多页签的白名单中,在子应用unmount中调用unmountCache将子应用虚拟dom相关数据在卸载之前缓存到基座中。
tips:由于此时的多页签需求涉及的子应用比较多,所以都是循环渐进的接入多页签功能的,凡是白名单中的子应用则会加载多页签的相关逻辑,白名单之外的保持不变。
// mainService.js
import shares from "@/qiankunUtils/shares";
class MainService {
constructor() {
this.microAppCacheMap = new Map();
this.appRootMenuCodes = [];
this.microAppNameWhitelist = [];
}
// 调用此方法的子应用,将自动添加进入多页签的白名单
getCache(cacheKey) {
if (!this.microAppNameWhitelist.includes(cacheKey)) {
this.microAppNameWhitelist = [...this.microAppNameWhitelist, cacheKey];
shares.$emit(
shares.directivePool.multiTabWhitelistChange,
this.microAppNameWhitelist
);
}
return this.microAppCacheMap.get(cacheKey);
}
// 父应用提供unmountCache方法,子应用卸载时,将虚拟dom相关数据缓存到基座
unmountCache(cacheKey, instance) {
if (!cacheKey || !instance) return;
// 缓存当前虚拟dom相关数据之前,判断当前页签中是否含有当前子应用的页签,不包含时,不缓存
if (!this.appRootMenuCodes.includes(cacheKey)) return;
const cachedInstance = {};
cachedInstance._vnode = instance._vnode;
// keepalive设置为必须 防止进入时再次created,同keep-alive实现
if (!cachedInstance._vnode.data.keepAlive)
cachedInstance._vnode.data.keepAlive = true;
cachedInstance.$router = instance.$router;
cachedInstance.apps = [].concat([instance.$router.apps[0]]);
cachedInstance.$router.app = null;
this.microAppCacheMap.set(cacheKey, cachedInstance);
}
// 清除缓存的所有子应用虚拟dom相关数据
clearCacheMap() {
this.microAppCacheMap.clear();
this.appRootMenuCodes = [];
this.microAppNameWhitelist = [];
}
// 页签数据变化时,同步基座缓存数据
syncInstance(names) {
const nowMicroAppNames = Array.isArray(names) ? names : [];
this.appRootMenuCodes = nowMicroAppNames;
if (nowMicroAppNames.length) {
// 页签中子应用存在时,剔除页签中不存在的子应用虚拟dom数据
const oldNames = [...this.microAppCacheMap.keys()];
oldNames.forEach((k) => {
if (!nowMicroAppNames.includes(k)) this.microAppCacheMap.delete(k);
});
} else {
// 页签中子应用不存在时,清除所有缓存在基座的子应用虚拟dom数据
this.clearCacheMap();
}
}
}
const mainService = new MainService();
export default mainService;
5、在share.js中实现Shares类,这里主要通过事件总线的方式来实现基座与子应用之间的通信,至于为什么不使用qiankun自带的initGlobalState、onGlobalStateChange,是因为踩坑后发现,action.onGlobalStateChange同时只会触发一次,在同一个应用中,如果有多个地方都调用了action.onGlobalStateChange,那么只有最后一个onGlobalStateChange会触发·····反正不怎么好用!!!
// share.js
import Vue from "vue";
class Shares {
constructor() {
this.state = {};
this.directivePool = {
multiTabChange: "MULTITABCHANGE", // 基座页签数据变化
refreshNowPage: "REFRESHNOWPAGE", // 手动刷新当前页面
multiTabCompletion: "MULTITABCOMPLETION", // 补全基座页签数据中的pageName
multiTabWhitelistChange: "MULTITABWHITELISTCHANGE", // 多页签白名单变化
};
this.bus = new Vue();
}
$getState() {
return this.state;
}
$emit(emitKey, emitParams) {
// 缓存页签数据
if (emitKey === this.directivePool.multiTabChange)
this.state = { ...this.state, ...emitParams };
return this.bus.$emit(emitKey, emitParams);
}
$on(onkey, callback) {
return this.bus.$on(onkey, callback);
}
}
const shares = new Shares();
export default shares;
6、子应用keep-alive逻辑抽离到createMainRouterView.js。要做多页签,每个子应用都需要做keep-alive,以及页签的增删查改、刷新的同时与子应用通信,子应用做相应的处理等等相关逻辑,实际上是可以抽离成函数组件,在基座做统一管理的。
// createMainRouterView.js
import shares from "@/qiankunUtils/shares";
export default (microAppName) => {
return {
name: "MainRouterView",
data() {
return {
microAppName,
multiTabOptions: [],
keepAliveNames: [],
refreshBeforeKeepAliveNames: [],
routerFlag: true,
nowMultiTabData: {},
};
},
deactivated() {
this.routerFlag = false;
},
activated() {
this.routerFlag = true;
},
created() {
if (shares.state) this.handleMultiTabData(shares.state);
},
watch: {
$route: {
handler(to) {
if (to.matched && to.matched.length > 1) this.getNowPageName(to);
},
immediate: true,
deep: true,
},
},
mounted() {
// 监控基座页签数据变化
shares.$on(shares.directivePool.multiTabChange, (params) => {
this.handleMultiTabData(params);
});
// 监控基座触发页签刷新
shares.$on(shares.directivePool.refreshNowPage, (params) => {
this.handleRefreshNowPage(params);
});
},
methods: {
// 获取当前访问页面的name属性
getNowPageName(to) {
// 获取当前router-view下加载页面组件的name属性
const nowPageName = to?.matched[1]?.components?.default?.name;
// console.log(nowPageName, "====nowPageName");
if (nowPageName && !this.keepAliveNames.includes(nowPageName)) {
this.$nextTick(() => {
// 将当前页面name存储到keepalive中
this.keepAliveNames = [
...new Set([...this.keepAliveNames, nowPageName]),
];
this.$nextTick(() => {
const { changeType, changeItem } = this.nowMultiTabData;
// 新增页签数据时,页签数据中没有pageName,则补全当前页签数据中的pageName
if (changeType === "add" && changeItem && !changeItem.pageName) {
const newChangeItem = {
...changeItem,
pageName: nowPageName,
};
console.log("补全当前页面的页签数据:", newChangeItem);
// 补全基座页签数据
shares.$emit(
shares.directivePool.multiTabCompletion,
newChangeItem
);
}
});
});
}
},
// 刷新当前页面
handleRefreshNowPage(params) {
const { appName, tabData: refreshData } = params || {};
const { pageName, resUrl } = refreshData || {};
// 需要刷新的页签数据属于当前子应用时
if (appName === this.microAppName) {
// 刷新的页面为当前页面时,清除keepalive缓存,执行刷新逻辑后恢复keepalive缓存
if (resUrl && resUrl === this.$route.path) {
this.refreshBeforeKeepAliveNames = JSON.parse(
JSON.stringify(this.keepAliveNames)
);
// 删除当前页面的keepAlive缓存
this.keepAliveNames = this.keepAliveNames.filter(
(k) => k !== pageName
);
// 通过router-view的v-if指令控制页面刷新
this.routerFlag = false;
this.$nextTick(() => {
this.routerFlag = true;
this.keepAliveNames = JSON.parse(
JSON.stringify(this.refreshBeforeKeepAliveNames)
);
this.refreshBeforeKeepAliveNames = [];
});
} else {
// 刷新页面不为当前页面时,仅清除keepalive缓存
// 删除当前页面的keepAlive缓存
this.keepAliveNames = this.keepAliveNames.filter(
(k) => k !== pageName
);
}
}
},
// 监控到基座页签数据发生变化
handleMultiTabData(changeData) {
const {
changeType,
changeMicroAppName,
changeItem,
nowAcitveMicroAppName,
multiTabOptions,
} = changeData || {};
// 变化的页签数据属于当前子应用时
// 单个删除或新增时
if (
["add", "delete"].includes(changeType) &&
changeMicroAppName === this.microAppName
) {
// 基座发生页签删除时
if (changeType === "delete") {
const { pageName } = changeItem || {};
if (pageName)
// 删除对应子应用下的keepAlive缓存
this.keepAliveNames = this.keepAliveNames.filter(
(k) => k !== pageName
);
}
}
// 关闭所有页面时,所有子应用的缓存都会被清除,故不用做内部keepalive的处理
// 关闭其他页签时,则只有关闭了全部页签的子应用缓存被清除
// 当前激活的唯一子应用,将其keepalive值设置为当前激活的页面即可
if (["batchDeleteOther"].includes(changeType)) {
if (nowAcitveMicroAppName === this.microAppName) {
this.keepAliveNames = multiTabOptions.map((item) => item.pageName);
}
}
// 存储当前变化数据
this.nowMultiTabData = changeData;
},
},
render: function (h) {
// 将以下template转化为函数
// <transition
// name="fade-transform"
// mode="out-in"
// >
// <keep-alive :include="keepAliveNames">
// <router-view
// v-slot="{ Component }"
// v-if="routerFlag"
// >
// <Component :is="Component" :key="$route.fullPath" />
// </router-view>
// </keep-alive>
// </transition>
return h(......);
},
};
};
在子应用中:
1、修改mian.js,在main.js中接入多页签缓存相关逻辑
// mian.js
let instance = null;
function render(props = {}) {
const {
container,
name,
mainService,
createMainRouterView,
} = props;
// 从基座获取当前子应用的虚拟domn相关数据,并将当前子应用添加到基座多页签白名单中
// 非白名单子应用不会生成页签
const instanceCache = mainService.getCache(name);
if (!instanceCache) {
// 通过基座传入createMainRouterView函数生成mian-router-view组件并挂载为全局组件
if (createMainRouterView && createMainRouterView instanceof Function) {
const MainRouterView = createMainRouterView(name);
Vue.component("main-router-view", MainRouterView);
}
// 初始化路由
initRouter();
// 启用router.onBeforeEach监控
routerOnBeforeEach();
// 生成vue实例
instance = new Vue({
router: getRouter(),
store,
render: (h) => h(App)
});
} else {
// 从基座缓存获取路由
const router = instanceCache.$router;
// 追加路由实例
router.apps.push(...instanceCache.apps);
// 将路由注入当前实例中
initRouter(router);
// 启用router.onBeforeEach监控
routerOnBeforeEach();
instance = new Vue({
router,
store,
render: () => instanceCache._vnode,
});
}
instance.$mount(container ? container.querySelector("#app") : "#app");
}
export async function mount(props) {
render(props);
}
export async function unmount(props = {}) {
const { name, mainService } = props;
// 子应用卸载之前,缓存实例中的虚拟dom等数据到基座中
mainService.unmountCache(name, instance);
// vue实例卸载
instance.$destroy();
// vue实例变量清空
instance = null;
// 删除路由,若不删除,会导致缓存过的子应用,在清除缓存之后,再次重新渲染实例,路由会失效
removeRouter();
}
2、在子应用的layout组件中,用main-router-view替换掉router-view即可。
至此,多页签缓存的需求就差不多完成了,为了防止踩过的坑已经自己还踩所以记录一下大概的实现逻辑和代码,仅供参考。