写点什么

微前端在得物客服域的实践 / 那么多微前端框架,为啥我们选 Qiankun + MF

作者:得物技术
  • 2022 年 6 月 06 日
  • 本文字数:6987 字

    阅读完需:约 23 分钟

一、业务背景

当前客服一站式工作台包含在线服务、电话、工单和工具类四大功能,页面的基本结构如下:



每个业务模块相对独立,各有独立的业务体系,单个模块体积较大,项目整体采用 SPA + iframe 的架构模式,其中的工单系统就是通过 iframe 嵌套的。在客服业务不断迭代的过程中,SPA + iframe 的架构模式暴露出了很多问题,主要问题如下:

  • 问题一:SPA 架构模式下,由于各个模块集中于一个架构下,导致首屏加载资源过多,首屏加载速度较慢;SPA 只有入口文件,所以需要对各个模块做业务模式兼容,导致入口文件代码条件语句较多,代码紊乱,出现线上问题的时候,排查较为困难,如果有新的同学参与开发,梳理业务也较为困难,甚至有的时候难以理解。

  • 问题二:项目中嵌套大量的 iframe,iframe 也会拖累页面的加载速度,iframe 使用 postMessage 通讯时也会带来数据延迟,数据丢失等各种问题,客服使用时间较长的时候,当切换 iframe 中的页面时,前一个页面中的无法被完全释放,导致浏览器所占的内存不停的飙升,最终导致浏览器崩溃。

基于上面两个问题,我们用微前端技术对一站式工作台做了业务上的拆分,本文主要阐述在拆分过程中遇到的问题和挑战。


二、技术方案调研

通过对微前端技术方案的调研,可以知道:微前端是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将单页面前端应用由单一的单体应用转变为多个小型前端应用聚合为一的应用,具备以下几个核心价值:

  • 技术栈无关: 主框架不限制接入应用的技术栈,微应用具备完全自主权

  • 独立 开发 、独立部署: 微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新

  • 增量升级: 在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略

  • 独立运行时: 每个微应用之间状态隔离,运行时状态不共享

通过对开源社区相关微前端技术的调研,现今主流的微前端解决方案主要包括以下这些:

  • 技术框架: iframe、single-spa、qiankun、icestark、Garfish、microApp、ESM、EMP

  • 技术亮点: js Entry、html Entry、沙箱隔离、样式隔离、web Component、ESM、ModuleFederation

经过调研以及结合我们的业务现状,采用了 qiankun + Module Federation 作为我们微前端的技术框架,按照功能拆分,将应用拆分为 4 个独立的系统,可以独立 开发 ,独立部署,可根据权限配置接入基座;项目中涉及到依赖其他模块的地方采用远程组件的方式加载依赖组件,例如:IM,电话中会依赖工单中的工单创建,赔付,工单详情,订单详情等组件,工具箱目前会依赖 IM 中的会话记录组件,所以 IM,工单可以作为 remote 端,IM、电话,工具箱可以作为 host 端,提供更友好的组件复用方法,取消了以前的 iframe 加载方式,也不需要利用 qiankun 加载多个微应用的方式去实现,避免大量资源的重复加载,提高页面的响应速度。

1、一站式工作台微前端架构图



2、MF 远程组件规划图



三、方案具体实现

前面我们已经通过调研和结合项目实际,采用 qiankun 作为业务应用拆分的微前端框架,模块联邦作为不同应用之间共享远程组件的框架,形成了初步的框架体系,在此框架体系下,我们面临很多的技术挑战,如下:

  • 微应用需要具备缓存(keep-alive)能力,应用切换状态不能丢失

  • 需要具备同一时刻加载多个微应用

  • 沙箱隔离和引入第三方资源

  • 基座-微应用,微应用-微应用之间如何进行通讯

  • 如何接入远程组件

  • 样式隔离



基座-微应用连接示意图

1、微应用缓存能力的实现

