May 30, 2024
微前端的核心思想是将前端应用视为由多个微服务组成的整体,每个微前端模块都有自己的业务逻辑、数据和用户界面,且能够独立运行和部署。
背景:新运维接入平台,qiankun和iframe都带来了较大的适配问题,从而引起微前端新框架的尝试想法
微前端方案:
● 基于 NPM 包的微前端:将微应用打包成独立的 NPM 包,然后在主应用中安装和使用;
● 基于代码分割的微前端:在主应用中使用懒加载技术,在运行时动态加载不同的微应用;
● 基于 Web Components 的微前端:将微应用封装成自定义组件,在主应用中注册使用;
● 基于 Module Federation 的微前端:借助 Webpack 5 的 Module Federation 实现微前端;
● 基于动态 Script 的微前端:在主应用中动态切换微应用的 Script 脚本来实现微前端;
● 基于 iframe 的微前端:在主应用中使用 iframe 标签来加载不同的微应用;
● 基于框架(JavaScript SDK)的微前端:使用 single-spa、qiankun、wujie( Web Components) 等通用框架。
微前端技术现状对比:
选型 | 静态资源预加载 | 子应用保活 | iframe | js沙箱 | css沙箱 | 接入成本 | 地址 |
---|---|---|---|---|---|---|---|
EMP | √ | √ | × | × | × | 低 | github.com/efoxTeam/em… |
Qiankun | √ | × | × | √ | √ | 中低 | qiankun.umijs.org/zh/ |
无界 | √ | √ | √ | √ | √ | 中低 | wujie-micro.github.io/doc/ |
micro-app | √ | √ | √ | √ | √ | 中低 | zeroing.jd.com/micro-app/ |
从全域土地和基础信息平台两个系统上感受下qiankun的实际应用,总结以下不足:
Web Components 可以理解为浏览器的原生组件,它通过组件化的方式封装微应用,从而实现应用自治。(类vue组件)
主要包含三部分:
参考:https://juejin.cn/post/7212603829572911159#heading-9
目前市面上两款基于WebComponents的微前端框架:
将子应用的js
注入主应用同域的iframe
中运行,iframe
是一个原生的window
沙箱,内部有完整的history
和location
接口,子应用实例instance
运行在iframe
中,通过代理 iframe
的document
到webcomponent
,实现两者的互联
前置条件:支持跨域
无界提供基于 vue 封装的 wujie-vue 和基于 react 封装的 wujie-react组件,可直接使用
以vite主子应用为例子
主应用:
1⃣️安装
// vue3版本
yarn add wujie-vue3
// vue2版本
yarn add wujie-vue2
2⃣️入口文件全局引用组件
typescriptimport WujieVue from "wujie-vue3" app.use(WujieVue)
3⃣️应用配置、启动、预加载
ts/** * 配置应用,主要是设置默认配置 * preloadApp、startApp的配置会基于这个配置做覆盖 */ wujieList.forEach((app) => { const { name, url, isPreload } = app setupApp({ name, // 子应用名称 url,// 子应用匹配地址 attrs: {}, // 自定义iframe属性,子应用运行在iframe内,attrs可以允许用户自定义iframe的属性 exec: true, // 预执行模式,为false时只会预加载子应用的资源,为true时会预执行子应用代码,极大的加快子应用打开速度 props: {}, // 注入给子应用的数据 // @ts-ignore fetch: credentialsFetch, // 自定义 fetch,添加自定义fetch后,子应用的静态资源请求和采用了 fetch 的接口请求全部会走自定义fetch alive: true, // 保活 plugins: [], // 插件系统 prefix: {},// 当子应用开启路由同步模式后,如果子应用链接过长,可以采用短路径替换的方式缩短同步的链接 degrade: false, // 降级处理 ...lifeCycles // 生命周期改造(单例模式) }) isPreload && preloadApp({ name, url }) }) /** * wujie组件配置 */ <WujieVue class="h-full" :name="props.name" :url="props.url" :sync="true" // 路由同步模式,开启后无界会将子应用的name作为一个url查询参数,实时同步子应用的路径作为这个查询参数的值,这样分享 URL 或者刷新浏览器子应用路由都不会丢失。 :props="propParams" :plugins="plugins" :beforeLoad="beforeLoad" :beforeMount="beforeMount" :afterMount="afterMount" :beforeUnmount="beforeUnmount" :afterUnmount="afterUnmount" ></WujieVue>
子应用:
其中保活模式、重建模式子应用无需做任何改造工作,单例模式需要做生命周期改造
改造入口函数:
window.__WUJIE_MOUNT
函数上window.__WUJIE_UNMOUNT
上window.__WUJIE.mount()
vite例子:
tsdeclare global { interface Window { // 是否存在无界 __POWERED_BY_WUJIE__?: boolean // 子应用mount函数 __WUJIE_MOUNT: () => void // 子应用unmount函数 __WUJIE_UNMOUNT: () => void // 子应用无界实例 __WUJIE: { mount: () => void } } } if (window.__POWERED_BY_WUJIE__) { let instance: any window.__WUJIE_MOUNT = () => { const router = createRouter({ history: createWebHistory(), routes }) instance = createApp(App) instance.use(router) instance.mount("#app") } window.__WUJIE_UNMOUNT = () => { instance.unmount() } /* 由于vite是异步加载,而无界可能采用fiber执行机制 所以mount的调用时机无法确认,框架调用时可能vite 还没有加载回来,这里采用主动调用防止用没有mount 无界mount函数内置标记,不用担心重复mount */ window.__WUJIE.mount() } else { createApp(App) .use(createRouter({ history: createWebHistory(), routes })) .mount("#app") }
主应用使用统一封装好的组件,子应用无需过多的适配,**学习成本**相对较低
预加载+保活的机制实现了**首屏的高效率加载,js在空的iframe内执行,整体运行性能高,速度**快
特色功能点:
三种简易的通信方式
主应用通过data传参给子应用, 子应用通过methods方法传参给主应用
ts// 主应用 <WujieVue name="xxx" url="xxx" :props="{ data: xxx, methods: xxx }"></WujieVue> // 子应用 const props = window.$wujie?.props; // {data: xxx, methods: xxx}
利用子应用运行在主应用的iframe
类似iframe的传参和调用
ts// 主应用获取子应用的全局变量数据 window.document.querySelector("iframe[name=子应用id]").contentWindow.xxx //子应用获取主应用的全局变量数据 window.parent.xxx
去中心化的通信方案,方便。类似于组件间的通信
主应用
ts// 使用 wujie-vue import WujieVue from"wujie-vue"; const{ bus }= WujieVue; // 主应用监听事件 bus.$on("事件名字",function(arg1,arg2, ...){}); // 主应用发送事件 bus.$emit("事件名字", arg1, arg2,...); // 主应用取消事件监听 bus.$off("事件名字",function(arg1,arg2, ...){});
子应用
ts// 子应用监听事件 window.$wujie?.bus.$on("事件名字",function(arg1,arg2, ...){}); // 子应用发送事件 window.$wujie?.bus.$emit("事件名字", arg1, arg2,...); // 子应用取消事件监听 window.$wujie?.bus.$off("事件名字",function(arg1,arg2, ...){});
多应用共存
强大的插件系统,方便用户在运行时去修改子应用代码从而避免去改动仓库代码
tsconst plugins = [ { jsBeforeLoaders: [{ content: "window.lodash = window.parent.lodash" }] }, // 应用共享 { // 在子应用所有的css之前 cssBeforeLoaders: [ // 强制使子应用body定位是relative { content: "body{position: relative !important}" } ] }, { jsLoader: (code: any) => { // 替换popper.js内计算偏左侧偏移量 var codes = code.replace( "left: elementRect.left - parentRect.left", "left: fixed ? elementRect.left : elementRect.left - parentRect.left" ) // 替换popper.js内右侧偏移量 return codes.replace("popper.right > data.boundaries.right", "false") } }, // 子应用样式切换问题 { patchElementHook(element: any, iframeWindow: any) { if (element.nodeName === "STYLE") { element.insertAdjacentElement = function ( _position: any, ele: any ) { iframeWindow.document.head.appendChild(ele) } } } } ]
天然支持vite框架
**Q1:**冒泡组件的处理(poptip)
A1: 官方:body设置relative(iview无效)
民间方法: 设置无界应用的plugins
参考:https://segmentfault.com/a/1190000044158847
jsplugins: [ { // 在子应用所有的css之前 cssBeforeLoaders: [ // 强制使子应用body定位是relative { content: "body{position: relative !important}" }, ], }, { jsLoader: (code) => { // 替换popper.js内计算偏左侧偏移量 var codes = code.replace( "left: elementRect.left - parentRect.left", "left: fixed ? elementRect.left : elementRect.left - parentRect.left" ); // 替换popper.js内右侧偏移量 return codes.replace("popper.right > data.boundaries.right", "false"); }, }, ],
Q2:改变url 子应用的路由切换无效
A2: 保活模式的原因, 需要在子应用监听路由变化进行跳转,单例和重建模式不会出现该情况
tswindow.$wujie?.bus.$on("vue3-router-change", (path: string) => router.push(path) )
Q3: 运维通过改变root下的theme实现换肤,子应用因为shadowdom的原因无法获取子应用自身的root
A3: shadowDom的原因导致子应用无法获取自身的root,需要把root下的全局样式挂到html下,通过获取shadowRoot的html去进行去全局样式的替换
jsconst root = window.__POWERED_BY_WUJIE__ ? window.$wujie.shadowRoot.querySelector("html") : document.querySelector(":root")
PS:MicroApp不开启shadowDom的话这块可忽略
Q4: 子应用与浏览器url隔离,地图基础库适配问题
A4: 地图应用库部分是根据平台qiankun的形式,需要通过浏览器url去拼凑地址,比如dora的cesiumpath,会导致使用异常,后续看看和基础库沟通下
MicroApp借鉴了WebComponent的思想,通过CustomElement结合自定义的ShadowDom,将微前端封装成一个类WebComponent组件,实现微前端的组件化渲染。
两套js沙箱:1、proxy+with(同qiankun) 2、iframe沙箱(支持vite)
样式隔离:每个样式添加前缀,将子应用的样式影响禁锢在当前标签区域
css.test { color: red; } /* 转换为 */ micro-app[name="xxx"] .test { color: red; }
**前提:**子应用支持跨域
以接入新运维为例子
主应用:
1⃣️安装插件
// 安装microApp插件
npm i @micro-zoe/micro-app --save
2⃣️初始化全局启动以及相应配置项全局修改
ts// main.ts import microApp from "@micro-zoe/micro-app" microApp.start({ 对应配置项全局修改 })
3⃣️在micro组件中进行相应配置即可
子应用:
1⃣️设置webpack动态public-path,处理静态资源
jsif (window.__MICRO_APP_ENVIRONMENT__) { __webpack_public_path__ = window.__MICRO_APP_PUBLIC_PATH__ }
2⃣️可选,用于设置渲染和卸载操作
ts// 👇 将渲染操作放入 mount 函数,子应用初始化时会自动执行 // window.mount = () => { // instance = createApp(App).use(store).use(router) // instance.mount("#app") // } // // 👇 将卸载操作放入 unmount 函数,就是上面步骤2中的卸载函数 // window.unmount = () => { // instance.unmount() // }
3⃣️效果
虚拟路由系统(1.0版本新增)
默认开启,通过disable-memory-router
关闭,关闭后通过浏览器url更新页面
开启后子应用需要完全跟浏览器url脱钩,相关功能不生效(例如:运维tab)
虚拟路由系统拥有router的功能包括:push、replace、go、back等,轻松实现跨应用等路由跳转
js/** * @param {string} name 必填,子应用的name * @param {string} path 必填,子应用除域名外的全量地址(也可以带上域名) * @param {boolean} replace 可选,是否使用replace模式,不新增堆栈记录,默认为false */ router.push({ name: "子应用名称", path: "页面地址", replace: 是否使用replace模式 })
拥有导航守卫进行路由拦截等操作
灵活方便的通信系统
1⃣️发送数据
micro-app会遍历新旧值中的每个key判断值是否有变化,如果所有数据都相同则不会发送(注意:只会遍历第一层key),如果数据有变化则将新旧值进行合并后发送。
子=>主:
js// res为监听数据函数的回调 window.microApp.dispatch({city: 'HK'}, (res: any[]) => { console.log(res) // ['返回值1', '返回值2'] })
主=>子:
js// 返回值会放入数组中传递给setData的回调函数 microApp.setData('my-app', {city: 'HK'}, (res: any[]) => { console.log(res) // ['返回值1', '返回值2'] })
子=>子:
js// micro不支持直接子应用数据通信,为了有效的避免数据污染,防止多个子应用之间相互影响,需通过全局通信实现 //主应用全局通信 microApp.setGlobalData({city: 'HK'}, (res: any[]) => { console.log(res) // ['返回值1', '返回值2'] }) //子应用全局通信 window.microApp.setGlobalData({city: 'HK'}, () => { console.log('数据已经发送完成') })
主动清空数据缓存:
jsmicroApp.clearData("my-app")
2⃣️接收数据
子应用:
jswindow.microApp.addDataListener(dataListener)
主应用:
jsmicroApp.addDataListener("my-app", dataListener)
预加载
三个level
js// micro预加载 microApp.preFetch([ { name: "oms", url: "http://localhost:8082/oms/", level: 3 // "default-page": "/role" } ])
保活
设置keep-alive
Q1、开启shadowDom后页面渲染不出来
A1: 暂未发现解决方案
Q2:运维在with沙箱中无法渲染,浏览器url不对
A2:修改为iframe沙箱即可运行
Q3:存在和无界Q3一样的问题
A3:后续沟通
Q4:对于webcompoents不支持的浏览器没做降级处理,兼容性有待考虑
A4:等待后续优化
Q5:Vite无法加载,需要额外配置
A5:更新到1.0版本后讲沙箱切换为iframe沙箱即可
0.x版本按照以下步骤处理:
官方文档参考:[Vite (jd.com)](https://zeroing.jd.com/micro-app/docs.html#/zh-cn/framework/vite)
主应用配置
1⃣️
vue<micro-app name="app1" url="http://localhost:8081/" inline disableSandbox keep-alive ></micro-app>
Tip:这里关闭沙箱机制,所以baseUrl失效,样式隔离也失效了,基座的数据传输需要单独去处理;如果项目中接入了vite子应用需要考虑怎么处理样式隔离;考虑没有了沙箱机制出现的问题该如何去解决(我也不知道会出现什么问题,官网的建议是不接入等,1.0版本发布;)
2⃣️main.js增加plugins
jsmicroApp.start({ plugins: { modules: { // appName即应用的name值 app1: [ { loader(code) { if (process.env.NODE_ENV === "development") { // 这里 basename 需要和子应用vite.config.js中base的配置保持一致也就是micro-vite code = code.replace( /(from|import)(\s*['"])(\/micro-vite\/)/g, (all) => { return all.replace( "/micro-vite/", "http://localhost:8081/micro-vite/" ) } ) } return code } } ] } } })
子应用配置
1⃣️修改容器id,app => vite-app
2⃣️修改route为hash路由(此处用history也可以)
基座是hash路由,子应用也必须是hash路由
基座是history路由,子应用可以是hash或history路由
3⃣️修改vite.config
jsbase: `${process.env.NODE_ENV === 'production' ? 'http://my-site.com' : ''}/micro-vite/`, plugins: [ vue(), vueJsx(), // 自定义插件 (function () { let basePath = '' return { name: 'vite:micro-app', apply: 'build', configResolved(config) { basePath = `${config.base}${config.build.assetsDir}/` }, writeBundle(options, bundle) { for (const chunkName in bundle) { if (Object.prototype.hasOwnProperty.call(bundle, chunkName)) { const chunk = bundle[chunkName] if (chunk.fileName && chunk.fileName.endsWith('.js')) { //@ts-ignore chunk.code = chunk.code.replace( /(from|import\()(\s*['"])(\.\.?\/)/g, //@ts-ignore (all, $1, $2, $3) => { return all.replace($3, new URL($3, basePath)) } ) //@ts-ignore const fullPath = join(options.dir, chunk.fileName) //@ts-ignore writeFileSync(fullPath, chunk.code) } } } } } })() ],
两者共同特点:
对比与总结:
qiankun问题
基于路由匹配,切换时自动卸载,在一张图加载时出现较为明显的白屏,即无法保活
首屏快速预加载和应用保活,有效解决了微前端加载速度的问题
对于qiankun初学者来说,需要掌握webpack以及qinakun的一些基础配置,路由匹配规则容易出现错误导致子应用无法加载,有一定学习成本
无论是无界还是 MicroApp,相较于 qiankun,学习成本和接入成本较低。Web Components 方式更为直观易懂
组件transfer后样式丢失(挂载到了主应用的dom下,但是css隔离了)
弹窗组件的适配也成功解决了 qiankun 中弹窗样式丢失的问题。
社区停摆,不支持vite框架
尽管两者社区活跃度相对 qiankun 较为逊色,但市面上应用较为丰富。官方对于 issue 的回复速度和维护效率较高。
两者对于vite都做了适配工作
综合考虑,整体而言,我认为无界更为优越。其实现原理更直观,便于开发者理解。虽然 MicroApp 1.0 刚推出,解决了之前的一些问题,但社区上仍存在一些尚需沉淀的问题。