业务背景 大鹏地图可视化大屏项目是一个集地图应用 和视图应用 为一体的大屏应用,通过头部菜单 可切换视图应用,视图应用包括左右两边的内容展示区域,视图应用可以和地图应用通信和交互。项目采用Vue3 搭建 ,核心问题在于,3D 地图如果使用iframe 方式 集成,那么性能和用户体验会大幅降低 ,为了解决这个问题,我们采用微前端服务框架 qiankun
成功将地图 h5 应用和 Vue3 视图应用 以 DOM 的方式嵌入同一个页面 中,这些嵌入的应用就称为微应用 ,下图中的地图应用和视图应用均为微应用。
为什么采用微前端方案
1.技术栈无关 - 支持接入任意技术栈的应用,支持未来任何技术栈
试想一下,5 年前的 Angular.js 在当时也是非常火的技术栈,许多大型项目都在用,然而技术每年都会迭代更新,每年前端都只会学习使用最新的技术栈,如今的 Angular.js 已经几乎无人问津,而当初用这在当时很热门的技术栈搭建的项目,现在却已是没人想去改、去优化、去移植、甚至没人会修改的地步。
2.可独立开发、测试、部署 - 不同团队或人员维护对应应用,职责拆分,从巨石解耦,加快构建和开发
你能想象把百度和谷歌放在一个页面里同时运行吗?甚至是把 qq 音乐、网易云音乐、酷狗音乐放在一个页面里运行?没错,微前端他实现了,你可以随时把一个应用单独拿出来开发、部署,同时也能在一个基座中将这些单独的应用集成进来组合成新的应用。
3.增量升级 - 不用打包全部代码更新升级,快速且更有针对性
抛开以前传统应用的整体打包升级方式,微前端方案是将细粒度更小的应用组合成一个大的应用,因此只需要小应用升级即可,好比你有一套房,只需要对其中一个房间进行升级改造,其余房间丝毫不用动。
4.独立运行时
每个微小应用都拥有自己的独立运行时上下文,也就是说他们的 js、css 环境是互相不受影响的,比如 A
应用修改了 window.a
,B
应用也修改了 window.a
,但他们的 window
不是同一个 window
对象,故不会造成变量污染。
方案实践 基座应用改造 基座采用 vue-cli 搭建,子应用也一样用 vue-cli 搭建,技术栈统一为 Vue3,后续接入子应用可以接入其他技术栈的应用。
安装 qiankun
路由配置 让 /home
作为整个基座和子应用的共同主路由
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 const Login = ( ) => import ("../views/login.vue" );const Home = ( ) => import ("../views/home.vue" );const NotFound = ( ) => import ("@/components/exception/not-found.vue" );const routes : Array <any > = [ { path : "" , redirect : { name : "home" , }, }, { path : "/" , redirect : { name : "home" , }, }, { path : "/home" , name : "home" , component : Home , }, { path : "/:catchAll(.*)" , component : NotFound , }, ]; export default routes;
基座通过 createWebHashHistory
创建 hash
路由,使用 history
和 memory
路由也可以
1 2 3 4 5 6 7 8 9 10 11 import routes from "./router" ;function render ( ) { router = createRouter ({ history : createWebHashHistory (), routes, }); instance.use (router); }
配置导航菜单 点击菜单要切换视图子应用,因此菜单每个选项都应该包含一个应用信息,包括目录 id,名称、入口、挂载容器 id,用于切换视图微应用。
导航目录的数据结构如下,真实场景是通过接口获取的,便于动态修改
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 export default [{ "id" : 1 , "name" : "蓝天" , "app" : { "name" : "vue3-air-app" , "entry" : { "dev" : "//localhost:8081/" , "product" : "http://182.48.115.108:8887/vue3-air-app/" }, "container" : "#microapp" }, "active" : false }, { "id" : 2 , "name" : "碧水" , "app" : { "name" : "vue3-water-app" , "entry" : { "dev" : "//localhost:8082/" , "product" : "http://182.48.115.108:8887/vue3-water-app/" }, "container" : "#microapp" }, "active" : false }, …… ]
当菜单切换时,通过 loadMicroApp
加载菜单中的应用信息即可完成视图应用的切换,例如
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import { loadMicroApp } from "qiankun" ;import MENU from "./menu" ;const isProd = process.env .NODE_ENV === "production" ; function onChangeMenu (id ) { const currentApp = MENU .find ((menu ) => menu.id === id).app ; loadMicroApp ({ name : currentApp.name , entry : currentApp.entry [isProd ? "dev" : "product" ], container : currentApp.container , props : {}, }); }
加载微应用 基座加载微应用有两种方式,一种是通过 registerMicroApps
注册子应用信息包括子应用的名称(name)、入口(entry)、挂载容器 id(container)、路由匹配规则(activeRule),注册后的应用会根据浏览器 url 的变化来匹配 对应的子应用并加载,第二种是通过 loadMicroApp
来手动加载子应用,也是需要传入子应用的名称、入口、挂载容器 id,不过是少了路由匹配规则,他能让你的子应用立即挂载 ,无须匹配任何路由规则,本项目采用的是 loadMicroApp
,因为要同时加载地图应用和视图应用 。
loadMicroApp 使用说明
作用:通过 qiankun
的loadMicroApp
函数实现在基座中挂载/卸载子应用。
优点:在一个页面中可以同时挂载多个微应用,比如可以同时挂载地图应用和视图应用。
缺点:无法根据路由匹配规则来挂载应用,因为一个路由只能匹配一个应用。
适用场景:当需要在一个页面中同时挂载 2 个以上子应用,并且子应用的挂载不需要通过路由匹配来实现。
demo 演示
1 2 3 4 5 6 7 <template > <div class ="container" > <div id ="micro-app1" > </div > <div id ="micro-app2" > </div > </div > </template >
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import { loadMicroApp } from "qiankun" ;loadMicroApp ({ name : "app1" , entry : "//localhost:8088/" , container : "#micro-app1" , props : {}, }); loadMicroApp ({ name : "app2" , entry : "//localhost:8089/" , container : "#micro-app2" , props : {}, });
注意:loadMicroApp 重复挂载 name 和 container 一样的应用是会出错的,比如下面操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import { loadMicroApp } from "qiankun" ;loadMicroApp ({ name : "app1" , entry : "//localhost:8088/" , container : "#micro-app1" , props : {}, }); loadMicroApp ({ name : "app2" , entry : "//localhost:8088/" , container : "#micro-app2" , props : {}, }); loadMicroApp ({ name : "app2" , entry : "//localhost:8089/" , container : "#micro-app2" , props : {}, });
解决方案:通过 loadMicroApp
进一步封装了 switchMicroApp
函数,实现根据应用的挂载情况来决定如何切换应用,首次挂载应用时 直接调用 loadMicroApp
加载应用,非首次挂载应用时 ,则需要先卸载之前挂载的应用后才挂载新的应用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 import { LoadableApp , loadMicroApp } from "qiankun" ;const contentApp : any = ref (null );function switchMicroApp (runningMicroApp: LoadableApp<any > ) { const microApp = runningMicroApp; if (microApp && contentApp?.value ?.getStatus () === "MOUNTED" ) { contentApp.value .unmount (); contentApp.value = loadMicroApp (microApp); return ; } contentApp.value = loadMicroApp (microApp); } switchMicroApp ({ name : "app2" , entry : "//localhost:8088/" , container : "#micro-app2" , props : {}, }); switchMicroApp ({ name : "app2" , entry : "//localhost:8089/" , container : "#micro-app2" , props : {}, });
Layout 组件 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 <template> <div class ="ths-main" > <t-header :data ="headerNavs" @click ="onClickHeader" > </t-header > <div class ="content" id ="microapp" > </div > <div class ="map" > <div class ="map-mask" > </div > <div id ="map-app" > </div > </div > </div > </template> <script lang="ts"> export default defineComponent({ name: 'Home', setup() { // 当前头部菜单配置 const headerNavs = ref<HeaderNavItems>([]); // 当前选中的应用id const curAppId = ref<number>(0); // 默认要加载的子应用,地图+首页 const BASE_MICRO_APPS: BaseMicroApps = { map: { id: 99, name: 'h5-map-app', entry: !isProd ? '//localhost:8088/' : `${webUrl}h5-map-app/`, container: '#map-app', props: { baseUrl, experimentalStyleIsolation: true, }, }, home: { id: 0, name: 'vue3-home-app', entry: !isProd ? '//localhost:8090/' : `${webUrl}vue3-home-app/`, container: '#microapp', props: { baseUrl, experimentalStyleIsolation: false, }, }, }; /** * 点击菜单切换子应用 * @param id 应用id */ onClickHeader(id: number) { // 点击的是当前选中的则返回 if (curAppId.value === id) { return; } // 当前选中的应用id curAppId.value = id; // 根据点击的应用id,选中加载哪个应用 headerNavs.value.forEach((item) => { if (item.id === id) { switchMicroApp(item.id, { ...item.app, entry: item.app.entry[isProd ? 'product' : 'dev'], }); } }); } onBeforeMount(() => { // 默认加载地图应用 loadMicroApp(BASE_MICRO_APPS.map); // 默认加载首页应用 loadMicroApp(BASE_MICRO_APPS.home); }); onMounted(() => { // 请求导航菜单数据,返回上面‘配置导航菜单’中的MENU对象 getHeaderMenu('dapeng-header-menu').then((data: HeaderNavItems) => { if (isEmpty(data)) { headerNavs.value = []; return false; } headerNavs.value = data; return true; }); }); return { onClickHeader, }; }, }); </script>
刷新路由保存应用状态 由于通过 loadMicroApp
方式加载的应用无法匹配路由 ,所以当路由变化时就无法刷新或保持状态 ,一旦刷新路由,那么基座中加载的应用都会重置成首次加载的应用 ,例如大气、水环境的应用刷新后都会重新渲染成首页应用。
解决方案
每个应用对应菜单的一个 id,所以通过 localStorage
的方式缓存切换的菜单 id,路由刷新后再根据 localStorage
中的 id 切换对应应用即可。
预加载微应用 在获取到包含所有子应用信息的菜单数据后,可以预先请求 其余子应用的html、js、css 等静态资源,等切换子应用时,可以直接从缓存 中读取这些静态资源,从而加快渲染 子应用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import { prefetchApps } from "qiankun" ;getHeaderMenu ("dapeng-header-menu" ).then ((data: HeaderNavItems ) => { if (isEmpty (data)) { headerNavs.value = []; return false ; } headerNavs.value = data; prefetchApps ( headerNavs.value .map ((nav ) => ({ name : nav.app .name , entry : nav.app .entry [isProd ? "product" : "dev" ], })) ); return true ; });
打造 qiankun 子应用 我们基于公司 fe-cli 创建一个 Vue3 项目应用,由上述的流程描述,我们知道子应用得向外暴露一系列生命周期函数 供 qiankun 调用,在 index.js 文件中进行改造:
增加 public-path.ts 文件 目录外层添加 public-path.ts
文件,当子应用挂载在主应用下时,如果我们的一些静态资源沿用了 publicPath=/ 的配置,我们拿到的域名将会是主应用域名,这个时候就会造成资源加载出错,好在 Webpack 提供了 __webpack_public_path__
动态更改 publicPath
的修改方式,window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
等同于 location.host + location.pathname
如 http://localhost:8081/ 或 http://182.48.115.108:8887/vue3-air-home/ ,如下:
1 2 3 4 if ((window as any ).__POWERED_BY_QIANKUN__ ) { __webpack_public_path__ = (window as any ).__INJECTED_PUBLIC_PATH_BY_QIANKUN__ ; }
路由 base 设置 试想一下,当基座的路由 base 不再是本地的 '/'
,而是线上的 '/microFE-dapeng-base/'
,而子应用的路由 base 设置还是’/‘,会发生什么,没错,答案是无法匹配到 '/microFE-dapeng-base/'
路由,导致本地子应用路由无法匹配,资源无法加载。注意 createWebHistory
的第一个参数就是设置的路由 base,那么通过如下配置即可解决子应用 base
设置问题:
1 2 3 4 5 6 7 8 9 const isProd = process.env .NODE_ENV === "production" ; const BASE_PREFIX = isProd ? "/microFE-dapeng-base/" : "/" ; router = createRouter ({ history : createWebHistory ( (window as any ).__POWERED_BY_QIANKUN__ ? BASE_PREFIX : "/" ), routes, });
可见,只要是开发环境,不管子应用是 qiankun
环境下或者独立运行,base
始终都是 '/'
,因为本地开发基座应用都不会设置域名二级目录;而线上环境的话,如果子应用是独立运行,那么 base
就是 '/'
,相对于当前根路径;如果是 qiankun
环境下,那么 base
就是 '/microFE-dapeng-base/'
相对于基座路由。
增加生命周期函数 子应用的入口文件加入生命周期函数初始化,方便主应用调用资源完成后按应用名称调用子应用的生命周期
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 export async function bootstrap ( ) { console .log ("bootstraped" ); } export async function mount (props ) { console .log ("mount" , props); render (props); } export async function unmount ( ) { console .log ("unmount" ); instance.unmount (); instance._container .innerHTML = "" ; instance = null ; router = null ; }
注意:所有的生明周期函数都必须是 Promise
子应用独立运行配置 在上述的生命周期 mount 钩子中挂载了子应用的实例的 DOM,那么当子应用要单独运行,是不是也要挂载一次实例的 DOM 呢?通过 !window.__POWERED_BY_QIANKUN__
判断如果不是 qiankun 环境的话,就立即挂载实例的 DOM。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 function render (props ) { const container = props.container || null ; const isProd = process.env .NODE_ENV === 'production' ; const BASE_PREFIX = isProd ? '/microFE-dapeng-base/' : '/' ; router = createRouter ({ history : createWebHistory (window .__POWERED_BY_QIANKUN__ ? BASE_PREFIX : '/' ), routes, }); instance = createApp (App ); instance.use (router); instance.mount (container ? container.querySelector ('#app' ) : '#app' ); } if (!window .__POWERED_BY_QIANKUN__ ) { render (); } export async function bootstrap ( ) {...}export async function mount (props ) { render (props) }export async function unmount ( ) {...}
修改打包配置 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 function isProd ( ) { return process.env .NODE_ENV === "production" ; } const publicPath = isProd () ? `http://182.48.115.108:8887/${name} /` : `http://localhost:${port} ` ; module .exports = { devServer : { host : "localhost" , hot : true , disableHostCheck : true , port, overlay : { warnings : false , errors : true , }, headers : { "Access-Control-Allow-Origin" : "*" , }, }, publicPath, configureWebpack : (config ) => { return { output : { library : `${name} -[name]` , libraryTarget : "umd" , jsonpFunction : "webpackJsonp_VueMicroApp" , }, }; }, chainWebpack : (config ) => { config.module .rule ("fonts" ) .use ("url-loader" ) .loader ("url-loader" ) .options ({ limit : 4096 , fallback : { loader : "file-loader" , options : { name : "fonts/[name].[hash:8].[ext]" , publicPath, }, }, }) .end (); config.module .rule ("images" ) .use ("url-loader" ) .loader ("url-loader" ) .options ({ limit : 4096 , fallback : { loader : "file-loader" , options : { name : "img/[name].[hash:8].[ext]" , publicPath, }, }, }); }, };
注意:配置的修改为了达到三个目的,一个是暴露生命周期函数给主应用调用,第二点是允许跨域访问,第三点是将图片等静态资源的相对路径地址修改为绝对路径从而解决资源相对于基座路径的问题,修改的注意点可以参考代码的注释。
暴露生命周期 : UMD 可以让 qiankun 按应用名称匹配到生命周期函数
跨域配置 : 主应用是通过 Fetch 获取资源,所以为了解决跨域问题,必须设置允许跨域访问
项目中遇到的问题 1、子应用未成功加载 如果项目启动完成后,发现子应用系统没有加载,我们应该打开控制台分析原因:
控制台无报错 :子应用未加载,检查子应用导出的生命周期 mount 中是否调用了 render 挂载 DOM
**挂载容器未找到 **:检查容器 DIV 是否在 loadMicroApp
时一定存在,如不能保证需设法在 DOM 挂载后执行。
2、基座应用路由模式 基座路由配置
1 2 3 4 5 exports routes = [{ path : '/home' , name : 'home' , component : Home , }];
基座应用项目是 hash 模式路由,子路由是 history 模式
子应用配置路由
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 export default [ { path : "/" , name : "Home" , component : () => import ("@/views/home.vue" ), }, ]; router = createRouter ({ history : createWebHistory (window .__POWERED_BY_QIANKUN__ ? BASE_PREFIX : "/" ), routes, });
基座应用项目是 hash 模式路由,子路由是 hash 模式
子应用配置路由
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 export default [ { path : "/home" , name : "Home" , component : () => import ("@/views/home.vue" ), }, ]; router = createRouter ({ history : createWebHashHistory ( window .__POWERED_BY_QIANKUN__ ? BASE_PREFIX : "/" ), routes, });
3、CSS 样式错乱 由于默认情况下 qiankun
并不会开启 CSS
沙箱进行样式隔离,当主应用和子应用产生样式错乱时,有两种样式隔离配置:
4、H5 微应用静态资源 404 h5 子应用如果没有通过 webpack 等工具打包,没有在打包的时候将静态资源相对地址替换成 publicPath ,那么还是那个问题,应用被转换成 DOM 后 append 到基座 html 中,相对路径其实已经从原来应用的 url 变为了当前页面也就是基座的 url,通过设置 head
中的 base
标签 href
属性解决相对路径问题:
1 2 3 4 <base href ="http://localhost:8088/" /> <base href ="http://182.48.115.108:8887/h5-map-app/" />
5、异步加载的 js 中再异步加载了其他 js,loadMicroApp 加载的应用全局作用域会错乱 qiankun
在加载 js
时,会根据加载的 js
来匹配对应所属的微应用,并开启对应沙箱隔离 js
,如果异步加载的 js
中再次异步加载了 js
,那么最后异步加载的 js
对应的应用就无法正确匹配到属于哪个微应用 ,就会造成无法开启正确的沙箱进行隔离 ,导致 js
全局作用域污染。
解决方案
先只加载有多重异步引入 js
的应用,让所有异步的 js
只能匹配该应用的沙箱,加载完后再通知基座开始加载其余正常应用。
6、配置线上和本地环境的 publicPath 设置资源加载的默认路径 微应用不能使用相对路径的资源,因此需设置资源加载路径为绝对路径,并且区分线上和本地环境。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 const isProd = process.env .NODE_ENV === "production" ;const publicPath = isProd ? `http://182.48.115.108:8887/${name} /` : `http://localhost:${port} ` ; module .exports = { publicPath, }; module .exports = { assetsPublicPath : publicPath, };
如果需要本地打包后能正常访问应用,需将 isProd
手动改为 false
7.线上子应用单独运行需修改路由 path 子应用打包部署后,为了能让子应用独立运行,则需要根据子应用部署地址的 path
来设置路由 path
,比如子应用部署地址是 http://182.48.115.108:8887/vue3-air-app/
,那么子应用路由的 path
就应该变成 '/vue3-air-app' + '/'
而不是 '/'
,为了统一,访问地址的 path
就是应用的名称 packageName
1 2 3 4 5 6 7 8 9 10 11 const packageName = require ('../../package.json' ).name ;const basePath = '/' ;export default [ { path : !(window as any ).__POWERED_BY_QIANKUN__ && process.env .NODE_ENV === 'production' ? `/${packageName} ${basePath} ` : basePath;, name : 'Home' , component : () => import ('@/views/home.vue' ), }, ];
8、 另外,在接入过程中,总结了几个需要注意的点
虽然 qiankun
支持 jQuery
,但对多页应用的老项目接入不是很友好,需要每个页面都修改,成本也很高,这类老项目接入还是比较推荐 iframe
;
因为 qiankun
的方式,是通过 HTML-Entry
抽取 JS
文件和 DOM
结构的,实际上和主应用共用的是同一个 Document
,如果子应用和主应用同时定义了相同事件,会互相影响,如,用 onClick
或 addEventListener
给 <body>
添加了一个点击事件,JS
沙箱并不能消除它的影响,还得靠平时的代码规范
部署上有点繁琐,需要手动解决跨域问题
在 vue
中使用图片得用 require(相对/绝对路径).default 获取图片路径
在子应用 js
中通过 function
或者 var
声明在 window
上的全局变量无法识别,原因在于 Proxy
沙箱将 window
替换成 Proxy
实例了,所以声明的变量无法保存在 proxy
对象上,如果要使用全局变量,可以用 (function(global){global.obj = {}, global.fn = function() {}}(window))
像地图依赖的的三方 js 有很多不确定性,比如引入了 CesiumJs
和其他 qiankun
无法完美支持的 js 库等,以及其中引入了很多静态资源相对地址都无法在打包时替换为绝对地址,所以为了让这些三方 js 库能顺利集成,最好的方式是将他们在基座的 index.html
中加载,这样 qiankun
就不会劫持三方引入的 js 从而发生错误了。
切换子应用前需要先卸载前一个子应用,否则会报错
1 2 3 app1 = loadMicroApp (); app1.unmount ; app2 = loadMicroApp ();
无法兼容 IE,在基座的 main.ts 中引入如下依赖解决
1 2 3 4 5 6 import "whatwg-fetch" ;import "custom-event-polyfill" ;import "core-js/stable/promise" ;import "core-js/stable/symbol" ;import "core-js/stable/string/starts-with" ;import "core-js/web/url" ;
8、未来可能需要考虑一些问题
自动化注入:每一个子应用改造的过程其实也是挺麻烦的事情,但是其实大多的工作都是标准化流程,在考虑通过脚本自动注册子应用,实现自动化
总结 其实写下来整个项目,最大的感受 qiankun 的开箱可用性非常强,需要更改的项目配置基本很少,当然遇到的一些坑点也肯定是踩过才能更清晰。
如果文章有什么问题或者错误,欢迎指正交流,谢谢!
完整的项目在线演示地址 点我查看