qiankun 为我们提供了两个注册方法:registerMicroAppsloadMicroApp

  • registerMicroApps(apps, lifeCycles?):适用于 route-based 场景,路由改变会帮我们自动注册微应用和销毁上一个微应用,对于不需要做缓存的应用来说,推荐使用这个方法,简单易用,只需要给微应用设置一个独立的路由匹配规则即可。

下面是 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)], },);
复制代码
  • loadMicroApp(app, configuration?):适用于需要手动 加载/卸载 一个微应用的场景。对于我们来说,需要实现缓存和同时加载多个微应用,这个方法更适用。

结论:

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;}
复制代码


// 为每个微应用提供一个挂载的容器节点:<template>  <div class="tabs-view">    <div class="tabs-view-content tabs-view-container">      <template v-if="microApps && microApps.length">        <div          v-for="micro in microApps" :key="micro.name"          :id="micro.id"          v-show="currentPath && currentPath.startsWith(`${micro.key}`)"        ></div>      </template>    </div>  </div></template>
<script lang="ts">import { defineComponent, computed, toRefs, watch, ref } from 'vue'import { useRoute } from 'vue-router'import { useStore } from 'vuex'
export default defineComponent({ name: 'Micro-content', components: { }, props: { currentMenu: { type: String, default: '' } }, setup(props) { const route = useRoute() const store = useStore() // 微应用注册表 const microApps = computed(() => store.getters.microAppsList).value const currentPath = ref(route.path)
watch( () => route.path, (to, _) => { currentPath.value = route.path }, { immediate: true } )
return { route, spin, microApps, currentPath } }})</script>
复制代码


<template>  <div class="app-content">    <a-config-provider :locale="zhCN" prefixCls="basic">      <router-view v-if="isShowViews" v-slot="{ Component }">        <keep-alive v-if="isKeppAlive">          <component :is="Component" />        </keep-alive>        <component :is="Component" v-else />      </router-view>    </a-config-provider>  </div></template>
复制代码

在子应用中的逻辑:需要调用 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 = nullfunction 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}
复制代码


<template>  <a-config-provider :locale="zhCN">    <router-view v-slot="{ Component }">      <keep-alive v-if="isKeepAlive">        <component :is="Component" />      </keep-alive>      <component :is="Component" v-else />    </router-view>  </a-config-provider></template>
复制代码

微应用加载前后 performance 性能对比图:

  • 第一次激活各个微应用性能消耗:



  • 加载成功之后切换微应用性能消耗:



通过微应用激活前后的性能对比可知:

  • 微应用初始化加载的时候,需要经历一次资源请求,页面渲染,会有一次大的性能开销;

  • 微应用加载成功之后,在此切换回来,采用“display: none”+keep-alive 方式处理+路由过滤,虽然需要经历一次重流重绘,但也不会带来太大的性能开销

2、沙箱隔离和引入第三方资源资源

qiankun 内部的沙箱主要是通过是否支持 window.Proxy 分为 LegacySandbox 和 SnapshotSandbox 两种。对于通过 script 标签去加载的第三方资源,需要注意的是:要显示的申明一个全局变量并挂载到 window 上,这样才能在使用的时候获取到。

扩展阅读:多实例还有一种 ProxySandbox 沙箱,这种沙箱模式目前看来是最优方案。由于其表现与旧版本略有不同,所以暂时只用于多实例模式。ProxySandbox 沙箱稳定之后可能会作为单实例沙箱使用。原文链接:https://segmentfault.com/a/1190000022275991

// 例如下面这个例子// global.js中定义一个全局变量var globalMicroApp = 'micro-name'// index.html引入这个global.js<script src="global.js"></script>
// global.js中定义一个全局变量var globalMicroApp = 'micro-name'window. globalMicroApp = globalMicroApp// index.html引入这个global.js<script src="global.js"></script>
复制代码

案例 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;};
复制代码

3、应用之间的通讯

