深圳幻海软件技术有限公司 欢迎您!

我们是怎么在项目中落地 Qiankun

2023-02-28

背景由于业务增长,团队拆分,我们需要将原有系统的一部分模块(Vue实现)迁移到另外一个系统(React)中。但两个系统技术栈不同,导致重构成本变大,但业务又希望在短期内看到效果,后面可以增量的重构。要求是对用户无感知的,真正将两个系统融合到一起。经过技术调研,我们决定用微前端的方式实现。微前端是一种

背景

由于业务增长,团队拆分,我们需要将原有系统的一部分模块(Vue实现)迁移到另外一个系统(React)中。但两个系统技术栈不同,导致重构成本变大,但业务又希望在短期内看到效果,后面可以增量的重构。

要求是对用户无感知的,真正将两个系统融合到一起。

经过技术调研,我们决定用微前端的方式实现。

微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。

微前端架构旨在解决单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用(Frontend Monolith)后,随之而来的应用不可维护的问题。这跟我们现在的情况是相符的。它具有如下的特点:

  • 技术栈无关。主框架不限制接入应用的技术栈,微应用具备完全自主权
  • 独立开发、独立部署。微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新
  • 增量升级。在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略
  • 独立运行时。每个微应用之间状态隔离,运行时状态不共享

技术选型

微前端是一种类似微服务的架构,目标是将单一的单体应用变成由多个小型应用聚合为一的应用。

经过调研,我们有以下的实现方案。

iframe

优点:

  • 提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决

缺点:

  • url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用
  • UI 不同步,DOM 结构不共享
  • 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果
  • 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程

缺点层面,暂时是无法满足业务的要求的,所以我们没有采取这种方案。

qiankun

qiankun 是一个基于 single-spa 的微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统。

它有以下的特性:

  • 📦 基于 single-spa 封装,提供了更加开箱即用的 API。
  • 📱 技术栈无关,任意技术栈的应用均可 使用/接入,不论是 React/Vue/Angular/JQuery 还是其他等框架。
  • 💪 HTML Entry 接入方式,让你接入微应用像使用 iframe 一样简单。
  • 🛡 样式隔离,确保微应用之间样式互相不干扰。
  • 🧳 JS 沙箱,确保微应用之间 全局变量/事件 不冲突。
  • ⚡️ 资源预加载,在浏览器空闲时间预加载未打开的微应用资源,加速微应用打开速度。
  • 🔌 umi 插件,提供了 @umijs/plugin-qiankun 供 umi 应用一键切换成微前端架构系统。

以上基本能满足我们的要求。

webpack Module Federation

webpack 5 的支持的特性。

单页应用的每个页面都是在单独的构建中从容器暴露出来的。主体应用程序(application shell)也是独立构建,会将所有页面作为远程模块来引用。通过这种方式,可以单独部署每个页面。在更新路由或添加新路由时部署主体应用程序。主体应用程序将常用库定义为共享模块,以避免在页面构建中出现重复。

优点:

  • 能够共享常用库(我们的项目比较特殊,主框架分别为 Vue 和 React,所以能共享的更多的是一些 moment.js / lodash / axios 这类工具库)

缺点:

  • 需要使用 webpack
  • 需要升级 webpack 5

qiankun 有一个缺点就是模块共享,如果能够和 webpack module federation 一起解决这个问题是一个不错的实践。但旧项目是基于 webpack4 构建,升级存在一定的风险,固没有采用这个方案。

其他框架

  • micro-app[1]。京东零售。micro-app是京东零售推出的一款微前端框架,它基于类WebComponent进行渲染,从组件化的思维实现微前端,旨在降低上手难度、提升工作效率。GitHub Star 数[2]- 2.5k
  • emp[3]。欢聚时代。基于下一代构建实现微前端解决方案,结合了 webpack5 和 Module Federation。GitHub Star 数[4]- 2.7k
  • single-spa。qiankun 就是基于这个进行开发,做了一些优化,比如 开箱即用、HTML Entry。GitHub star 数[5]-11k

qiankun GitHub star 数[6]-12.4k。可以看到 qiankun 的社区也是非常活跃的,综上,我们最终选择拥抱 qiankun。

qiankun 主应用改造

我们的主应用主技术栈是 React, 第一步是安装:

yarn add qiankun
  • 1.

