基于微前端qiankun的tab切换

背景

我们的平时做后台,如果项目越来越大,就会使用一些微前端框架来满足我们的需求,拆分为一个一个的小型应用。今天我们来讨论的就是基于微前端框架 qiankun,如何做一个全局的 tab 切换的功能。

首先一个基本的 tab 切换功能是很简单的,甚至直接使用 element-ui 等组件库的组件就可以完成,但是基于微前端的 tab 切换需要考虑以下几点:

  1. 当切换到的页面是子应用时候,需要去加载对应的子应用页面
  2. 列表页打开不同的详情页要打开多个详情页tab
  3. tab 切换时要保存页面状态
  4. 每个 tab 标签可以自定义标题
  5. 列表页跳转详情页改变状态后,再返回列表页自动更新列表状态

基于以上几个问题,我们一点一点的来完善一个 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;

最终效果

在这里插入图片描述

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Lvan的前端笔记

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值