1、概述
1、iframe嵌入子系统,弊端太多,体验太差;微前端可以很好的解决其弊端
2、但是微前端作为一种非标准技术方案,漏洞比较多
- 样式污染
- 脚本污染
3、目前系统中应用需要区分主、微应用的技术方案差异带来的兼容
- vue2,vue3
- hash、history
- element-ui、element-plus、ant-design
- 原生html微应用
4、组件中渲染容器存在问题,应用中需要避开
5、微应用分为嵌入系统(复数公用)、局部嵌入某一页面,两种应用情况
6、遗留的问题,重写微应用router实例存在问题(router实例重复,路由跳转麻烦)
2、解决方案
2.1、样式污染
qiankun的沙箱隔离,在我实际使用中发现,很多情况下无法避免污染,甚至很多环境无法生效
- 不允许使用全局样式,必须使用scoped
- 脚本挂载的全局样式必须指定dom,不能随意挂载向html、body等根元素
- 切换其他微应用,前一个微应用依然会残留影响无法去除;因此如果必须写全局样式,需存在命名空间
2.2、脚本污染
- vue-router相互影响是最大的问题,解决方案如下
3、技术差异
-
vue2使用官方推荐解决方案
-
vue3使用vite-plugin-qiankun的方案
-
主微同是element-ui时,微应用使用独立entry打包element样式,主应用加载微应用时将其剔除(版本有差异,保证主应用版本高于微应用),主题统一
-
主是element-ui,微是element-plus
- 微应用须自定义element命名空间,避免其对主应用的影响,主题不统一
- 主应用通过改变html标签的css变量实现主题统一
-
主是anti-design,微是element-plus;同上
-
原生html微应用,使用qiankun的fetch自定义,使其注入必要的生命周期,不考虑主题
4、容器
在组件template中渲染容器,存在路由加载速度问题
- 主应用进入路由A,渲染A组件,组件A中包含渲染微应用的容器,加载微应用
- 主应用切换路由至其他路由B
- 主应用再次进入路由A,在该步骤会出错
步骤a完成后,页面中存在qiankun实例,监听location变化;
步骤c中主应用第二次进入路由A时,主应用首先是location变化,然后渲染对应的组件A,此时qiankun程序执行早于组件A的渲染,导致qiankun找不到挂载的容器元素报错
两个处理方法:
4.1、主应用的布局组件中常驻微应用容器
<template>
<section class="main-layout" :class="{ 'is-micro-on': microAppVisible }">
<Breadcrumb />
<transition name="fade-transform" mode="out-in">
<router-view v-if="!$route.meta.keepAlive" :key="key" />
</transition>
<transition name="fade-transform" mode="out-in">
<keep-alive>
<!-- 需要缓存的视图组件 -->
<router-view v-if="$route.meta.keepAlive" />
</keep-alive>
</transition>
<transition name="fade-transform" mode="out-in">
<div v-show="microAppVisible" id="micro-app-container" class="micro-app-container" :class="'micro_' + appRoot" />
</transition>
</section>
</template>
4.2、动态渲染一个微应用容器
在微应用第一挂载时渲染一个div,该div不受vue影响会一直存在;其dom中位置与上面的方法一样
<template>
<div ref="mark"> </div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
const containerId = 'micro-app-container';
let container: HTMLElement;
const isExist = !!document.querySelector('#' + containerId);
if (!isExist) {
container = document.createElement('div');
container.setAttribute('id', containerId);
container.setAttribute('class', 'ele-body');
container.setAttribute('style', 'display:none');
//......
}
const mark = ref();
onMounted(async () => {
if (!isExist) {
mark.value.parentElement.appendChild(container);
//......
}
});
</script>
5、应用情况
5.1、微应用脚手架配置
5.1.1、webpack
publicPath:"/xxx", // base保持与微应用名称一致
configureWebpack: {
output: {
library: `${require('./package.json').name}-[name]`,
libraryTarget: 'umd', // 把微应用打包成 umd 库格式
jsonpFunction: `webpackJsonp_${require('./package.json').name}`
}
},
chainWebpack(config) {
config.entryPoints.clear(); // 清空默认入口
// elementUI主题样式引入
config.entry('element-theme').add(path.resolve('./src/styles/element-variables.scss'));
config.entry('app').add(path.resolve('./src/main.js')); // 重新设置
//......
},
transpileDependencies: ['qiankun', 'import-html-entry']
5.1.2、vite
import qiankun from 'vite-plugin-qiankun';
base:"/xxx", // base保持与微应用名称一致
plugins: [
qiankun('xxx', { // 微应用名字,与主应用注册的微应用名字保持一致
useDevMode: true,
})
]
5.2、嵌入系统(复数公用)
qiankun官方推荐的方案,需要指定加载的微应用;此处处理为根据url自动加载对应的子系统
主应用有hash、history,此处以history为例,hash参考后续的应用案例
主应用
- 主应用创建占位路由
- 布局组件中监听路由变化,识别是否加载微应用
- 识别出加载的微应用
- 如果前一个微应用与当前相同,不重复;如果不同卸载前一个
- 调整微应用base,调整微应用路由,调整微应用的push和replace
- 沙箱加载微应用
hash微应用
- 微应用生命周期导出,支持主应用调整
- base兼容主应用当前路径,或/
- 路由中以/micro-app开头兼容
- router.push/router.replace支持以/micro-app开头(此处与上述冲突要兼容)
history微应用
- 微应用生命周期导出,支持主应用调整
- base兼容主应用当前路径(/micro-app加进去了,所以后续设置不需要了),必须设置
- 支持跳转至hash路由
5.2.1、主应用创建占位路由
该路由没有什么用,只是主应用需要有对应的页面;此处布局容器常驻容器的做法,如果容器选择动态渲染,参考上述案例
{
// 微前端应用-hash
path: 'micro-app',
name: 'micro-app',
component: () => import('@/views/micro-app.vue'),
meta: { title: '', microApp: true }
},
{
// 微前端应用-history
path: 'micro-app/*',
name: 'micro-app',
component: () => import('@/views/micro-app.vue'),
meta: { title: '', microApp: true }
}
<template> <div /> </template>
5.2.2、识别微应用
通过location.href路径来判断是否加载微应用,path中包含“/micro-app”即加载微应用,其后跟随的即对应的系统,例如:“/micro-app/XXX”,XXX就是子系统
layout.vue文件中监听路由变化,判断是否微应用加载;
注意区分hash、history模式,
import routerAdapter from '@/utils/microRouterAdapter'
watch: {
$route: {
immediate: true,
handler(to) {
this.displayMicroApp(to);
}
}
},
methods: {
async displayMicroApp(to) {
const TAG = '/micro-app'
let displayMicro = to.path.includes(TAG);
if (IEVersion() !== -1) displayMicro = false; // 不是ie时启用微前端,ie用iframe
if (!displayMicro) {
return (this.microAppVisible = false); // 不是微前端微应用路径
}
let appRoot;
if (this.$route.params.pathMatch) {
// 微应用history模式
window._isMicroHash = false;
// xxx/../..
appRoot = this.$route.params.pathMatch.split('/')[0];
} else {
// 微应用hash模式
window._isMicroHash = true;
// #/micro-app/xxx/../..
appRoot = location.hash.split('/')[2];
}
if (!appRoot) return (this.microAppVisible = false); // 没有微应用名称
this.microAppVisible = true;
this.appRoot = appRoot
// 同一微应用不重复加载
if (window._microAppRoot && appRoot === window._microAppRoot) {
console.log('the same micro app with last one, no need to load again')
return;
}
window._microAppRoot = appRoot;
// 如果已经存在另一个微应用了, 切换微应用
if (window._microApp) {
console.log('another micro app is running, unmount it first')
await window._microApp?.unmount();
}
window._microApp = null;
const app = {
name: appRoot,
entry: `//${location.host}/${appRoot}/?_=${Math.random()}`,
container: '#micro-app-container',
activeRule: TAG
};
if (!window._isMicroHash) {
// history模式
const baseUrl = location.pathname.split(TAG)[0] + TAG + '/' + appRoot;
const modifyRoutes = routes => routes;
app.props = { baseUrl, modifyRoutes, routerAdapter }
}
const config = { }
config.sendbox = { strictStyleIsolation: true, experimentalStyleIsolation: true }
// const sendbox = true
config.singular = true
config.fetch = (url) => {
if (url.includes('element-theme')) {
// 剔除微应用主题文件
url = `/${appRoot}/favicon.ico`;
}
// if (/element-plus-[0-9a-z]*.css/.test(url)) url = 'favicon.ico';
return window.fetch(url);
};
window._microApp = loadMicroApp(app, config);
}
}
microRouterAdapter.js,调整微应用路由跳转,此处调整history微应用跳至hash页面,使用主应用的router
import router from '../router/index'
export default function(microRouter) {
function adapter(fn) {
return async function(to) {
// 判断当前是否为微前端嵌入c场景
let path;
if (typeof to === 'string') {
console.log(to)
path = to
} else if (!!to && to.path) {
path = to.path || ''
}
if (path?.includes('#/micro-app')) {
router.push(path.replace('/user-management', ''))
return;
}
return await fn(to);
};
}
microRouter.push = adapter(microRouter.push);
microRouter.replace = adapter(microRouter.replace);
return microRouter;
}
5.2.3、hash微应用
1、base处理
微应用不要设置base,或设为‘/’
new Router({ routes: constantRoutes }
2、main.js
如果router中设置了base,则需要将其设置与主应用当前路径一致,即#之前的要一样
/** ***************************微前端改造****************************************/
let instance = null
function render(props = {}) {
if (location.href.indexOf('layout=none') === -1 && isIE()) return;
const { container, modifyRoutes, baseUrl, routerAdapter } = props;
// router = new Router({
// base: baseUrl,
// routes: modifyRoutes(routes)
// })
// routerAdapter?.(router)
instance = new Vue({
router,
store,
render: (h) => h(App)
}).$mount(container ? container.querySelector('#app-wrap') : '#app-wrap')
}
// 独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
render()
}
export async function bootstrap() {
console.log('[vue] vue app bootstraped')
}
export async function mount(props) {
console.log('[vue] props from main framework', props) render(props)
}
export async function unmount() {
instance.$destroy()
instance.$el.innerHTML = ''
instance = null
// router = null
}
3、路由兼容
微应用原本的路径中没有/micro-app的,需要加入,并且在路由跳转时也要加入
export const constantRoutes = [
{
path: '/:micro?/' + preName,
name: preName,
component: () => import('@/layout/white'),
}
]
//......
function judgeRoutePath(fn) {
return function(payload, onComplete, onAbort) {
// 判断当前是否为微前端嵌入
if (location.href.includes('/micro-app')) {
if (typeof payload === 'string') {
// 绝对路径,且不以/micro-app开头
if (payload.startsWith('/') && !payload.startsWith('/micro-app')) {
payload = '/micro-app' + payload
}
} else {
// 绝对路径,且不以/micro-app开头
if (payload && payload.path?.startsWith('/') && !payload.path.startsWith('/micro-app')){
payload.path = '/micro-app' + payload.path
}
}
}
return fn.call(this, payload, onComplete, onAbort)
}
}
router.push = judgeRoutePath(router.push)
router.replace = judgeRoutePath(router.replace)
//......
5.2.4、history微应用
import { renderWithQiankun, qiankunWindow, type QiankunProps } from 'vite-plugin-qiankun/dist/helper';
import { createRouter, createWebHistory } from 'vue-router';
let app: App;
const render = (props: QiankunProps = {}) => {
const { container, modifyRoutes, baseUrl, routerAdapter } = props;
const el: string | Element = container?.querySelector('#app') || '#app';
// 避免 id 重复导致微应用挂载失败
app = createApp(root);
let router;
if (baseUrl && modifyRoutes) {
router = createRouter({
history: createWebHistory(baseUrl),
routes: modifyRoutes(routes),
});
} else {
router = createRouter({
history: createWebHistory(import.meta.env.VITE_BASE_URL),
routes: routes,
});
}
routerAdapter?.(router);
app.use(router);
app.mount(el);
};
const initQianKun = () => {
renderWithQiankun({
bootstrap() {
console.log('微应用:bootstrap');
},
mount(props) {
// 获取主应用传入数据
console.log('微应用:mount', props);
render(props);
},
unmount(props) {
console.log('微应用:unmount', props);
app?.unmount();
},
update(props) {
console.log('微应用:update', props);
},
});
};
qiankunWindow.__POWERED_BY_QIANKUN__ ? initQianKun() : render(); // 判断是否使用 qiankun ,保证项目可以独立运行
5.2.5、服务器代理
前端界面自动识别微应用路径,然后调用。例如:"https://domain/xxx"、"https://domain/yyy"
但是微应用与主应用很可能不在一个域名下,所以需要后台代理支持
5.3、局部嵌入某一页面
局部嵌入微应用某一页面,微应用配置与上述相同
主要问题是,使主应用当前路径,触发微应用对应页面,并且提供对应页面需要的参数
此处案例是主应用hash,微应用history,所以找到目标页面,并把它路径改成当前location的#之前一样就行
<template>
<div :id="id" class="micro-app-once-vue" />
</template>
<script>
import { loadMicroApp } from 'qiankun'
let _microApp
export default {
props: {
entry: {
type: String,
required: true
},
path: {
type: String,
required: true
},
query: {
type: Object
}
},
data: () => ({ id: 'once-app-' + Math.floor(10000 * Math.random()) }),
mounted() {
this.displayMicroApp()
},
beforeDestroy() {
_microApp?.unmount()
_microApp = null
},
methods: {
async displayMicroApp() {
const { entry, path } = this
if (!path) return
// 将微应用页面需要参数放上去
const query = Object.assign({}, this.query, this.$route.query);
this.$router.replace({ path: this.$route.path, query })
await new Promise((r) => setTimeout(r, 500));
const pathname = location.pathname
// 主应用向微应用传递参数
const microAppProps = {
baseUrl: '/',
/**
* 修改子应用routes,修改为加载指定路由文件
* @param routes
*/
modifyRoutes(routes) {
// 只保留指定的路由,并改为根路径
const recursion = (rs, pre) => {
for (const rou of rs || []) {
if (path === (pre + rou.path).replace('//', '/')) {
return Object.assign({}, rou, { path: pathname.replace('/', '') })
} else if (rou.children?.length) {
const re = recursion(rou.children, pre + rou.path)
if (re) {
return Object.assign({}, rou, { children: [re] })
}
}
}
return false
}
const result = recursion(routes, '')
if (result) {
console.log(result)
return [result]
} else {
return []
}
}
}
console.log('display micro app[once]')
const appConfig = {
name: 'flowApp', // 'micro-app-once', 子应用已指定名称,此处需与之保持一致
entry: `${entry}?_=${Math.random()}`,
container: '#' + this.id,
props: microAppProps
}
// let conf = { sandbox: { experimentalStyleIsolation: true }, singular: true };
const conf = { sandbox: false, singular: true }
const lifeCycles = {
// 生命周期钩子函数
// beforeLoad: (app) => {
// console.log('beforeLoad', app)
// },
async beforeMount(app) {
console.log('beforeMount ', app)
},
async afterMount(app) {
console.log('afterMount', app)
},
async beforeUnmount(app, global) {
console.log('beforeUnmount ', app)
}
// afterUnmount: (app) => {
// console.log('afterUnmount', app)
// }
}
_microApp = loadMicroApp(appConfig, conf, lifeCycles)
}
}
}
</script>
主应用history,微应用history
<template>
<div id="micro-app-once"></div>
</template>
<script lang="ts" setup>
import { LoadableApp, loadMicroApp, MicroApp } from 'qiankun'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { MicroAppProps } from '@/typings/ImportTypes'
import { wait, changeElementTheme } from '@/utils/util'
import { useThemeStore } from '@/store/modules/theme'
import { ROUTE_BASE } from '@/config/setting'
const route = useRoute()
const props = defineProps({
entry: { type: String, required: true },
path: { type: String, required: true }
})
const themeStore = useThemeStore()
let _microApp: MicroApp
const displayMicroApp = async () => {
let { entry, path } = props
if (!path) return
await wait(100)
const changeTheme = function () {
// let doc = document.querySelector('#micro-app-once [data-qiankun]')
let doc = document.querySelector('html')
if (!doc) return
let { color, darkMode } = themeStore.$state
changeElementTheme(color, doc, darkMode)
}
watch(() => themeStore.$state.color, changeTheme)
watch(() => themeStore.$state.darkMode, changeTheme)
// 主应用向微应用传递参数
const microAppProps: MicroAppProps = {
baseUrl: ROUTE_BASE,
/**
* 修改子应用routes,修改为加载指定路由文件
* @param routes
*/
modifyRoutes(routes) {
// 只保留指定的路由,并改为根路径
return routes
.filter((r) => r.path.includes(path))
.map((rt) => Object.assign({}, rt, { path: route.path }))
}
}
console.log('display micro app[once]')
let appConfig: LoadableApp<MicroAppProps> = {
name: 'flowApp', //'micro-app-once', 子应用已指定名称,此处需与之保持一致
entry: `${entry}?_=${Math.random()}`,
container: '#micro-app-once',
props: microAppProps
}
// let conf = { sandbox: { experimentalStyleIsolation: true }, singular: true };
let conf = { sandbox: false, singular: true }
let lifeCycles = {
// 生命周期钩子函数
// beforeLoad: (app) => {
// console.log('beforeLoad', app)
// },
async beforeMount(app: any) {
console.log('beforeMount ', app)
},
async afterMount(app: any) {
console.log('afterMount', app)
changeTheme()
},
async beforeUnmount(app: any, global: Window) {
console.log('beforeUnmount ', app)
}
// afterUnmount: (app) => {
// console.log('afterUnmount', app)
// }
}
_microApp = loadMicroApp(appConfig, conf, lifeCycles)
}
onMounted(displayMicroApp)
onBeforeUnmount(() => _microApp?.unmount())
</script>
5.4、原生html微应用
原生html作为微应用,面临的问题是qiankun微应用需要入口文件抛出对应的生命周期函数
如果微应用可以通过源码修改,则没有问题;当微应用不能修改时,可以fetch自定义修改
<template>
<div :id="id" class="micro-app" />
</template>
<script>
import { loadMicroApp } from 'qiankun';
import URL from 'url'
let contextPath = '/workflow-app'
if (location.href.includes('/portal-')) {
contextPath = '/flow-app'
}
let entryJs = `<script>(function (global) { if (!Promise) return;global['purehtml'] = {bootstrap: function () {console.log('purehtml bootstrap'); return Promise.resolve(); }, mount: function (props) { console.log('purehtml mount'); if (layer) { layer.msg = props.message.info }props.window.downWorkFlowAttFile=function(id){downWorkFlowAttFile(id)}; return Promise.resolve(); }, unmount: function () { console.log('purehtml unmount'); return Promise.resolve(); }, }; })(window);</`
entryJs += 'script>'
function modifyUrl(url) {
url = URL.parse(url)
url.host = location.host
url.pathname = contextPath + url.pathname
return URL.format(url)
}
async function fetch(url) {
if (url.includes('element-theme')) url = 'favicon.ico';
if (url.includes('//workflow.')) {
url = modifyUrl(url)
}
// include, same-origin, omit
const options = { credentials: 'same-origin' }
if (url.includes('//member.')) {
// 该域名对静态资源做了跨越限制,去除same-origin改为no-cors
options.mode = 'no-cors'
}
let res = await window.fetch(url, options);
if (url.includes('RenderWorkFlowStepProgressBody')) {
// 入口entry.js特殊处理
let content = await res.text()
// 增加entry
content = content.replace('</html>', entryJs + '</html>')
// 替换其中跨域链接
content = content.replaceAll(/\/\/workflow\.(uk|u|you)zhicai.com/g, contextPath)
// 去掉jsonp
content = content.replaceAll('dataType: "jsonp",', '')
// 去除jsonp之后处理返回数据
content = content.replaceAll('success: function (d) {', 'success: function (d) {d=eval(d);')
// 适配onclick时找不到该方法
content = content.replace('function downWorkFlowAttFile', 'window.downWorkFlowAttFile=function')
content = new Blob([content], { type: 'text/plain' });
res = new Response(content, { headers: res.headers })
}
return res
}
export default {
props: {
workFlowType: {
type: [String, Number],
required: true
},
projectId: {
type: String,
},
businessId: {
type: String,
},
url: {
type: String,
required: true
}
},
data() {
const id = 'workflow-app' + Math.floor(10000 * Math.random());
return {
id,
};
},
mounted() {
this.displayFlowApp();
},
beforeDestroy() {
this._microApp?.unmount();
},
methods: {
async displayFlowApp() {
if (!this.url) return;
const dom = document.querySelector('#' + this.id);
if (!dom) return;
await new Promise((r) => setTimeout(r, 300));
console.log('display flow app');
const appConfig = {
name: this.id,
entry: this.url,
container: '#' + this.id,
props: { message: this.$message, window },
};
const conf = {
// singular: true,
fetch,
sandbox: { experimentalStyleIsolation: true },
excludeAssetFilter(assetUrl) {
return !assetUrl.includes('//workflow.');
},
};
this._microApp = loadMicroApp(appConfig, conf);
setTimeout(() => {
this.$el.querySelectorAll('button').forEach((button) => {
button.addEventListener('click', function(e) {
e.stopPropagation();
});
});
}, 10000);
},
},
};
</script>
5.5、element-plus微应用
如果主微应用是使用新版对应vue3的ui库,其实不需要考虑这个问题;
但是如果是主应用element-ui,微应用element-plus,就会存在样式污染的问题,而element-plus推荐按需加载,一旦使用按需加载样式文件分散,无法使用上述的通过剔除主题文件达到效果。
建议自定义命名空间。
首先删除工程中所有主动引用element-plus组件和样式的地方,全部通过按需加载(message、messagebox、loading等服务方式调用的不算)
参考element-plus官网
// styles/element/index.scss
// we can add this to custom namespace, default is 'el'
@forward 'element-plus/theme-chalk/src/mixins/config.scss' with (
$namespace: 'ewf' // 此处指定命名空间,样式文件中已“ewf”替换“el”
);
// @use "element-plus/theme-chalk/src/index.scss" as *;
// 由于element-plus样式按需引入,js引入组件的样式需要独立加载
@import 'element-plus/theme-chalk/src/loading.scss';
@import 'element-plus/theme-chalk/src/message.scss';
@import 'element-plus/theme-chalk/src/message-box.scss';
<template>
<el-config-provider namespace="ewf"> // 同上
<el-container>
<div id="app">
<router-view v-slot="{ Component }">
<transition>
<component :is="Component" />
</transition>
</router-view>
</div>
</el-container>
</el-config-provider>
</template>
//elementui 按需导入
import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/vite';
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
plugins: [
AutoImport({ resolvers: [ElementPlusResolver()] }),
// Components({ resolvers: [ElementPlusResolver()] }),
// use unplugin-vue-components
Components({
resolvers: [
ElementPlusResolver({
importStyle: "sass",
// directives: true,
// version: "2.1.5",
}),
],
}),
],
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "@/assets/style/element-index.scss" as *;`,
},
},
},
6、存在问题
使用qiankun微前端解决方案中,我发现一个无法调和的问题
1、vue-router,不论在那个版本中都没有提供主动卸载的接口;
2、且vue官方的指导方案中router/index.js导出的是一个Router实例,工程中很多独立的js文件都是通过引用这个实例来router.push
3、但是在上述很多功能,是通过主应用自定义微应用的路由实现,则微应用main中可能重复实例化Router
4、而且在多个微应用切换时,会存在微应用vue-router未销毁问题,影响页面功能
基于上面几点,微应用应
- 尽量不要设置base或设置“/”
- 尽量不要直接引用router/index.js
- 如果可以,router/index.js仅抛出routes配置,实例化在main.js的render方法中做
或者,微应用工程中做是否在微应用情况的判断,特殊处理