第二步是设置路由,这里的 path 需要有一个特殊的前缀,用于激活子应用,这里我们统一称为 ``/vueApp`,这个后面还会用到,大家请记住。

第三步添加渲染入口:

const ChargingContainer = () => <section id="micro-app-container" />;
  • 1.

第四步注册微应用,通过 qiankun 的 registerMicroApps 注册,name 微应用名称(这个后面也会用到,这里我就叫 vueAppName),entry 代表的微应用入口。container ,微应用的容器节点的选择器或者 Element 实例,就是第三步中的渲染入口中声明的。activeRule 是微应用的激活规则,支持数组,这里设置的就是我们第二步上面提到的 /vueApp。

import {
  registerMicroApps,
  addGlobalUncaughtErrorHandler,
  start,
} from 'qiankun';
import { initAppGlobalState } from './action';
import { CHARGING_ACTIVE_RULES } from './constant';

const apps = [
  {
    name: 'vueAppName', // app name registered
    entry: `//localhost:9528/appVue`,
    container: '#micro-app-container',
    activeRule: `/${CHARGING_ACTIVE_RULES}`,
  },
];

/**
 * 注册微应用
 * 第一个参数 - 微应用的注册信息
 * 第二个参数 - 全局生命周期钩子
 */
registerMicroApps(apps, {
  // qiankun 生命周期钩子 - 微应用加载前
  beforeLoad: (app: any) => {
    // eslint-disable-next-line
    console.log('before load', app.name);
    return Promise.resolve();
  },
  // qiankun 生命周期钩子 - 微应用挂载后
  afterMount: async(app: any) => {
    // eslint-disable-next-line
    console.log('after mount', app.name);
    await initAppGlobalState();
    return Promise.resolve();
  },
});

// 导出 qiankun 的启动函数
export default start;
  • 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.

第五步 qiankun 中的 start 函数,用来启动 qiankun。它可以通过 Options 传参开启一些有用的功能,比如 prefetch 预加载,sandbox 开启沙箱等。导出 start 在 App.ts 中启动即可。这里需要注意的 start 启动函数的时机,需要在微应用入口渲染完成之后才调用。

registerMicroApps 和 start 的图示(来自网络)。

qiankun 注册微应用

我们微应用的主技术栈是 Vue。

在主应用注册好了微应用后,我们还需要对微应用进行一系列的配置。

第一步,我们在 Vue 的入口文件 main.js 中,导出 qiankun 主应用所需要的三个生命周期钩子函数(相关功能在代码注释中说明),代码实现如下:

let instance = null;
let ownRouter = router;

/**
 * 渲染函数
 * 两种情况:主应用生命周期钩子中运行 / 微应用单独启动时运行
 */
function render(props) {
  // eslint-disable-next-line
  console.log('render 子应用');
  if (props) {
    // 注入 actions 实例
    actions.setActions(props);
  }
  ownRouter = router;
  // 挂载应用
  instance = new Vue({
    router: ownRouter,
    store,
    i18n,
    render: h => h(App) // 需要用render的方式渲染
  }).$mount('#pms-app');
}

// 独立运行时,直接挂载应用
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

/**
 * bootstrap 只会在微应用初始化的时候调用一次
 * 下次微应用重新进入时会直接调用 mount 钩子
 * 不会再重复触发 bootstrap。
 * 通常我们可以在这里做一些全局变量的初始化
 * 比如不会在 unmount 阶段被销毁的应用级别的缓存等。
 */
export async function bootstrap() {
  // eslint-disable-next-line
  console.log('pmsMicroApp bootstraped');
}

/**
 * 应用每次进入都会调用 mount 方法
 * 通常我们在这里触发应用的渲染方法
 */
export async function mount(props) {
  // eslint-disable-next-line
  console.log('pmsMicroApp mount', props);

  props.onGlobalStateChange((curState) => {
    store.dispatch('InitUserInfo', curState.store);
    setRequest(curState.createRequest);
    render(props);
  });
}

/**
 * 应用每次 切出/卸载 会调用的方法
 * 通常在这里我们会卸载微应用的应用实例
 */
export async function unmount() {
  // eslint-disable-next-line
  console.log('pmsMicroApp unmount');
  instance.$destroy();
  instance = null;
  ownRouter = null;
}
  • 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.

