微前端(QianKun)落地实施和最后部署上线总结
历时不到两个月,来到新公司后,接到新需求:“要把ERP系统拆分出来,里面有包括PMS、OMS、WNS等等”模块。
当时的第一个想法就是微前端,那么接下来就是方案落地并实施改造。
graph TD
查阅资料 --> 第一次实施方案不通过 --> 第二次实施方案通过 --> 落地改造
背景
改造前的项目技术栈是 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参数说明:
参数 | 说明 | 类型 | 是否唯一 | 默认值 |
---|---|---|---|---|
name | 应用名称 | string | Y | |
entry | 应用访问地址,这里用环境变量区分 | string | Y | |
container | 应用渲染节点 | string | ||
activeRule | 应用触发的URL前缀,最后一个/的后面的内容建议跟name 相同,因为好判断出属于哪个应用的路由 |
string | Y | |
loader | 应用加载loading | (loading) => {} | ||
props | 传递给子应用的参数 | string | number | array | array |
// 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页面
根据如上规则,可以知道第一个/后面的module是固定的,第二个/后面就是应用名,第三个/后面是具体的路由地址。所以根据如上规则可以用正则表达式匹配,并改写请求。
- 优点:一个端口,
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; } } }
- 优点:一个端口即可,
参考链接
- 微前端qiankun从搭建到部署的实践
- qiankun 微前端实践总结(二)
- 微前端qiankun Vue应用间通信的思考
- 微前端qiankun从搭建到部署的实践
- qiankun 微前端方案实践及总结
- 体验微前端(qiankun)
- 基于qiankun落地部署微前端爬”坑“记
看完这篇的人可以看看升级版
Vue+微前端(QianKun)落地实施和最后部署上线总结(二)普通版
最后,招贤纳士
base: 广州-海珠区-磨碟沙地铁
广州爆米科技正在在招人啦~
职位 | 薪资 | 内容 |
---|---|---|
中高级前端工程师 | 15-35k | vue全家桶+nuxt+flutter+element-ui+vant,主要是供应链和商城 |
中高级JAVA工程师 | 15-35k | |
中高级PHP工程师 | 15-35k | |
中高级测试工程师 | 15-30k | |
电商产品经理 | 20-40k | |
供应链产品经理 | 20-40k | |
测试总监 | 30-50 |
简历发到我邮箱,我内推。iikiiklk@qq.com,邮件标题:简历-姓名-前端 附上你的简历
今天的文章Vue+微前端(QianKun)落地实施和最后部署上线总结分享到此就结束了,感谢您的阅读。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
如需转载请保留出处:https://bianchenghao.cn/13453.html