背景
改造前的项目技术栈是 Vue全家桶(vue2.6.10+element2.12.0+webpack4.40.2+vue-cli4.5.7),用到了动态菜单、菜单权限等,路由使用history
模式,所以本篇介绍的都是关于Vue
接入QianKun
。
微前端概念
- 类型
<iframe></iframe>
一样,只不过微前端是用fetch去请求js并渲染在指定的DOM容器。 - 跟技术栈无关,任何前端技术栈都可以接入。
- 多个应用结合在一起,可以一起运行,又可以单独运行。
- 一个复杂庞大的项目拆成多个微应用,单独开发、单独部署、单独测试,互不影响。
- 原理是通过在主应用引入每个子应用的入口文件(main.js),进行解析,并指定渲染的容器(DOM),其次每个子应用设置打包的文件为UMD,然后在main.js暴露(
export
)生命周期方法(bootstrap
、mount
,unmount
),然后再其mount
进行渲染,也就是new Vue(...)
,并在unmount
执行destory
。
什么时候需要用到微前端
- 类似于ERP系统的。
- 庞大的系统需要拆分给不同团队去做时。
- 系统里面有很多个模块,模块里面又很多个子模块时。
Qiankun 用到的API介绍
registerMicroApps(apps, lifeCycles?)
自动挡加载模块,一次性写好配置,直接传入,然后调用start()
,qiankun
会自动监听url变化调用对应的应用暴漏的生命周期函数。start(opts?)
配合registerMicroApps
使用,当调用registerMicroApps
后,运行启动。loadMicroApp(app, configuration?)
手动加载模块,需要自己监听Url并手动加载模块。addGlobalUncaughtErrorHandler(handler)/removeGlobalUncaughtErrorHandler(handler)
添加/移除监听应用加载错误。initGlobalState(state)
初始化全局共享状态,类似于vuex,返回三个个方法,分别是setGlobalState(state)
和onGlobalStateChange((newState, oldState) => {})
setGlobalState(state)
设置全局状态onGlobalStateChange((newState, oldState) => {})
监听全局状态变化
app参数说明:
// apps 应用信息 // name 应用名称(唯一) // entry 应用访问地址(唯一) // container 应用渲染节点 // activeRule 应用触发的URL前缀(唯一) // props 传递给子应用的参数 [ { name: 'pms', entry: 'http://localhost:7083/', container: '#subView', activeRule: '/module/pms', loader: (loading) => console.log(loading), props: { routerBase: '/module/pms', // 子应用的路由前缀(router的base) routerList: [...], // 子应用的路由列表 ... } }, ... ]
落地实施开始
项目结构
| -- erp | -- .git | -- common // 公共模板 | -- main // 主应用 | -- package.json | -- pms // pms应用 | -- package.json | -- oms // oms应用 | -- package.json | -- tns // tns应用 | -- package.json | -- wns // wns应用 | -- package.json | -- package.json
路由设计
首先,项目是有一个登录页的,但是登录页不加载子应用,只有通过登录成功后,跳到第一个页面,才进行加载子应用的。
先统一术语:登录页、启动页
这里区分一起运行和独立运行,先讲讲一起运行
一起运行
一起运行是指在主应用(main)登录,登录成功后跳转到对应的子页面。
/login -> 登录页
/module/ -> 登录成功后默认到启动页,全局路由守卫在这里判断,判断跳到这个路由,根据获取路由表数据,再跳入到路由表的第一个路由;如果路由表没数据,则代表这个用户没有菜单,那就也没权限,直接跳到回登录页,并提示就OK,不过还是看你公司产品怎么定。
主应用登录成功后,把路由存到全局状态里,除了主应用addRoute
添加路由外,有两种思路处理子应用动态菜单
- 在路由守卫获取所有菜单后,然后通过判断前缀,把相应的子应用路由通过
apps
配置的props
传递进去。 - 每个子应用第一次运行时,在全局路由守卫判断是一起运行的,直接获取全局状态里的路由表,循环判断是否属于当前子应用的路由,再
addRoute
进去。
这里的启动页的组件指向Layout
,动态加载路由会装入到Layout
的子路由,保证第一次进来启动微应用,跳转路由时,则不会触发。
既然/module/
是启动页了,那么拼接子页面的?举以下几个例子,
/module/pms/A // pms应用 A页面 /module/pms/B // pms应用 B页面 /module/oms/A // oms应用 A页面
子应用的路由前缀,都基本一样,是不是每次都要写?其实只要在子应用的路由base属性设置前缀,比如pms应用,则设置base: '/module/pms'。
new Router({ base: '/module/pms', routes, mode: 'history' })
独立运行
独立运行是指子应用独立运行,运行后登录页、Layout
基础模块包括菜单、注销,还能正常开发和使用。
这个时候就需要把登录页、Layout
、App
三个模块迁移到common模块,通过引入的方式;然后根据window.__POWERED_BY_QIANKUN__
判断当前运行环境是否独立运行做相对应的逻辑处理。
window.__POWERED_BY_QIANKUN__
true, 一起运行window.__POWERED_BY_QIANKUN__
false, 独立运行
// pms应用 独立运行 /module/pms/login -> 登录页 /module/pms/ -> Layout /module/pms/A -> A页面 /module/pms/B -> B页面
准备材料:
- 应用名,这里假如叫
pms
- 端口号,避免跟已有应用冲突,比如 7083
- 固定前缀,这里跟你的路由设计有关系,我取
/module/
公共包配置
公共包主要是为了集成一些公共模块,比如axios
、element ui
、dayjs
、样式、store
、utils
,子应用直接引入即可。
如果公共包有安装对应的插件,则不用在子应用再次安装,直接引入即可。这里举例element-ui
cd common npm i element-ui -S
// pms 子应用 main.js import { Message } from 'common/node_modules/element-ui' Message('提示内容')
| -- common | -- src | -- api | -- components // 公共组件 | -- pageg | -- layout | -- App.vue | -- plugins // element、dayjs、v-viewer | -- sdk | -- fetch.js // axios封装 | -- store | -- commonRegister.js // 动态vuex模块,与onGlobalStateChange结合使用 | -- styles | -- utils | -- index.js | -- package.json
- cd 进入 common
- 并在执行
npm init -y
,会生成package.json
文件。 - 修改入口文件路径,
main
属性为src/index.js
,"main": "src/index.js"
- 并在执行
- 修改
main.js
文件内容,具体是什么,看你项目情况而定。
import store from './store' import plugins from './plugins' import sdk from './sdk' import * as utils from './utils' import globalComponents from './components/global' import components from './components' import * as decorator from './utils/decorator' export { store, plugins, sdk, utils, decorator, globalComponents, components }
commonRegister.js
全局状态
commonRegister.js
参考微前端qiankun从搭建到部署的实践中的主应用的状态封装。
// commonRegister.js /** * * @param {vuex实例} store * @param {qiankun下发的props} props * @param {vue-router实例} router * @param {Function} resetRouter - 重置路由方法 */ function registerCommonModule(store, props = {}, router, resetRouter) { if (!store || !store.hasModule) { return } // 获取初始化的state // eslint-disable-next-line no-mixed-operators const initState = (props.getGlobalState && props.getGlobalState()) || { menu: null, // 菜单 user: {}, // 用户 auth: {}, // token权限 app: 'main' // 启用应用名,默认main(主应用),区分各个应用下,如果运行的是pms,则是pms,用于判断路由 } // 将父应用的数据存储到子应用中,命名空间固定为common if (!store.hasModule('common')) { const commonModule = { namespaced: true, state: initState, actions: { // 子应用改变state并通知父应用 setGlobalState({ commit }, payload = {}) { commit('setGlobalState', payload) commit('emitGlobalState', payload) }, // 初始化,只用于mount时同步父应用的数据 initGlobalState({ commit }, payload = {}) { commit('setGlobalState', payload) }, // 登录 async login({ commit, dispatch }, params) { // ... dispatch('setGlobalState') }, // 刷新token async refreshToken({ commit, dispatch }) { // ... dispatch('setGlobalState') }, // 获取用户信息 async getUserInfo({ commit, dispatch }) { // ... dispatch('setGlobalState') }, // 登出 logOut({ commit, dispatch }) { to(api.logout()) commit('setUser') commit('setMenu') commit('setAuth') dispatch('setGlobalState') if (router) { router && router.replace && router.replace({ name: 'Login' }) } else { window.history.replaceState(null, '', '/login') } resetRouter && resetRouter() // 重置路由 }, // 获取菜单 async getMenu({ commit, dispatch, state }) { // ... dispatch('setGlobalState') }, setApp({ commit, dispatch }, appName) { commit('setApp', appName) dispatch('setGlobalState') } }, mutations: { setGlobalState(state, payload) { // eslint-disable-next-line state = Object.assign(state, payload) }, // 通知父应用 emitGlobalState(state) { if (props.setGlobalState) { props.setGlobalState(state) } }, setAuth(state, data) { state.auth = data || {} if (data) { setToken(data) } else { removeToken() } }, setUser(state, data) { state.user = data || {} }, setMenu(state, data) { state.menu = data || null }, setApp(state, appName) { state.app = appName } }, getters: { // ... } store.registerModule('common', commonModule) } else { // 每次mount时,都同步一次父应用数据 store.dispatch('common/initGlobalState', initState) } }
子应用配置
- 修改
package.json
:name
属性为应用名。dependencies
属性添加一个"common": "../common"
,为了引入公共包。
-
- 修改
vue.config.js
的publicPath
属性固定前缀+应用名,/module/pms
。 - 设置
header
允许跨域请求。 - 引入
package.json
,设置publicPath
为固定前缀+应用名、configureWebpack.output
设置打包后的格式为UMD方便Qiankun
引入和设置公共包common
参与编译。// vue.config.js const { name } = require('./package.json') module.exports = { publicPath: `/module/${name}`, // /module/pms devServer: { // 端口号配置在环境变量中 port: process.env.VUE_APP_PORT, headers: { 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-cache', Pragma: 'no-cache', Expires: 0 } }, ... configureWebpack: { output: { // 把子应用打包成 umd 库格式 library: `${name}-[name]`, libraryTarget: 'umd', jsonpFunction: `webpackJsonp_${name}` } }, // 设置common要参与编译打包(ES6 -> ES5) transpileDependencies: ['common'] }
- 修改
- 设置唯一端口,在.env里面设置端口号,这里端口号没有说必须要这里设置,你也在其他地方设置,看你项目设计而定,但是端口号必须唯一,不跟已有应用发生冲突
// .env VUE_APP_PORT=7083
- 在
src
下新建一个public-path.js
文件
;(function () { if (window.__POWERED_BY_QIANKUN__) { if (process.env.NODE_ENV === 'development') { // eslint-disable-next-line __webpack_public_path__ = `//localhost:${process.env.VUE_APP_PORT}${process.env.BASE_URL}` return } // eslint-disable-next-line __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ // __webpack_public_path__ = `${process.env.BASE_URL}/` } })()
- 改造
main.js
文件
// main.js import './public-path' import Vue from 'vue' import Router from 'vue-router' import store from './store' import common from 'common' import App from 'common/src/pages/App' // Vue.use(common.plugins.base, isNotQiankun) // 安装common的Plugins插件 // Vue.use(common.globalComponents) // 全局组件 Vue.use(Router) const { name: packName } = require('../package.json') require('@styles/index.scss') const _import = require('@router/_import_' + process.env.NODE_ENV) // true:一起运行,false:独立运行 const isNotQiankun = !window.__POWERED_BY_QIANKUN__ Vue.config.productionTip = false let instance = null /** * 子项目默认初始化 * @param {Object} props - 主应用传递的参数 */ function render(props) { const { container, routerBase, routerList, name } = props || {} // 初始化路由 const router = new Router({ base: isNotQiankun ? process.env.BASE_URL : routerBase, routes: routerList || [], mode: 'history' }) instance = new Vue({ name, router, store, provide: { name: packName, isNotQiankun }, render: (h) => h(App) // 公用APP.vue }).$mount(container ? container.querySelector('#app') : '#app') } // 如果独立运行时,则会执行这里 if (isNotQiankun) { // 独立运行时,应该干点什么事 render() } /** * qiankun 框架子应用的三个生命周期 * bootstrap 初始化 * mount 渲染时 * unmount 卸载 */ export async function bootstrap(props) { // Vue.prototype.$mainBus = props.bus } export async function mount(props) { render(props) } export async function unmount() { instance.$destroy() instance.$el.innerHTML = '' instance = null }
- 设置全局路由守卫
// router/config.js import NProgress from 'common/node_modules/nprogress' // Progress 进度条 import store from '@store' import { utils } from 'common' import Layout from 'common/src/pages/layout' // 引入cmmom的layout const _import = require('@router/_import_' + process.env.NODE_ENV) const { name } = require('../../package.json') const isNotQiankun = !window.__POWERED_BY_QIANKUN__ // 路由白名单 const whitelist = ['/login', '/404', '/401', '/'] export default { install(router) { router.beforeEach(async (to, from, next) => { // 这里采用了主应用props传入子应用的方式 // 一起运行时,路由拦截交给主应去做,子应用不做任何操作,避免冲突 if (!isNotQiankun) return next() // 当独立运行时,执行开启进度条和获取菜单 NProgress.start() // 设置启动应用,也可以在main.js直接设置,感觉这里设置会好一点(神秘加成) store.dispatch('common/setApp', name) // 进入路由的时白名单时,则直接next if (whitelist.includes(to.path)) return next() // 没有权限(token),重定向到登录页 if (!store.getters['common/token']) return next({ path: '/login', replace: true }) // 有菜单时,判断是否启动页(/layout/),是的话,重定向到路由表的第一个 if (store.getters['common/menu']) { const match = utils.findFirstRoute(store.getters['common/menu']) if (!(to.path === '/layout/' && match)) return next() const { base } = router.options return next({ path: match.path.replace(base, '') }) } else { // 没有路由时,则获取 const [err, routes] = await utils.to(store.dispatch('common/getMenu')) if (err) return next('/login') const routerList = utils.filterRouter(routes ? [routes] : [], _import, Layout, 0) const { children } = routerList[0] children.forEach((e) => { router.addRoute({ ...e, path: e.path.startsWith('/') ? e.path : `/${e.path}` }) }) next({ ...to, replace: true }) return next() } }) router.afterEach(() => { isNotQiankun && NProgress.done() // 结束Progress }) } }
主应用配置
- 在src创建
micro
目录,在里面创建三个文件,apps.js
、store.js
和index.js
。
// micro/apps.js import store from './store' import Vue from 'vue' import vuexStore from '@store' import { OPEN_LOADING, CLOSE_LOADING } from '@store/types' import { utils } from 'common' // 全局路由前缀 export const MODULE_NAME = 'module' /** * 根据应用名称获取菜单,比如pms * @param {string} name - 应用名 * @returns {array} 应用路列表 */ function getRoute(name) { const routerList = vuexStore.getters['common/menu'] || [] const childPath = `/${MODULE_NAME}/${name}` const match = routerList.find((e) => e.path === childPath) if (!match) return [] return Array.isArray(match.children) ? match.children : [] } // 是否生产环境 const isProduction = process.env.NODE_ENV === 'production' /** * name: 子应用名称 唯一 * entry: 子应用路径 唯一 * container: 子应用渲染容器 固定 * activeRule: 子应用触发路径 唯一 * props: 传递给子应用的数据 */ const apps = [ { name: 'pms', entry: 'http://localhost:7083/', container: '#subView' }, { name: 'oms', entry: 'http://localhost:8823/', container: '#subView' } ] // { // name: 'childTemplate', // entry: 'http://localhost:8082/module/childTemplate/', // container: '#subView', // activeRule: '/module/childTemplate', // props: { // routerBase: '/module/childTemplate', // getGlobalState: store.getGlobalState, // components: [MainComponent], // utils: { // mainFn // } // } // } export default (routerList) => apps.map((e) => ({ ...e, entry: `${isProduction ? '/' : e.entry}${MODULE_NAME}/${e.name}/?t=${utils.rndNum(6)}`, activeRule: `/${MODULE_NAME}/${e.name}`, // container: `${e.container}-${e.name}`, // KeepAlive loader: (loading) => { if (loading) { vuexStore.commit(`load/${OPEN_LOADING}`) } else { vuexStore.commit(`load/${CLOSE_LOADING}`) } }, props: { routerBase: `/${MODULE_NAME}/${e.name}`, // 子应用路由的base getGlobalState: store.getGlobalState, // 提供子应用获取公共数据 routerList: getRoute(e.name, routerList), // 提供给子应用的路由列表 bus: Vue.prototype.$bus // 主应用Bus通讯 } }))
// micro/store.js import { initGlobalState } from 'qiankun' import Vue from 'vue' // 父应用的初始state // Vue.observable是为了让initialState变成可响应:https://cn.vuejs.org/v2/api/#Vue-observable。 export const initialState = Vue.observable({ menu: null, user: {}, auth: {}, tags: [], app: 'main' }) const actions = initGlobalState(initialState) actions.onGlobalStateChange((newState, prev) => { // console.log('父应用改变数据', newState, prev) for (const key in newState) { initialState[key] = newState[key] } }) // 自定义一个get获取state的方法下发到子应用 actions.getGlobalState = (key) => { // 有key,表示取globalState下的某个子级对象 // 无key,表示取全部 return key ? initialState[key] : initialState } export default actions
// micro/index.js import { registerMicroApps, // setDefaultMountApp, start, addGlobalUncaughtErrorHandler } from 'qiankun' import apps from './apps' import { Message } from 'common/node_modules/element-ui' import NProgress from 'common/node_modules/nprogress' import router from '@router' import { utils } from 'common' export default function (routerList) { registerMicroApps(apps(routerList), { beforeLoad: (app) => { // console.log('--------beforeLoad', app) NProgress.start() }, beforeMount: (app) => { // console.log('--------beforeMount', app) // console.log('[LifeCycle] before beforeMount %c%s', 'color: green;', app.name) }, afterMount: (app) => { NProgress.done() // console.log('-------afterMount', app) // console.log('[LifeCycle] before afterMount %c%s', 'color: green;', app.name) }, beforeUnmount: (app) => { // console.log('-------beforeUnmount', app) // console.log('[LifeCycle] before beforeUnmount %c%s', 'color: green;', app.name) }, afterUnmount: (app) => { // console.log('-------afterUnmount', app) // console.log('[LifeCycle] after afterUnmount %c%s', 'color: green;', app.name) } }) // 监听错误 addGlobalUncaughtErrorHandler( utils.debounce((event) => { const { error } = event if (error && ~error.message?.indexOf('LOADING_SOURCE_CODE')) { Message.error(`${error.appOrParcelName}应用加载失败`) router.push({ name: 'Child404' }) } }, 200) ) // 默认加载应用 // setDefaultMountApp('/module/childTemplate/') start() }
使用的时候,引入micro
即可。
<template> <!-- #subView 就是刚才app里的container --> <div id="subView" v-loading="loading" element-loading-text="正在加载子应用中..." /> </template> <script> import micro from '@/micro' import { GET_LOADING } from '@store/types' export defalt { computed: { loading() { return this.$store.getters[`load/${GET_LOADING}`] } }, mounted() { // 启动加载微应用 micro() } } </script>
FAQ&注意点
-
加载子应用时,必须先主应用写好容器节点,对用的字段是
app
的container
,而且必须等到容器节点加载完成才去运行微运用,也就是放到mounted
生命周期里运行。 -
app
的name
、entry
、activeRule
必须唯一。 -
app
的entry
建议通过环境变量进行判断赋值,因为部署的时候,可以有三个模式:- 多个应用对应多个端口,那就要微应用的请求允许跨域,因为主应用是通过fetch去获取子应用的静态资源的,然后通过正则去解析出来子应用的静态资源信息,然后fetch下来,所以必须要求这些静态资源支持跨域。
- 多个应用一个端口,通过正则表达式动态匹配子应用路径,这个时候就要求应用名跟应用触发的URL前缀的/最后一个字符一样,就是
app
的name
跟activeRule
字段。
const isProduction = process.env.NODE_ENV === 'production' const apps = [ { name: 'pms' entry: isProduction ? '/' : 'http://localhost:7083/', activeRule: '/module/pms' ... }, ... ]
-
全局状态通讯,有几种方法
vue.observable
+initGlobalState(state)
+getGlobalState()
+setGlobalState()
+onGlobalStateChange(handle)
方法结合。通过observable
初始化数据,让数据变为可响应的,再传入initGlobalState
返回一个对象,把这个对象通过app
的props
传递给子应用调用,当state
发生变化时,onGlobalStateChange
就会响应变化,并作出改变,类似watch
。
import { initGlobalState } from 'qiankun' import Vue from 'vue' // 父应用的初始state // Vue.observable是为了让initialState变成可响应:https://cn.vuejs.org/v2/api/#Vue-observable。 export const initialState = Vue.observable({ name: 'xxx' }) const actions = initGlobalState(initialState) actions.onGlobalStateChange((newState, prev) => { // console.log('父应用改变数据', newState, prev) for (const key in newState) { initialState[key] = newState[key] } }) // 定义一个获取state的方法下发到子应用 actions.getGlobalState = (key) => { return key ? initialState[key] : initialState } // 子应用使用时,类似 setData // const state = actions.getGlobalState() // 获取 // state.name = '4' // actions.setGlobalState(state) // 设置 export default actions
- 在1的例子上再升级,加上
vuex
+registerModule
动态模块,可以扩展把用户模块(登录、获取token、获取菜单、获取应用、注销)放到里,让每个应用不用重新写一次用户模块,查看例子commonRegister.js
配置
-
路由拦截设计,当一起运行时,则交给主应用处理;当独立运行时,则由运行的子应用处理,判断是一起运行还是独立运行可以通过
window.__POWERED_BY_QIANKUN__
的值判断。 -
路由表判断归属,提供一种思路,可以通过设置应用名和匹配URL前缀最后一个/后的内容相同,然后判断前缀是否相同。
{ name: 'pms' // pms跟下面的pms一样就好了 activeRule: '/module/pms' }
-
多个应用设置同一个名称的挂载节点(
#app
),导致渲染错误。可以通过父应用传过来的props
中的container
节点,通过这个container
再寻找下面的#app
。// main.js function render(props) { const { container, routerBase, routerList, name } = props || {} new Vue({ ... }).$mount(container ? container.querySelector('#app') : '#app') }
-
commonRegister.js
的initState
初始内容必须跟主应用src/micro/store.js
的initialState
一样,否则会导致一起运行与单独运行的全局状态对不上,无法保持一致。 -
vue-devtools没显示出子应用的节点,无法调试。这里其实是因为子应用没有父节点来继承它导致的,所以手动设置一下即可。
// main.js const isNotQiankun = !window.__POWERED_BY_QIANKUN__ /** * 子项目默认初始化 * @param {Object} props - 主应用传递的参数 */ function render(props) { ...省略 // 解决vue-devtools在qiankun中无法使用的问题 if (!isNotQiankun && process.env.NODE_ENV === 'development') { // vue-devtools 加入此处代码即可 const instanceDiv = document.createElement('div') instanceDiv.__vue__ = instance document.body.appendChild(instanceDiv) } }
- 快速生成子应用,可以预先建好一个模板子应用childTemplate,然后用node.js脚本生成,其中只要修改应用名、端口号,不过剩下一些路由、script脚本要手动加。
KeepAlive
改造
面包削切换,管理页面缓存。
这里提供一种已经实践并部署上线的方案,使用loadMicroApp
手动加载子应用实现,不使用registerMicroApps
,防止成为地中海。
微前端的KeepAlive
跟平时的有点不同,因为是多个微应用结合在一起的项目了,里面有多个Vue
实例,所以各个微应用都要写<KeepAlive></KeepAlive>
标签,然后在commonRegister.js
,添加tags: []
初始数据,在新增/切换/删除面包削的时候要往里面push
和splice
。
由于一起运行后,从pms应用切换到oms应用后,pms应用如果是使用多级路由,并且还是Layout
组件里面包裹<KeepAlive></KeepAlive>
做缓存的话,这个时候只剩下最后最外层的App
组件节点,刚才Layout
组件的缓存也会消失。
因为这个时候路由地址是oms应用的,故pms应用跟当前路由找不到匹配的组件,所以无法匹配二级路由,导致Layout
组件消失,进而导致缓存也消失了。
切换前后的路由变化:
切换前:module/pms/A
切换后:module/oms/B
切换前后的组件变化:
切换前:App - Layout(KeepAlive)
切换后:App
路由变化,导致组件匹配不到就很明显了。
独立运行则使用Layout
组件模式,在这里面使用<KeepAlive></KeepAlive>
先看改造完效果
所以有了如下改造思路:
设计思路
- 所有微应用都引用同一个
App
组件和同一个Layout
组件,故可以把App
和Layout
放到公共包(common)里。 app
的container
设置唯一,并在主应用上循环渲染出来,给到子应用渲染。-
- 一起运行:主应用用
Layout
装载所有子应用,并且把所有子应用路由转为一级路由,然后给到主应用Layout
路由的children
;子应用App
组件启用KeepAlive
,Layout
组件只给主应用使用。
// 主应用路由 const mainRoutes = [ { path: '/module', component: Layout, children: [] } ] const childRoutesFlag = [...] // 已经把所有子应用路由转为一级路由 mainRoutes.[0].children.push(...childRoutesFlag)
- 独立运行:启动应用
App
组件不启用KeepAlive
,采用Layout
组件,当作容器,并在里面启用KeepAlive
。
- 一起运行:主应用用
公共包/src/pages/App组件
<template> <div id="app" class="WH"> <template v-if="!isQiankun"> <RouterView class="WH app__container" /> </template> <template v-else> <Transition name="slide-left" mode="out-in" appear> <KeepAlive :include="tags"> <RouterView class="WH app__container" /> </KeepAlive> </Transition> </template> </div> </template> <script> // App.vue export default { name: 'APP', computed: { isQiankun() { return window.__POWERED_BY_QIANKUN__ }, tags() { if (!this.isQiankun) return [] const tags = this.$store.getters['common/tags'] const { base } = this.$router.options return tags .filter((e) => e.path.startsWith(base) && (e.meta || {}).keepAlive === 1) .map((e) => { const pathSplit = e.path.replace(base, '').split('/').pop() || '' return pathSplit .replace(/-(\w)/g, ($0, $1) => $1.toUpperCase()) .replace(/^([a-z]{1})/, ($0) => $0.toUpperCase()) }) } } } </script>
公共包/src/pages/Layout组件
<template> <div class="layout WH"> <!-- <LayoutSide class="layout__left" :isCollapse="isCollapse" /> --> <div class="layout__right"> <!-- <LayoutHeader v-model="isCollapse" /> --> <template v-if="route.meta.isNotChild || isNotQiankun"> <ElScrollbar :vertical="false" class="scroll-container"> <div class="layout__main__container"> <Transition name="slide-left" mode="out-in" appear> <KeepAlive :include="tags"> <RouterView :key="key" class="WH layout__main__view" /> </KeepAlive> </Transition> </div> </ElScrollbar> </template> <Component :is="container" v-show="container && !isNotQiankun" class="layout__container WH" ></Component> </div> </div> </template> <script> // Layout export default { name: 'Layout', props: { // 渲染子应用的组件,只有在主应用使用时才传入 // main/router/index.js // import ChildContainer from '@components/ChildContainer' // { // path: '/module', // component: Layout, // props: { // container: ChildContainer, // isNotQiankun: false // }, // children: [] // } container: { type: Object, default: null }, isNotQiankun: { type: Boolean, default: true } }, inject: { isNotQiankun: { default: false } }, computed: { route() { return this.$route }, key() { return this.$route.fullPath }, tags() { const tags = this.$store.getters['common/tags'] const { base } = this.$router.options return tags .filter( (e) => (e.path.startsWith(base) || this.isNotQiankun) && (e.meta || {}).keepAlive === 1 ) .map((e) => { const pathSplit = e.path.replace(base, '').split('/').pop() || '' return pathSplit .replace(/-(\w)/g, ($0, $1) => $1.toUpperCase()) .replace(/^([a-z]{1})/, ($0) => $0.toUpperCase()) }) } } } </script>
主应用/src/components/ChildContainer组件,渲染子应用的
<template> <div v-loading="loading" :element-loading-text="`正在加载${childName}子应用中...`" class="childContainer WH" > <ElScrollbar ref="scrollContainer" :vertical="false" class="scroll-container"> <template> <div v-for="(item, index) in childList" v-show="activation.startsWith(item.activeRule)" :id="item.container.replace('#', '')" :key="index" class="sub-content-wrap WH" /> </template> </ElScrollbar> </div> </template> <script> // 子容器 import apps from '@micro/apps' import { GET_LOADING, OPEN_LOADING, CLOSE_LOADING } from '@store/types' import { loadMicroApp } from 'qiankun' export default { name: 'ChildContainer', data() { return { microList: new Map() } }, computed: { loading() { return this.$store.getters[`load/${GET_LOADING}`] }, childList() { return apps() }, activation() { return this.$route.path || '' }, childName({ activation, childList }) { return childList.find((item) => activation.startsWith(item.activeRule))?.name || '' } }, watch: { activation: { immediate: true, handler: 'activationHandleChange' } }, methods: { // 监听路由变化,新增/修改/删除 缓存 async activationHandleChange(path, oldPath) { this.$store.commit(`load/${OPEN_LOADING}`) await this.$nextTick() const { childList, microList } = this const conf = childList.find((item) => path.startsWith(item.activeRule)) if (!conf) return this.$store.commit(`load/${CLOSE_LOADING}`) // 如果已经加载过一次,则无需再次加载 const current = microList.get(conf.activeRule) if (current) return this.$store.commit(`load/${CLOSE_LOADING}`) // 缓存当前子应用 const micro = loadMicroApp({ ...conf, router: this.$router }) microList.set(conf.activeRule, micro) micro.mountPromise.finally(() => { this.$store.commit(`load/${CLOSE_LOADING}`) }) } } } </script>
Nginx部署
Nginx部署方案有三种,如果没有特殊需求,个人推荐第三种
- 多个应用多个端口,主应用配置多个子应用路径转发到相对应的子应用端口上。
- 优点:子应用能单独访问
- 缺点:每有新增子应用时,则每次都要新增一个子应用端口和转发
http{ # main server { listen 80; location / { try_files $uri $uri/ /index.html; root /usr/share/nginx/main; index index.html index.htm; } location /module/pms { try_files $uri $uri/ /index.html; proxy_pass http://127.0.0.1:8081; } location /module/oms { try_files $uri $uri/ /index.html; proxy_pass http://127.0.0.1:8082; } } # pms server { listen 8081; location / { try_files $uri $uri/ /index.html; root /usr/share/nginx/pms; index index.html index.htm; } } # oms server { listen 8082; location / { try_files $uri $uri/ /index.html; root /usr/share/nginx/oms; index index.html index.htm; } } }
- 多个应用一个端口,子应用需要一个二级目录装着,子应用只配置一个
location
即可,但是目录名必须跟主应用的Layout
路由的path
属性一样,并且应用名必须跟是部署的目录一致,比如有主应用(main),子应用有pms、oms,那么该目录结构如下:| -- main | -- index.html | -- module | -- pms | -- index.html | -- oms | -- index.html
- 优点:一个端口即可,
location
只需两个,一个主应用,一个子应用 - 缺点:子应用都得在一个指定的目录下,打包后完需要用sh命令,改变dist目录名和位置,增加复杂度;对于部分运维部署软件,可能无法回滚;无法单独访问子应用
server { listen 80; location / { # 主应用 root /data/web/qiankun/main; index index.html; try_files $uri $uri/ /index.html; } # ^~ 匹配任何以/module/开头的任何查询并且停止搜索。任何正则表达式将不会被测试。 # module 必须与 主应用的Layout路由的path 一直 location ^~ /module/ { # 所有子应用 alias /data/web/qiankun/module; try_files $uri $uri/ /index.html; } }
- 多个应用一个端口,通过正则表达式匹配后缀名,用
alias
或者rewrite
重写请求,要求应用名必须跟是部署的目录一致,可以设置vue.config.js
的outputDir
属性,改变dist目录名。
请求 -> /module/ // 主应用的启动页 请求 -> /module/pms/A // pms应用 A页面 请求 -> /module/pms/B // pms应用 B页面 请求 -> /module/oms/C // oms应用 C页面 请求 -> /module/oms/D // oms应用 D页面
- 优点:一个端口,
location
只需两个,一个主应用,一个子应用;子应用的location
用正则表达式动态匹配,并用rewrite
动态重写url;在服务器打包完后的路径就是最终路径,不用改写目录。 - 缺点:Nginx的
location
正则匹配性能消耗性能大一点?
server { listen 80; location / { # 主应用 root /data/web/qiankun/main; index index.html; try_files $uri $uri/ /index.html; } location ^~ /module/(.*) { # 所有子应用 try_files $uri $uri/ /index.html; if ($1 != "") { # 有值时,则跳到对应的子应用 alias /data/web/qiankun/$1 # rewrite "/module/(.*)" "/data/web/qiankun/$1" last; } else { # 没有值时,则跳到主应用 alias /data/web/qiankun/main #rewrite "/module/(.*)" "/data/web/qiankun/main" last; } } }
原文参考于https://juejin.cn/post/6973156414210441247
- 优点:一个端口即可,