另外,需要注意的是,需要在 main.js 入口中 import './public-path'; 否则会导致资源加载 404,比如主应用是 http://a.com/,微应用是 http://b.com,假如不设置的话,会以 http://a.com/1.js 访问微应用静态资源,会产生错误。public-path.js 如下:

// 设置动态配置路径
// 解决路由异构的问题:https://www.jianshu.com/p/5f99acb6aa10
if (window.__POWERED_BY_QIANKUN__ && process.env.NODE_ENV === 'development') {
  // 动态设置 webpack publicPath,防止资源加载出错
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

这里解释一下,因为在开发环境中,两个是不同的域名,所以需要设置 __webpack_public_path__,我们线上还是使用同一个域名(后面部署的环节会讲到),所以非开发环境不需要设置 __webpack_public_path__。

第二步,我们还需要修改一下路由,因为之前添加了一个前缀 /vueApp,所以我们在路由中设置 base(我们使用的是 Vue Router 的 history 模式,这里没试过 hash 模式):

new Router({
  base: window.__POWERED_BY_QIANKUN__ ? '/vueApp' : '/',
  mode: 'history',
  scrollBehavior: () => ({ y: 0 }),
  routes: constantRouterMap
});
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

第三步,修改 webpack 构建打包配置,使 main.js 导出的生命周期钩子函数可以被 qiankun 识别。先是 devServer,要使微应用能够被 fetch 并配置相应的跨域请求头,解决开发环境的跨域问题:

devServer: {
 // 关闭主机检查,使微应用可以被 fetch
 disableHostCheck: true,
 // 配置跨域请求头,解决开发环境的跨域问题
 headers: {
   "Access-Control-Allow-Origin": "*",
 },
 // ...
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

还需要配置导出方式,导出方式设置为:umd,就将我们的 library 暴露为所有的模块都可以运行的方式了(webpack 4 不支持对于 libraryTarget 设置为  module-ES Module。webpack 5 支持,但也还是实验阶段)。另外这个 library 设置的是微应用的包名,这里与主应用中注册的微应用名称一致。

output: {
   //// 微应用的包名,这里与主应用中注册的微应用名称一致
   library: "vueAppName",
   // 将你的 library 暴露为所有的模块定义下都可运行的方式
   libraryTarget: "umd",
   // 按需加载相关,设置为 webpackJsonp_pmsMicroApp 即可
   jsonpFunction: `webpackJsonp_pmsMicroApp`,
 },
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

至此,我们的微前端就搭建完成。

qiankun 通信

官方提供了 initGlobalState[7] 方法用于注册 MicroAppStateActions 实例用于通信。其使用的就是发布-订阅模式。

  • setGlobalState:设置 globalState - 设置新的值时,内部将执行 浅检查,如果检查到 globalState 发生改变则触发通知,通知到所有的观察者函数。
  • onGlobalStateChange:注册观察者函数 - 响应 globalState 变化,在 globalState 发生改变时触发该观察者函数。
  • offGlobalStateChange:取观察者函数 - 该实例不再响应 globalState 变化。

offGlobalStateChange:取观察者函数 - 该实例不再响应 globalState 变化。

主应用

在主应用中,通过 initGlobalState 和 setGlobalState 设置通信信息:

import { initGlobalState, MicroAppStateActions } from 'qiankun';

let globalState: any = {};
const actions: MicroAppStateActions = initGlobalState(globalState);
export async function initAppGlobalState() {
  await actions.setGlobalState(globalState);
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

子应用

在子应用中,设置 Action 类,并将 onGlobalStateChange,setGlobalState 映射到类方法中,导出类实例。

// micro-app-vue/src/shared/actions.js
function emptyAction() {
  console.warn("Current execute action is empty!");
}

class Actions {
  actions = {
    onGlobalStateChange: emptyAction,
    setGlobalState: emptyAction
  };
  /**
   * 设置 actions
   */
  setActions(actions) {
    this.actions = actions;
  }
  onGlobalStateChange(...args) {
    return this.actions.onGlobalStateChange(...args);
  }
  setGlobalState(...args) {
    return this.actions.setGlobalState(...args);
  }
}

const actions = new Actions();
export default actions;
  • 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.

在挂载子应用的时候,会调用 render 方法。这时可以获取到相关的 props,并传给 action 实例:

if (props) {
   // 注入 actions 实例
   actions.setActions(props);
 }
  • 1.
  • 2.
  • 3.
  • 4.

在需要使用的地方, 通过 onGlobalStateChange 监听获取:

actions.onGlobalStateChange(state => {
   console.log('state: ', state);
}, true);
  • 1.
  • 2.
  • 3.

CSS 隔离

qiankun 加载子项目 css 样式机制大体为:挂载子应用时将子应用的 css 样式以 style 标签的形式插入并做快照,卸载子应用时再将快照内的 style 样式删除。

所以在加载子应用期间,若未开启 css 沙箱隔离,后加载的这些样式,可能会对整个系统的样式产生影响,对此,qiankun 提供了两种 css 沙箱功能,可以将子应用的样式包裹在沙箱容器内部,以此来达到样式隔离的目的。

qiankun 严格沙箱

在加载子应用时,添加 strictStyleIsolation: true 属性,实现形式为将整个子应用放到 Shadow DOM 内进行嵌入,完全隔离了主子应用

缺点:

  • 子应用的弹窗、抽屉、popover 因找不到主应用的 body 会丢失,或跑到整个屏幕外
  • 主应用不方便去修改子应用的样式

实验性沙箱

在加载子应用时,添加 experimentalStyleIsolation: true 属性,实现形式类似于 vue 中 style 标签中的 scoped 属性,qiankun 会自动为子应用所有的样式增加后缀标签,如:div[data-qiankun-microName]

缺点:

  • 子应用的弹窗、抽屉、popover因插入到了主应用的body,所以导致样式丢失或应用了主应用了样式。相关issue[8]
使用 postcss-selector-namespace

在子应用中,配置 postcss 插件,给子应用添加类前缀:

const postcssLoader = {
   loader: 'postcss-loader',
   options: {
     // exclude: /node_modules/,
     sourceMap: options.sourceMap,
     plugins: [
       selectorNamespace({ namespace: '.vueapp' }),
     ]
   }
 }
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.

还是会存在上面插入 body 中的样式没有成功的问题,需要特殊处理。

主应用使用 CSS module,有特殊的情况特殊处理

上面也提到,子应用离开的时候,会销毁子应用的 style,而处于子应用的时候,我们页面大部分是子应用的 UI,所以我们尽可能保证主应用对子应用的无影响(主应用使用 CSS Module)。假如子应用对主应用有影响,我们就进行特殊处理。

因为我们主应用和子应用使用的框架是不一样的,所以冲突还比较少,所以目前使用这种方式。

部署

我们采用的是主应用和微应用都部署到同一个服务器(同一个 IP 和端口)的方式。将主应用部署在一级目录,微应用部署在二级目录。

需要注意:上面提到我们在路由中加了前缀 /vueApp,也是通过这个进行激活子应用。但是 activeRule 不能和微应用的真实访问路径一样,否则在主应用页面刷新会直接变成微前端应用页面。所以我们这里的二级目录名称为 microApp,跟 vueApp 区分开(只是举例说明)。

这里提到的微应用的真实访问路径就是微应用的 entry,我们设置为 ***/microApp/,然后子应用构建的时候,配置 webpack 构建时的 publicPath 为 microApp。

举例:

└── html/                     # 根文件夹
    |
    ├── microApp/                # 存放微应用的文件夹
    ├── index.html            # 主应用的index.html
    ├── css/                  # 主应用的css文件夹
    ├── js/                   # 主应用的js文件夹
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

主应用设置 entry 和 activeRules:

const apps = [
  {
    // ...
    entry: `//localhost:9528/microApp`,
    activeRule: `vueApp`,
  },
];
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

子应用路由设置:

base: window.__POWERED_BY_QIANKUN__ ? '/vueApp/' : '/microApp/',
  • 1.

子应用 publicPath 配置为:/microApp/

总结

随着互联网的快速发展,公司业务发展也随之增长,这个时候一个系统共存多个子应用的需求也就应运而生了。微前端作为近几年很火的架构,解决的就是这类问题。

qiankun 作为一个相对成熟的微前端解决方案,目前社区活跃,开箱即用,并且提供较为完备的功能,比如样式隔离、JS 沙箱、预加载等。

本文记录了 qiankun 在我们业务中的落地时间,整体而言,使用相对简单,能够满足我们业务需求,问题大部分能够在网上找到答案。如果跟我们有一样的业务场景,qiankun 是一个的不错选择。