文章目录
- 背景
- 主应用要做的
- 1、新建tab组件
- 2、引入组件
- 3、tabs.js核心
- 4、开始使用
- 子应用要做的
- 1、将父应用传给子应用的props挂载在Vue对象上
- 2、创建核心逻辑
- 3、将核心逻辑混入到App.vue
- 注意事项
- 分析@zy/qiankun-tabs源码
- index.js
- actions.js
- tabs.js
- 最终效果
背景
我们的平时做后台,如果项目越来越大,就会使用一些微前端框架来满足我们的需求,拆分为一个一个的小型应用。今天我们来讨论的就是基于微前端框架 qiankun,如何做一个全局的 tab 切换的功能。
首先一个基本的 tab 切换功能是很简单的,甚至直接使用 element-ui 等组件库的组件就可以完成,但是基于微前端的 tab 切换需要考虑以下几点:
- 当切换到的页面是子应用时候,需要去加载对应的子应用页面
- 列表页打开不同的详情页要打开多个详情页tab
- tab 切换时要保存页面状态
- 每个 tab 标签可以自定义标题
- 列表页跳转详情页改变状态后,再返回列表页自动更新列表状态
基于以上几个问题,我们一点一点的来完善一个 tab 切换功能。
首先我们希望,主应用只是一个壳子,最好是没有页面的,或者只有一两个页面,这样我们可以尽可能的把精力放在子应用。
主应用要做的
假设我们的微前端现在有一个主应用main、一个微应用app1
1、新建tab组件
在主应用 main 新建文件 components/tabs-switch.vue,这里把组件具体样式的制作留给业务来做(这里以elementUI的tab切换为例)
<template>
<!-- 使用elementUI 的tab组件 -->
<div class="ele-tab-main">
<el-tabs
:value="activeTab.fullPath"
type="card"
:closable="tabsList.length > 1"
@tab-remove="removeElementTab"
@tab-click="changeElementTab"
>
<el-tab-pane
v-for="(item) in tabsList"
:key="item.fullPath"
:label="item.title"
:name="item.fullPath"
/>
</el-tabs>
</div>
</template>
<script>
export default {
props: {
tabsList: {
type: Array,
default: () => []
},
activeTab: {
type: Object,
default: () => {}
}
},
methods: {
// 使用elementUI 的tab组件
changeElementTab (tab) {
if (tab.name === this.activeTab.fullPath) {
return
}
this.$router.push(tab.name)
},
removeElementTab (path) {
if (this.tabsList.length === 1) {
return
}
const index = this.tabsList.findIndex(item => item.fullPath.startsWith(path))
this.$emit('removeTab', this.tabsList[index], index)
}
}
}
</script>
<style lang="scss" scoped>
.ele-tab-main {
background-color: #fff;
}
</style>
这里注意我们很多地方使用的 fullPath 来判断,而不是使用 path,是因为我们想实现上面说的第 2 点(列表页打开不同的详情页要打开多个详情页tab),这样我们的 url 只要稍有变化,就会新打开一个 tab 页,但是如果你想要列表页打开详情页只打开一个标签,那么你可以用 path 来进行判断。
可以看到,这里的组件是一个纯 ui 组件,其逻辑和参数都由外部来控制,而外部就是我们 tab 核心。
2、引入组件
在主应用 main 的 App.vue 中加上如下代码,其中具体的分析我都写在了代码的注释中
<template>
<el-main id="mainBox">
// 引入tab切换组件
<TabsSwitch :tabsList="tabsList" :activeTab="activeTab" @removeTab="removeTab"></TabsSwitch>
// 主应用缓存,如果主应用没有页面,那么则不需要加keep-alive
<keep-alive :include="loadedRouteNames">
<router-view v-show="$route.name" :key="key"></router-view>
</keep-alive>
// 子应用挂载的节点,最终是根据这个id来挂载的
<div v-show="!$route.name">
<div
v-for="item in copyAppsList"
v-show="(routerBase + $route.path).startsWith(item.activeRule)"
:key="item.name"
:id="item.container.slice(1)"
></div>
</div>
</el-main>
</template>
<script>
import TabsSwitch from '@/components/tabs-switch.vue'
// tab切换核心逻辑
import tabs from '@/utils/tabs'
// [
// {
// name: "App1MicroApp",
// entry: '//localhost:9001',
// container: "#app1",
// activeRule: "/app1",
// props
// }
// ];
import appsList from '@/micro/apps.js'
import router from '@/router'
const copyAppsList = appsList.map(item => ({...item}))
export default {
components: {
TabsSwitch,
},
watch: {
$route: {
// <div id="app1"><div id="app"></div></div>
// <div id="app2"><div id="app"></div></div>
// 如上两个dom节点,如果先渲染了app1,这时候再点击app2,那么app2中的渲染会被渲染到app1的id="app"里,为了避免这种情况所以需要动态调换dom的顺序
handler(newValue) {
const index = appsList.findIndex(item => {
return (this.routerBase + newValue.path).startsWith(item.activeRule)
})
let copyAppsList = appsList
const spliceArr = copyAppsList.splice(index, 1)
copyAppsList.unshift(spliceArr[0])
this.copyAppsList = copyAppsList
},
immediate: true,
}
},
data() {
return {
tabsList: [],
activeTab: {},
loadedRouteNames: [],
copyAppsList: copyAppsList
}
},
computed: {
// 加key的目的是:如果不加,跳转/detail?code=0和/detail?code=1,是不会重新触发vue的mounted
key () {
return this.$route.fullPath
},
routerBase () {
const routerBase = this.$router.options.base
if (routerBase === '/' || !routerBase) {
return ''
} else {
return routerBase
}
}
},
methods: {
removeTab (item, index) {
tabs.closeTab(item, index, router)
},
getQiankunTabsData () {
tabs.onStorageChange(state => {
const { tabsList, activeTab } = state
this.tabsList = tabsList
this.activeTab = activeTab
})
tabs.onStorageChangeMain(routeNameList => {
this.loadedRouteNames = routeNameList
});
}
},
created () {
this.getQiankunTabsData()
}
}
</script>
3、tabs.js核心
上面看到我们引入了 @/utils/tabs
,作为我们切换的核心逻辑
新建文件utils/tabs.js
// @zy/qiankun-tabs这里的内容在最下面会讲到
import { Tabs } from '@zy/qiankun-tabs'
import appsList from '@/micro/apps'
import router from '@/router/index'
export default new Tabs({
apps: appsList,
router: router,
defaultTitle: '详情',
routerPush: (url) => {
router.push(url)
},
routerBase: router.options.base
})
4、开始使用
以上基本就接入完成了,我们开始使用的话首先预加载
在main.js加入代码,主要是为了预加载应用
import { prefetchQiankunApps } from "@zy/qiankun-tabs";
import apps from "./micro/apps";
prefetchQiankunApps(apps);
路由守卫进入时候打开tab,在 router/index.js 加入
import tabs from '@/utils/tabs'
// 子应用的路由跳转也可以在主应用这里监听到
router.beforeEach((to, from, next) => {
const { fullPath, name, path, query, meta } = to
const data = {
fullPath,
name,
path,
query,
meta
}
if (to.fullPath === from.fullPath) {
return
}
// 路由进入之前要打开tab
tabs.openTab(data)
next()
})
子应用要做的
1、将父应用传给子应用的props挂载在Vue对象上
将父应用传给子应用的props挂载在Vue对象上
main.js
export async function mount(props) {
// 新增
Vue.prototype.parentProps = props;
render(props);
}
2、创建核心逻辑
新建文件 mixin/tab.js
export default function ({ appName='', whiteList=[] }) {
return {
computed: {
// 加key的目的是:如果不加,跳转/detail?code=0和/detail?code=1,是不会重新触发vue的mounted
key () {
return this.$route.fullPath
}
},
data() {
return {
loadedRouteNames: []
}
},
methods: {
getLoadedRouteNames (name, fun) {
if (window.__POWERED_BY_QIANKUN__) {
this.parentProps.onGlobalStateChange(state => {
const microApp = state.loadedApp[name];
if (microApp) {
const { childRoute } = microApp
const loadedRoutes = childRoute.map(item => this.$router.resolve(item));
const loadedRouteNames = loadedRoutes.map(item => item.route.name);
fun(loadedRouteNames)
}
}, true);
} else {
fun()
}
}
},
created () {
this.getLoadedRouteNames(appName, res => {
this.loadedRouteNames = window.__POWERED_BY_QIANKUN__ ? res : whiteList;
})
}
}
}
3、将核心逻辑混入到App.vue
App.vue改造
<template>
<div>
<keep-alive :include="loadedRouteNames">
<router-view :key="key" />
</keep-alive>
</div>
</template>
<script>
import myTabMixin from './mixin/tab'
const tabMixin = myTabMixin({
// 必填:该应用的名称,同接入微前端主应用使用的name值
appName: 'App1MicroApp',
// 若需要下面的功能3,则将你要刷新的列表组件的 name 填到该数组里,否则置空数组就行
whiteList: []
})
export default {
mixins: [tabMixin],
}
</script>
以上三步,子应用必须接入
上面 功能 中说到这三个功能需要子应用接入
1、tab切换时保存状态(需子应用接入)
2、每个tab标签都可以自定义标题,若不填则默认为“详情”(需子应用接入)
3、列表页跳转详情页改变状态后,再返回列表页自动更新列表状态(需子应用接入)
我们看看如何接入:
功能1只需要满足下面的注意事项1就可以
功能2只需要满足下面注意事项2就可以
功能3需要两步骤:
1、上面 App.vue 中有个 whiteList 参数,将你要刷新的列表组件的 name 填到该数组里
2、将你要刷新的列表组件的生命周期函数created或者mounted,换成activated。
注意事项
1、路由名称问题:不管是主应用还是子应用,路由名称需要与组件名称一致,否则tab切换时将无法缓存,举例:
以下路由名称和组件名称应该是相同的
router.js
import HomeView from '@/views/HomeView.vue'
const routes = [
{
path: '/home',
// 路由名称
name: 'HomeView',
component: HomeView
}
]
views/HomeView.vue
<script>
export default {
// 组件名称
name: 'HomeView'
}
</script>
2、标题问题:不管子应用还是主应用,路由跳转的时候需要带上参数tabTitle,不带的话默认tab标题为’标题’,例如:
this.$router.push({
path: '/detail',
query: {
tabTitle: '详情页'
}
})
分析@zy/qiankun-tabs源码
包含三部分:
index.js
import Vue from 'vue'
import {
loadMicroApp,
prefetchApps,
addGlobalUncaughtErrorHandler
} from "qiankun";
import _Tabs from './tabs'
import _actions from './actions'
/**
* 添加全局的未捕获异常处理器
*/
addGlobalUncaughtErrorHandler((event) => {
console.error(event);
});
export const prefetchQiankunApps = (apps) => {
prefetchApps(apps)
}
export const manualLoadMicroApp = (app) => {
return loadMicroApp(app)
}
export const Tabs = _Tabs
export const actions = _actions
actions.js
import { initGlobalState } from 'qiankun';
const state = {
loadedApp: {}
};
// 初始化 state,返回值默认有三个函数
// onGlobalStateChange
// setGlobalState
// offGlobalStateChange
const actions = initGlobalState(state);
actions.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log('主应用检测到state变更:', state, prev);
});
actions.getGlobalStateThroughKey = (key) => {
return key ? state[key] : state
}
actions.setGlobalStateThroughKey = (key, value) => {
if (key) {
actions.setGlobalState({
...state,
...{
[key]: value,
},
});
}
}
export default actions;
tabs.js
import { manualLoadMicroApp } from "./index";
// 一个进度条插件
import NProgress from "nprogress";
import "nprogress/nprogress.css";
import actions from "./actions";
// 判断当前页签是否是微应用下的页面
const isMicroApp = function (list, path) {
return !!list.some((item) => {
return path.startsWith(item.activeRule);
});
};
const getIsExistItem = (list, currentItem, code) => {
const isExistItem = list.findIndex((item) => {
return item[code] === currentItem[code];
});
return isExistItem;
};
// 给当前的原始数据添加一些新参数,变成新的item,作为原始数据
const getCurrentItemForMain = ({ routes, defaultTitle }) => {
const { query } = routes;
const obj = {
appName: "MainApp",
title: (query && query.tabTitle) || defaultTitle,
id: "",
};
return { ...routes, ...obj };
};
const getCurrentItemForMicro = ({ routes, appConfig, defaultTitle }) => {
const { query } = routes;
const obj = {
appName: appConfig.name,
title: (query && query.tabTitle) || defaultTitle,
id: appConfig.container.slice(1),
};
return { ...routes, ...obj };
};
class Tabs {
constructor({ apps, defaultTitle, routerPush, routerBase }) {
this.appsList = apps;
this.defaultTitle = defaultTitle || '标题'
this.routerPush = routerPush
this.routerBase = routerBase
}
// 已加载的微应用
loadedApp = {};
loadedMainRoute = [];
state = {
tabsList: [],
activeTab: {}
}
callBackFun = null
getFullUrl (url) {
const routerBase = this.routerBase
if (routerBase === '/' || !routerBase) {
return '' + url
} else {
return routerBase + url
}
}
onStorageChange (fun) {
if (Object.prototype.toString.call(fun) === '[object Function]') {
this.callBackFun = fun
}
if(this.callBackFun) {
this.callBackFun(this.state)
}
}
onStorageChangeMain (fun) {
if (Object.prototype.toString.call(fun) === '[object Function]') {
this.callBackFunMain = fun
}
if(this.callBackFunMain) {
this.callBackFunMain(this.loadedMainRoute)
}
}
getTabsState () {
return this.state;
}
pushTabsList(data, index) {
this.state.tabsList.splice(index, 0, data)
this.state.activeTab = data
this.onStorageChange()
}
changeActiveTab(data) {
this.state.activeTab = data
this.onStorageChange()
}
changeTabsList(data) {
this.state.tabsList = data
this.onStorageChange()
}
pushMainRoute (routeName) {
this.loadedMainRoute.push(routeName)
this.onStorageChangeMain()
}
deleteMainRoute (index) {
this.loadedMainRoute.splice(index, 1);
this.onStorageChangeMain()
}
loadedAppAndGetList(routes, appConfig) {
const { fullPath } = routes;
const loadedApp = this.loadedApp;
try {
// 判断目前有没有加载过该子应用,如果没有的话,才去加载应用
// 如果没有加这个判断,所以从主应用跳到app1-home,在从app1-home跳到app1-about就会重新加载app1
if (!loadedApp[appConfig.name]) {
NProgress.start();
const app = manualLoadMicroApp(appConfig);
NProgress.done();
loadedApp[appConfig.name] = {
app: app,
// 这里为什么要缓存一个子应用路由的数组?因为删除标签的时候,需要判断当前微应用的页面是否全部关闭才能卸载应用
// 这里缓存主要是提供给子应用,让子应用使用keep-alive
// 子应用为什么不全部使用keep-alive呢?如果全部使用,会出现这种情况:
// 打开app1的home页和about页两个标签,在home页做一些操作,在about页面做一些操作,这时候关闭about页面,再重新打开about页,会出现之前的操作还保留的情况(因为由于没有关闭app1所有的页面,所以不会销毁app1,而且全局keep-alive),但是如果这时候是动态的keep-alive就不会有这种情况了
childRoute: [],
};
}
// '/app-vue-history/about'.replace('/app-vue-history', '')
// '/about'
const childRoutePath = this.getFullUrl(fullPath).replace(appConfig.activeRule, "");
loadedApp[appConfig.name].childRoute.push(childRoutePath);
return loadedApp;
} catch (error) {
console.error(error);
const host = location.host;
location.href = `https://${host}/admin/404`;
}
}
// routes是要打开的路由信息
openTab(routes) {
// const {
// path, // 普通路径
// fullPath, // 带参路径
// query, // query参数
// params, // params参数
// meta, // 其他参数
// name, // 路由name
// } = routes;
const {
path,
fullPath
} = routes;
const { activeTab, tabsList } = this.getTabsState();
if (activeTab.fullPath === fullPath) {
return;
}
// ----------------主应用逻辑start--------------------
if (!isMicroApp(this.appsList, this.getFullUrl(path))) {
const defaultTitle = this.defaultTitle
const currentItem = getCurrentItemForMain({ routes, defaultTitle });
// 当前item在tabList是否存在
const existItemIndex = getIsExistItem(tabsList, currentItem, "fullPath");
const existItem = tabsList[existItemIndex]
// 如果当前tabList已经有了这一条,那么直接打开这一条
if (existItem) {
this.changeActiveTab(existItem)
} else {
const rightTabIndex = getIsExistItem(tabsList, activeTab, "fullPath");
this.pushTabsList(currentItem, rightTabIndex + 1)
this.pushMainRoute(currentItem.name)
}
return false;
}
// ----------------微应用逻辑start--------------------
// 获取微应用在apps中的配置
const appConfig = this.appsList.find((item) =>
this.getFullUrl(routes.fullPath).startsWith(item.activeRule)
);
// 获取当前初始化后的item
const defaultTitle = this.defaultTitle
const currentItem = getCurrentItemForMicro({ routes, appConfig, defaultTitle });
// 当前item在tabList是否存在
const existItemIndex = getIsExistItem(tabsList, currentItem, "fullPath");
const existItem = tabsList[existItemIndex]
// 如果当前tabList已经有了这一条,那么直接打开这一条
if (existItem) {
this.changeActiveTab(existItem)
} else {
const rightTabIndex = getIsExistItem(tabsList, activeTab, "fullPath");
this.pushTabsList(currentItem, rightTabIndex + 1)
this.loadedApp = this.loadedAppAndGetList(currentItem, appConfig);
// 设置全局变量:已经加载的app列表
actions.setGlobalStateThroughKey("loadedApp", this.loadedApp);
}
}
// 移除子应用已缓存的应用
unmountAppAndGetList (routes, appConfig, tabsList) {
const { fullPath } = routes;
const loadedApp = this.loadedApp;
// '/app-vue-history/about'.replace('/app-vue-history', '')
// '/about'
const childRoutePath = fullPath.replace(appConfig.activeRule, "");
const childRouteIndex =
loadedApp[appConfig.name].childRoute.indexOf(childRoutePath);
// 删除childRoute中对应的路由
loadedApp[appConfig.name].childRoute.splice(childRouteIndex, 1);
// 比如app1的所有标签是否都已经关闭
const microTabsAllClose = tabsList.every(
(item) => !this.getFullUrl(item.fullPath).startsWith(appConfig.activeRule)
);
if (microTabsAllClose) {
loadedApp[appConfig.name].app.unmount();
loadedApp[appConfig.name] = null;
}
return loadedApp;
}
closeTab(routes, index) {
const { activeTab, tabsList } = this.getTabsState()
const { path } = routes
// 删除后当前选中的
tabsList.splice(index, 1);
this.changeTabsList(tabsList)
// 以下是显示哪个activeTab的逻辑,现在的tabList是删除掉以后的tabList
// 如果点击叉号的routes是activeTab,那么activeTab右边的重置为最新的activeTab
if (routes.fullPath === activeTab.fullPath) {
// 这里的index是原来tabsList的后面的一位
const rightTab = tabsList[index];
// 如果点击了最后一位tab,那么activeTab就是当前列表的最后一个,否则就是右边的一个
if (index === tabsList.length) {
// 通过路由跳转,直接走router中的beforeEach逻辑了,所以这里的数据就不用管了
// this.changeActiveTab(tabsList[tabsList.length - 1].fullPath)
this.routerPush(tabsList[tabsList.length - 1].fullPath);
} else {
// this.changeActiveTab(rightTab.fullPath)
this.routerPush(rightTab.fullPath);
}
}
// 卸载子应用
if (isMicroApp(this.appsList, this.getFullUrl(path))) {
// 获取微应用在apps中的配置
const appConfig = this.appsList.find((item) =>
this.getFullUrl(routes.fullPath).startsWith(item.activeRule)
);
this.loadedApp = this.unmountAppAndGetList(routes, appConfig, tabsList)
actions.setGlobalStateThroughKey("loadedApp", this.loadedApp);
} else {
this.deleteMainRoute(index)
}
}
}
export default Tabs;