通讯方式可以采用:URL 携参,window,postMessage, qiankun 提供的 props, initGlobalState 等方式;在此只介绍 props, initGlobalState 这两种方式。

  • props 方式传递参数:

基座通过 qiankun loadMicroApp 方法下发一个 state 参数,这个 state 可以为普通类型,也可以为一个 callback,或者 vuex action 方法,微应用激活之后可以通过 qiankun 生命周期函数 mount 拿到 props 传递下来的 state,如果需要微应用更新数据到基座,可以下发一个 action 或者 callback,微应用在接受方法后保存到自己的 vuex store 中,需要更新数据的之后,直接调用缓存的 action 或者 callback。



props 通讯示意图

  • initGlobalState 方式传递参数:



action 订阅-发布模式示意图

基座:

import { initGlobalState, MicroAppStateActions } from 'qiankun';
// 初始化 stateconst 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);}
复制代码

4、如何接入远程组件

远程组件采用 webpack5 模块联邦去实现,在微前端实践中需要注意的事项:



// mian.ts中只能导出qiankun生命周期const { bootstrap, mount, unmount } = await import('./bootstrap')export { bootstrap, mount, unmount }
复制代码

需要将入口文件(mian.ts)转移到新的文件(bootstrap.ts),并在入口文件中导出 qiankun 生命周期,避免打包出两个入口文件,导致 qiankun 加载生命周期函数失败。




详细的接入方法可以参考这篇文章:Module Federation 在得物客服工单业务中的最佳实践

5、样式隔离

qiankun 官方 API 给我们提供了很完善的 API,如下所示:

sandbox - boolean | { strictStyleIsolation?: boolean, experimentalStyleIsolation?: boolean }

  • 默认场景 sandbox: true, 只能保证单实例下的样式隔离,无法保证多个微应用共存,基座-微应用之间的样式隔离;

  • 设置为 strictStyleIsolation: true ;表示开启严格的样式隔离模式。这种模式下 qiankun 会为每个微应用的容器包裹上一个 shadow dom 节点,从而确保微应用的样式不会对全局造成影响;

  • qiankun 还提供了一个实验性的样式隔离特性,当 experimentalStyleIsolation 被设置为 true 时,qiankun 会改写子应用所添加的样式为所有样式规则增加一个特殊的选择器规则来限定其影响范围,因此改写后的代码会表达类似为如下结构:

.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;}
复制代码

四、带来的成效

通过微前端技术对一站式工作台的改造,我们对改造前和改造后做了对比:


五、思考与总结

经历项目立项到完成整个过程,选定 qiankun 作为我们的微前端框架,在整个开发过程中可谓是艰难曲折,第一个难关就是微应用缓存能力的实现,社区中只有简短的 demo,距离真正落地到项目差的还很远;其次我们的项目还需要考虑刷新页面,在当前微应用重载其他微应用的场景;有些微应用需要依赖第三方的插件,这个插件可能会是一个 jQuery 插件,可能还会遇到 jsonp 跨域的场景;还需要考虑微应用之间通用组件的复用问题;原始项目采用 vite 构建,面对 qiankun 对 vite 支持不友好的情况下,最终不得不选择 webpack5。

在遭遇这一系列问题后,然后再到解决这些问题,对我们来说,收益还是很大,也积累了很多社区方案中短板的内容。经过这次项目之后我的思考是:任何技术框架都有其适用场景,对于特定的业务场景,可能原来的技术架构显得臃肿,但他可能是最合适的,微前端不是神话,正确的场景使用正确的技术才是最优选。

六、参考文档


文/CHENLONG

关注得物技术,做最潮技术人!

发布于: 刚刚阅读数: 3
用户头像

得物技术

关注

得物APP技术部 2019.11.13 加入

关注微信公众号「得物技术」

评论

发布
暂无评论
微前端在得物客服域的实践/那么多微前端框架,为啥我们选Qiankun + MF_架构_得物技术_InfoQ写作社区