当前客服一站式工作台包含在线服务、电话、工单和工具类四大功能,页面的基本结构如下:
每个业务模块相对独立,各有独立的业务体系,单个模块体积较大,项目整体采用SPA + iframe的架构模式,其中的工单系统就是通过iframe嵌套的。在客服业务不断迭代的过程中,SPA + iframe的架构模式暴露出了很多问题,主要问题如下:
基于上面两个问题,我们用微前端技术对一站式工作台做了业务上的拆分,本文主要阐述在拆分过程中遇到的问题和挑战。
通过对微前端技术方案的调研,可以知道:微前端是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将单页面前端应用由单一的单体应用转变为多个小型前端应用聚合为一的应用,具备以下几个核心价值:
通过对开源社区相关微前端技术的调研,现今主流的微前端解决方案主要包括以下这些:
解决方案 |
来源 |
特点 |
缺点 |
iframe |
- |
天生隔离样式与脚本、多页 |
窗口大小不好控制,隔离性无法被突破,导致应用间上下文无法被共享,随之带来开发体验、产品体验等问题无法做到单页导致许多功能无法正常在主应用中展示 |
single-spa |
国外 |
Js Entry, 主应用重写 window.addEventListener拦截监听路由的时间,执行内部的reroute逻辑,加载子应用 |
基于reroute,对于需要缓存,加载多应用的场景不适合 |
qiankun |
蚂蚁金服 |
基于 single-spa,增加了 html-entry,sandbox, globalSate, 资源预加载等核心功能 |
需要编译为umd方式,对于AMD,systemJs支持不友好,且官方没有公开支持vite构建 |
icestark |
阿里 |
把大部分配置通过 cache 写进window['icestark'] 全局变量 |
只对React支持,跨框架支持不友好 |
Garfish |
字节 |
对现有 MFE 框架的增强版,VM 沙箱 |
- |
microApp |
京东 |
基于web Component的实现 |
存在兼容性问题,微前端方面的探索不够成熟 |
ESM |
- |
微模块,通过构建工具编译为js,远程加载模块,无技术栈限制,跟页面路由无关,可以随处挂载 |
无法兼容所有浏览器(但可以通过编译工具解决),需手动隔离样式(可通过css module解决),应用通讯不友好 |
EMP |
欢聚时代 |
基于Module Federation、去中心化、跨应用状态共享、跨框架组件调用、远程拉取ts声明文件、动态更新微应用、第三方依赖的共享等能力 |
目前无法涵盖所有框架 |
经过调研以及结合我们的业务现状,采用了 qiankun + Module Federation 作为我们微前端的技术框架,按照功能拆分,将应用拆分为4个独立的系统,可以独立开发,独立部署,可根据权限配置接入基座;项目中涉及到依赖其他模块的地方采用远程组件的方式加载依赖组件,例如:IM,电话中会依赖工单中的工单创建,赔付,工单详情,订单详情等组件,工具箱目前会依赖IM中的会话记录组件,所以IM,工单可以作为remote端,IM、电话,工具箱可以作为host端,提供更友好的组件复用方法,取消了以前的iframe加载方式,也不需要利用qiankun加载多个微应用的方式去实现,避免大量资源的重复加载,提高页面的响应速度。
前面我们已经通过调研和结合项目实际,采用qiankun作为业务应用拆分的微前端框架,模块联邦作为不同应用之间共享远程组件的框架,形成了初步的框架体系,在此框架体系下,我们面临很多的技术挑战,如下:
基座-微应用连接示意图
qiankun为我们提供了两个注册方法:registerMicroApps,loadMicroApp
下面是qiankun官网的一段demo示例:
import { registerMicroApps } from 'qiankun';
registerMicroApps(
[
{
name: 'app1',
entry: '//localhost:8080',
container: '#container',
activeRule: '/react',
props: {
name: 'kuitos',
},
},
{
name: 'app2',
entry: '//localhost:8081',
container: '#container',
activeRule: '/vue',
props: {
name: 'Tom',
},
},
],
{
beforeLoad: (app) => console.log('before load', app.name),
beforeMount: [(app) => console.log('before mount', app.name)],
},
);
结论:
qiankun2.0之后官方为我们提供 loadMicroApp API,给我们带来手动控制应用加载/卸载的能力,且不是基于routeBase加载资源,所以我们不用担心在切换菜单的时候,导致前一个微应用被主动卸载。
基于loadMicroApp手动控制加载微应用的特性,想要实现keep-alive能力,可以在基座和微应用设置合适keep-alive缓存策略,然后通过“display: none”的方式去控制切换的显示和隐藏(DOM重新渲染会导致历史状态丢失),在基座中为每个微应用设置挂载点,应用切换的时候就不会导致前一个微应用DOM被卸载。
在基座中的逻辑:
当我们检测到路由变化的时候,手动的去调用 loadMicroAppFn 去加载对应的微应用,对于需要同时加载多个的场景,可以循环去调用加载(vite构建下加载多个微应用可能会失败,建议采用webpack构建)。
具体原因可参考issue:
// 手动加载微应用方法封装
const loadMicroAppFn = (microApp) => {
const app = loadMicroApp(
{
...microApp,
props: {
...microApp.props,
// 下发给微应用的数据
microFn: (status) => setMicroStatus(status)
},
},
{
sandbox: true,
singular: false
}
);
return app;
}
// 为每个微应用提供一个挂载的容器节点:
在子应用中的逻辑:需要调用qiankun生命周期,入口文件设置合适的keep-alive缓存策略
import './public-path'
import { createApp } from 'vue'
import App from './App.vue'
import router, { setupRouter, destroyRoute } from '@/router'
import { setupStore } from '@/store'
import { isChildApp } from '@/utils/env'
let app: any = null
function render(props) {
app = createApp(App)
// 挂载vuex状态管理
setupStore(app, props)
// 挂载路由
setupRouter(app)
// 路由准备就绪后挂载APP实例
router.isReady().then(() => {
app.mount(document.getElementById('miro-app'))
})
}
// 独立运行时
if (!isChildApp()) {
render({})
}
// 暴露主应用生命周期钩子
export async function mount(props: any) {
render(props)
}
export async function bootstrap() {
console.log('vue app bootstraped')
}
// 销毁生命周期
export async function unmount(props: any) {
app.unmount()
app._container.innerHTML = ''
destroyRoute()
app = null
}
微应用加载前后performance性能对比图:
通过微应用激活前后的性能对比可知:
qiankun 内部的沙箱主要是通过是否支持 window.Proxy 分为 LegacySandbox 和 SnapshotSandbox 两种。对于通过script标签去加载的第三方资源,需要注意的是:要显示的申明一个全局变量并挂载到window上,这样才能在使用的时候获取到。
扩展阅读:多实例还有一种 ProxySandbox 沙箱,这种沙箱模式目前看来是最优方案。由于其表现与旧版本略有不同,所以暂时只用于多实例模式。ProxySandbox 沙箱稳定之后可能会作为单实例沙箱使用。原文链接:万字长文+图文并茂+全面解析微前端框架 qiankun 源码 - qiankun 篇 - SegmentFault 思否
// 例如下面这个例子
// global.js中定义一个全局变量
var globalMicroApp = 'micro-name'
// index.html引入这个global.js
// global.js中定义一个全局变量
var globalMicroApp = 'micro-name'
window. globalMicroApp = globalMicroApp
// index.html引入这个global.js
案例1由于沙箱隔离,在使用的时候无法获取到该全局变量,案例2才是正确的方式,如果有使用jQuery,最好放在基座中加载,例如当使用ajax jsonp去跨域加载资源的时候,放在微应用中沙箱隔离的原因会导致无法获取到callbackName(没有显示的挂载到window上),对于jsonp跨域的请求,也需要特殊处理,否则qiankun会劫持该jsonp请求,将其转为fetch请求导致跨域失败。
const loadMicroAppFn = (microApp) => {
const app = loadMicroApp(
{
...microApp,
props: {
...microApp.props
},
},
{
sandbox: true,
singular: false,
// 指定部分特殊的动态加载的微应用资源(css/js) 不被 qiankun 劫持处理
excludeAssetFilter: (url) => {
return !!(url.indexOf("https://xxx.com/xxx") !== -1);
},
}
);
return app;
};
通讯方式可以采用:URL携参,window,postMessage, qiankun提供的props, initGlobalState等方式;在此只介绍props, initGlobalState这两种方式。
基座通过qiankun loadMicroApp方法下发一个state参数,这个state可以为普通类型,也可以为一个callback,或者vuex action方法,微应用激活之后可以通过 qiankun 生命周期函数 mount 拿到props传递下来的state,如果需要微应用更新数据到基座,可以下发一个action或者callback,微应用在接受方法后保存到自己的vuex store中,需要更新数据的之后,直接调用缓存的action或者callback。
props通讯示意图
action订阅-发布模式示意图
基座:
import { initGlobalState, MicroAppStateActions } from 'qiankun';
// 初始化 state
const actions: MicroAppStateActions = initGlobalState(state);
actions.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev);
});
actions.setGlobalState(state);
actions.offGlobalStateChange();
微应用:
// 从生命周期 mount 中获取通信方法,使用方式和 master 一致
export function mount(props) {
props.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev);
});
props.setGlobalState(state);
}
远程组件采用webpack5模块联邦去实现,在微前端实践中需要注意的事项:
// mian.ts中只能导出qiankun生命周期
const { bootstrap, mount, unmount } = await import('./bootstrap')
export { bootstrap, mount, unmount }
需要将入口文件(mian.ts)转移到新的文件(bootstrap.ts),并在入口文件中导出qiankun生命周期,避免打包出两个入口文件,导致qiankun加载生命周期函数失败。
详细的接入方法可以参考这篇文章:Module Federation 在得物客服工单业务中的最佳实践
qiankun官方API给我们提供了很完善的API,如下所示:
sandbox - boolean | { strictStyleIsolation?: boolean, experimentalStyleIsolation?: boolean }
.app-main {
font-size: 14px;
}
div[data-qiankun-react16] .app-main {
font-size: 14px;
}
这种试验特性(experimentalStyleIsolation)也可以通过postcss插件去实现,社区提供了一个插件postcss-plugin-namespace,使用起来也比较简单,配置如下:
postcss:{
plugins:[require('postcss-plugin-namespace')('.basic-project',{ ignore: [ '*'] })]
}
.app-main {
font-size: 14px;
}
.basic-project .app-main {
font-size: 14px;
}
虽然官方提供了很完善的API,但对于很多场景来说都不能很完美的解决样式冲突的问题,例如基座的全局样式会污染微应用的全局样式,如果你使用的是antd/ant-design-vue,可以采用如下的方式去更改UI库前缀,也是一个很好的解决方案:在入口文件app.vue中:ant-design-vue提供了一个prefixCls可以帮助我们修改class前缀:
在vue.config.js中可以在less/sass loader中覆盖ant-design-vue的类名全局变量:
修改完之后的效果:
// 修改前
.ant-menu-item {
text-align: center;
padding: 10px;
}
// 修改后
.basic-menu-item {
text-align: center;
padding: 10px;
}
通过微前端技术对一站式工作台的改造,我们对改造前和改造后做了对比:
项目名称 |
CR效率 |
开发效率 |
班车发布制度 |
远程组件 |
改造前 |
较慢 |
项目较重,代码耦合性较高,开发难度大 |
应用较重,班车发布需要考虑的问题较多 |
不支持 |
改造后 |
各子应用拆分,完全解耦,可节省1/3时间 |
独立应用开发,业务逻辑解耦,开发效率更高 |
独立开发,独立发布,更轻便,班车发布,需要测试回归的内容较少,能更快的交付业务需求 |
支持 |
经历项目立项到完成整个过程,选定qiankun作为我们的微前端框架,在整个开发过程中可谓是艰难曲折,第一个难关就是微应用缓存能力的实现,社区中只有简短的demo,距离真正落地到项目差的还很远;其次我们的项目还需要考虑刷新页面,在当前微应用重载其他微应用的场景;有些微应用需要依赖第三方的插件,这个插件可能会是一个jQuery插件,可能还会遇到jsonp跨域的场景;还需要考虑微应用之间通用组件的复用问题;原始项目采用vite构建,面对qiankun对vite支持不友好的情况下,最终不得不选择webpack5。
在遭遇这一系列问题后,然后再到解决这些问题,对我们来说,收益还是很大,也积累了很多社区方案中短板的内容。经过这次项目之后我的思考是:任何技术框架都有其适用场景,对于特定的业务场景,可能原来的技术架构显得臃肿,但他可能是最合适的,微前端不是神话,正确的场景使用正确的技术才是最优选。
文/CHENLONG
关注得物技术,做最潮技术人!