背景
由于业务增长,团队拆分,我们需要将原有系统的一部分模块(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
第二步是设置路由,这里的 path 需要有一个特殊的前缀,用于激活子应用,这里我们统一称为 ``/vueApp`,这个后面还会用到,大家请记住。
第三步添加渲染入口:
const ChargingContainer = () => <section id="micro-app-container" />;
第四步注册微应用,通过 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;
第五步 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;
}
另外,需要注意的是,需要在 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__;
}
这里解释一下,因为在开发环境中,两个是不同的域名,所以需要设置 __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
});
第三步,修改 webpack 构建打包配置,使 main.js 导出的生命周期钩子函数可以被 qiankun 识别。先是 devServer,要使微应用能够被 fetch 并配置相应的跨域请求头,解决开发环境的跨域问题:
devServer: {
// 关闭主机检查,使微应用可以被 fetch
disableHostCheck: true,
// 配置跨域请求头,解决开发环境的跨域问题
headers: {
"Access-Control-Allow-Origin": "*",
},
// ...
}
还需要配置导出方式,导出方式设置为:umd,就将我们的 library 暴露为所有的模块都可以运行的方式了(webpack 4 不支持对于 libraryTarget 设置为 module-ES Module。webpack 5 支持,但也还是实验阶段)。另外这个 library 设置的是微应用的包名,这里与主应用中注册的微应用名称一致。
output: {
// …
// 微应用的包名,这里与主应用中注册的微应用名称一致
library: "vueAppName",
// 将你的 library 暴露为所有的模块定义下都可运行的方式
libraryTarget: "umd",
// 按需加载相关,设置为 webpackJsonp_pmsMicroApp 即可
jsonpFunction: `webpackJsonp_pmsMicroApp`,
},
至此,我们的微前端就搭建完成。
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);
}
子应用
在子应用中,设置 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;
在挂载子应用的时候,会调用 render 方法。这时可以获取到相关的 props,并传给 action 实例:
if (props) {
// 注入 actions 实例
actions.setActions(props);
}
在需要使用的地方, 通过 onGlobalStateChange 监听获取:
actions.onGlobalStateChange(state => {
console.log('state: ', state);
}, true);
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' }),
]
}
}
还是会存在上面插入 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文件夹
主应用设置 entry 和 activeRules:
const apps = [
{
// ...
entry: `//localhost:9528/microApp`,
activeRule: `vueApp`,
},
];
子应用路由设置:
base: window.__POWERED_BY_QIANKUN__ ? '/vueApp/' : '/microApp/',
子应用 publicPath 配置为:/microApp/
总结
随着互联网的快速发展,公司业务发展也随之增长,这个时候一个系统共存多个子应用的需求也就应运而生了。微前端作为近几年很火的架构,解决的就是这类问题。
qiankun 作为一个相对成熟的微前端解决方案,目前社区活跃,开箱即用,并且提供较为完备的功能,比如样式隔离、JS 沙箱、预加载等。
本文记录了 qiankun 在我们业务中的落地时间,整体而言,使用相对简单,能够满足我们业务需求,问题大部分能够在网上找到答案。如果跟我们有一样的业务场景,qiankun 是一个的不错选择。