业务背景

当前客服一站式工作台包含在线服务、电话、工单和工具类四大功能

基本结构

每个业务模块相对独立,各有独立的业务体系,单个模块体积较大,项目整体采用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
解决方案 来源 特点 缺点
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解决),应用通讯不友好
MP 欢聚时代 基于Module Federation、去中心化、跨应用状态共享、跨框架组件调用、远程拉取ts声明文件、动态更新微应用、第三方依赖的共享等能力 目前无法涵盖所有框架

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

一站式工作台微前端架构图
微前端架构图
MF远程组件规划图
MF远程组件规划图

方案具体实现

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

  • 微应用需要具备缓存(keep-alive)能力,应用切换状态不能丢失
  • 需要具备同一时刻加载多个微应用
  • 沙箱隔离和引入第三方资源
  • 基座-微应用,微应用-微应用之间如何进行通讯
  • 如何接入远程组件
  • 样式隔离

基座-微应用连接示意图
基座-微应用连接示意图

微应用缓存能力的实现

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:

  • 想问一下,未来是否考虑支持 vite · Issue #1257 · umijs/qiankun【1】
  • [求答疑] 主子应用均使用 vite 使用 loadMicroApp 无法同时加载多个子应用 #1861【2】
// 手动加载微应用方法封装
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 = 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
}

<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方式处理+路由过滤,虽然需要经历一次重流重绘,但也不会带来太大的性能开销

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

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

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

// 例如下面这个例子
// 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;
};

应用之间的通讯

通讯方式可以采用: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通讯示意图
    props通讯示意图
    action订阅-发布模式示意图
    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模块联邦去实现,在微前端实践中需要注意的事项:
webpack5模块联邦


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

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

样式隔离

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前缀:
prefixCls

在vue.config.js中可以在less/sass loader中覆盖ant-design-vue的类名全局变量:
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。

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