写点什么

基于 qiankun 的微服务落地实践

作者:GFE
  • 2022-11-01
    北京
  • 本文字数:21230 字

    阅读完需:约 70 分钟

基于qiankun的微服务落地实践

前言

  近些年,前端发展火热, 百家争鸣, 各种技术层出不穷,如今的前端已经不再像以前一样就是简单的写页面、调样式、处理 DOM 等,现在的前端工作内容越来越复杂,技术点也越来越丰富。


  当前,基于 Vue、React、Angular 的单页应用开发模式已经成为业界主流, 基本上成为近几年前端项目必备技术, 其前端生态也逐渐完善, 我们可以利用这些技术与生态快速构建一个新的应用, 这也大大缩短了项目的研发周期.


  但是随着公司业务的不断发展,前端业务越来越复杂,SPA 模式势必会导致项目文件越来越多, 构建速度越来越慢, 代码和业务逻辑也越来越难以维护,应用开始变得庞大臃肿,逐渐成为一个巨石应用,面对一个庞大,具有悠久历史的项目, 在日常开发、上线时都需要花费不少的时间来构建项目,这样的现象对开发人员的开发效率和体验都造成了极不好的影响, 因此解决巨石问题, 迫在眉睫, 势在必行.


  因此我们需要选择一个合适的方案,能不影响现有项目的继续迭代, 能兼容新的的技术栈,能支持老项目的增量升级, 新技术的加入不影响线上的稳定运行,


  如果有这样一个应用, 每个独立模块都有一个独立仓库,独立开发、独立部署、独立访问、独立维护,还可以根据团队的特点自主选择适合自己的技术栈,这样就能够解决我们所面临的问题, 还能极大的提升开发人员的效率和体验.


业务背景

  运营平台是我们内部使用的一套管理系统, 并一直跟随业务保持着两周一个版本的迭代工作, 后期的需求也很多.


  因历史原因框架选型采用 Angular8.x 开发,并且累计超过 10+ 位开发者参与业务开发,是一个页面数量多、逻辑关系混乱、注释信息不够完整、技术规范不统一、代码量的庞大,构建、部署的低效等问题的“巨石应用”。


  考虑到组件复用以及降低维护成本,在想怎么可以做到及时止损,,控制住项目指数级的野蛮生长,并把 Vue 技术运用到项目中同时使用。


  因此统一技术栈、工程拆分、规范化开发提上了工作日程,并期望各工程原有访问地址维持不变,使用户感知不到变化。


​  系统是采用传统的布局结构,头部导航展示一级菜单, 左侧展示一级菜单下的二级菜单, 所有页面内容都呈现在中间白色区域。



项目文件统计



页面与组件总数已经超过 1000 个, 代码总量超过 17 万行.


Jenkins 构建一次时间



单次构建时间达到 12min 之久,在遇到多项目并发构建时时间甚至会更久,严重影响开发/测试/生产的部署效率.


面临问题

从可行性、成本、技术方案、事故、回归验证等方面考虑以下问题


1.需要将现有项目按照业务或其它一定的规则进行拆分


2.现有项目的迭代计划不受影响


3.不能影响线上用户使用


4.框架需要支持原有的 Angular 技术与新接入的 Vue2/Vue3 技术


5.总体项目性能不能牺牲过大,会影响使用.


6.对于拆分改造,能否输出文档


7.全盘改造的成本评估是否合理


8.改动的影响范围是否可控


9.团队成员是否需要提前进行相关技术学习培训


10.首次上线是否存在突发事故风险与应对方案


11.如何进行回归测试.


12.微服务化后团队合作需要做出哪些改变.

目标

1、能够实现增量升级,尽可能减少对现有迭代开发的进度影响


2、保持原有访问地址不变,让用户无感知变化,不带来任何多余麻烦


3、大型模块可以分开开发、独立部署,实现工程拆分


4、删除无用代码,精简代码以及实现前端代码的规范化,易于后期维护


5、整理出组件库,实现内网部署,实现公共组件复用目的


6、提高页面加载性能以及预期达到整体项目性能的提高


7、增加监控体系,能有效收集到线上遗留异常问题


8、清晰梳理各模块业务,提高团队成员对项目、业务等的认识


方案对比


qiankun 介绍

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


  qiankun 孵化自蚂蚁金融科技基于微前端架构的云产品统一接入平台,在经过一批线上应用的充分检验及打磨后,我们将其微前端内核抽取出来并开源,希望能同时帮助社区有类似需求的系统更方便的构建自己的微前端系统,同时也希望通过社区的帮助将 qiankun 打磨的更加成熟完善。


目前 qiankun 已在蚂蚁内部服务了超过 200+ 线上应用,在易用性及完备性上,绝对是值得信赖的。

------摘自 qiankun 官网介绍


Single-SPA 的简要介绍

  2018 年 Single-SPA 诞生了,single-spa 是一个用于前端微服务化的 JavaScript 前端解决方案(本身没有处理样式隔离,js 执行隔离)实现了路由劫持和应用加载;目前已经发展到 5.x 版本.官网:https://single-spa.js.org/


qiankun 的简要介绍

很多人可能会好奇 qiankun 这个名字是怎么来的。实际上源自于这句话:小世界有大乾坤。我们希望在微前端这个小世界里面,通过 qiankun 这个框架鼓捣一个大乾坤。 方涣 –– 蚂蚁金服体验技术部前端工程师



  在 qiankun 里直接选用了社区成熟的方案 Single-SPA。 Single-SPA 已经具有劫持路由的功能,并完成了应用加载功能,也支持路由切换的操作,所以在开源的基础上进行设计与开发可以节省很多成本, 不需要重复造轮了。正是因为基于 Single-SPA 这样强大的开源支持,qiankun 最早在 2019 年就已问世, 它提供了更加开箱即用的 API (single-spa + sandbox + import-html-entry) 做到了技术栈无关,并且接入简单(有多简单呢,像 iframe 一样简单)。



  • 什么样的一个应用能够成为子应用,能够接入到 qiankun 的框架应用里?

  • 由于对接 qiankun 的子应用与技术栈无关,所以 qiankun 框架在设计上也考虑协议接入的方式。也就是说只要你的应用实现了 bootstrap 、mount 和 unmount 三个生命周期钩子,有这三个函数导出,负责外层的框架应用就可以知道如何加载这个子应用.这三个钩子也正好是子应用的生命周期钩子。当子应用第一次挂载的时候,会执行 bootstrap 做一些初始化,然后执行 mount 将它挂载。如果是一个 React 技术栈的子应用,可能就在 mount 里面写 ReactDOM.render ,把项目的 ReactNode 挂载到真实的节点上,把应用渲染出来。当应用切换走的时候,会执行 unmount 把应用卸载掉,当它再次回来的时候(典型场景:你从应用 A 跳到应用 B,过了一会儿又跳回了应用 A),这个时候是不需要重新执行一次所有的生命周期钩子的,也就是说不需要从 bootstrap 开始,而是会直接从 mount 阶段继续,这就也做到了应用的缓存。

  • 子应用的入口又如何选择?

  • qiankun 在这里选择的是 HTML,就是以 HTML 作为入口。借用了 Single-SPA 能力之后,qiankun 已经基本解决了应用的加载与切换。我们接下来要解决的另一块事情是应用的隔离和通信。


最佳实践

  基于 qiankun 微服务的前端框架, 其架构基本可以概括为: 基座应用 + 子应用 + 公共依赖方式。


基座应用可以理解为一个用于承载子应用的运行容器,所有的子应用都在这个容器内完成一系列初始化、挂载等生命周期。子应用即拆分出来个的一些子系统做成的应用。


  公共依赖就是抽离出在主应用与子应用中都会存在的公共依赖, 抽离出来后只需要在加载主应用时初始化, 其它子应用只需使用即可,无需重复加载。


  我们这里采用的技术栈是主应用 Vue3,原系统拆分为四个子应用(Angular 框架),新增一个子应用(Vue3 框架). 新增的子应用用于承载后续的业务需求开发。



初始化基座项目, 通过 vue/cli 快速创建一个 vue 项目


vue create appbase
复制代码


安装完成后,运行如下,vue 的默认首页,只要保证能够正常运行就好。



构建主应用基座, 开始进行改造 , 首先安装 qiankun。




安装完 qiankun 后,先对 main.ts 进行改造,针对主应用进行配置, 这里我们需要定义并注册微服务应用, 并添加全局状态管理与消息通知机制


apps.ts 文件用于统一存放微应用的信息


微应用配置信息中的 container 是用于设定微应用挂载节点的,要与自己设定的节点<divid="subapp"></div>中的 id 保持一致


// src/core/apps.ts
import { RegistrableApp } from "qiankun";const container = '#subapp';const props = {};
export const apps: Partial<RegistrableApp<any>>[] = [ { name: 'app1', entry: 'http://localhost:8081', activeRule: `/portal/app1`, container, props }];
复制代码


// src/main.ts
import { addGlobalUncaughtErrorHandler, FrameworkLifeCycles, registerMicroApps, RegistrableApp, runAfterFirstMounted} from "qiankun";import '@/plugins/polyfills';import { createApp, App as AppType } from "vue";import App from './App';import { setupComponents } from './components';import { setupDirectives } from './directives';// import { setupHooks } from './hooks';import { setupI18n } from './locales';import { setupAntd, /**setupEcharts,*/ setupMitt, setupVxe } from './plugins';import router, { setupRouter } from './router';import { setupStore } from './store';import { getApp, setApp, updateApp } from './useApp';import { mockXHR } from '@/mock/index';import { isDevMode, isMockMode } from './utils/env';// import 'css-doodle';import './style.less';// Tailwind// import "@/assets/css/styles.css";import { setupEcharts } from "./plugins/echarts";import { AppPager as pager } from "./core/app.pager";import { apps } from "./core/apps";
// 主应用let app: AppType<Element> | any;
// 生产模式覆盖console方法为空函数function disableConsole() {
// @ts-ignore Object.keys(window.console).forEach(v => window.console[v] = function () { });}!isDevMode() && disableConsole();

// 判断是否为mock模式isMockMode() && mockXHR();

// 封装渲染函数const loader = (loading: boolean) => render({ loading });const render = (props: any) => { const { appContent, loading } = props; if (!app) {
app = createApp(App);
// app setApp(app);
// ui setupAntd(app);
// store setupStore(app);
// router setupRouter(app);
// components setupComponents(app);
// directives setupDirectives(app);
// i18n setupI18n(app);
// report setupEcharts(app);
// EventBus setupMitt(app);
// DataTable setupVxe(app);
// mount router.isReady().then(() => {
app.mount('#app', true); }); } else { // console.log(app); console.log('loading : ', loading); app.content = appContent; app.loading = loading; updateApp(app); }}
// 主应用渲染render({ loading: true });

// 注册子应用const microApps: RegistrableApp<any>[] = [ ...apps.map(mapp => { return { ...mapp, props: { ...mapp.props, app, pager } } as RegistrableApp<any>; })];const lifeCycles: FrameworkLifeCycles<any> = { // beforeLoad: app => new Promise(resolve => {
// console.log("app beforeLoad", app); // resolve(true); // }), // afterUnmount: app => new Promise(resolve => {
// console.log("app afterUnmount", app); // resolve(true); // })};registerMicroApps(microApps, lifeCycles);
// 启动微服务const opts: FrameworkConfiguration = { prefetch: false };start(opts);
复制代码


运行起来 看下页面效果



是的,没有错,还是 Vue 项目最开始的默认效果,因为我们只对 main.ts 进行了改造,在没有接入子应用时候,主营用作为一个独立应用仍然是可以运行的。


总结一下 main.ts 的改造过程:


  1. 初始化基座应用 vue create appbase

  2. 安装乾坤 yarn add qiankunnpm i qiankun -S

  3. 设置微服务应用挂载的 DOM 节点<div id="subapp"></div>,注意这个 id 需要在注册子应用时使用

  4. 定义子应用 appbase\src\core\apps.ts

  5. 改造 main.ts, 注册子应用并添加相关工具类函数.


到此一个基座应用的开发先告一段落, 接下来就是需要定义子应用,并确保定义的子应用能在当前基座应用下成功运行.


创建微应用容器

我们在实践项目中使用的是 Vue3 作为基座应用, 原有的 Angular 项目拆分为多个子应用, 并新增加了一个 Vue 的子应用. 为了增加对 qiankun 的理解, 本文特意在实例中增加了不同版本的 Angular 框架以及 React 框架的微应用演示.


接入 Vue 子应用 app1(vue3)

1.创建子应用 app1, 方式同上,仍然使用 vue/cli 创建


vue create app1
复制代码


2.对子应用进行改造。


添加 vue.config.js, 有两点需要注意:


  1. output 需要设置为 umd 格式的 library, 这样主应用就可以加载当前 lib 并运行.

  2. devServe 端口需要设置与注册该子应用时的端口一致, 并设置 cors, 因为子应用与主应用不同域.


// vue.config.js
const { name} = require('./package.json');
module.exports = { filenameHashing: true, productionSourceMap: false, css: { extract: true, sourceMap: false, requireModuleExtension: true, loaderOptions: { less: { lessOptions: { modifyVars: { 'primary-color': '#00cd96', 'link-color': '#00cd96', 'border-radius-base': '4px', }, javascriptEnabled: true, }, }, } },
configureWebpack: { output: { library: `app1-[name]`, libraryTarget: 'umd', jsonpFunction: `webpackJsonp_${name}`, }, resolve: { extensions: [".js", ".vue", ".json", ".ts", ".tsx"] }, module: { rules: [] }, plugins: [ // ...plugins ], externals: { // 'vue': 'Vue', // 'vue-router': 'VueRouter', // 'axios': 'axios' }, // 开启分离js optimization: { runtimeChunk: 'single', splitChunks: { chunks: 'all', maxInitialRequests: Infinity, minSize: 20000, cacheGroups: { vendor: { test: /[\\/]node_modules[\\/]/, name(module) { // get the name. E.g. node_modules/packageName/not/this/part.js // or node_modules/packageName const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1]; // npm package names are URL-safe, but some servers don't like @ symbols return `app1.${packageName.replace('@', '')}` } } } } }, // 取消webpack警告的性能提示 performance: { hints: 'warning', // 入口起点的最大体积 maxEntrypointSize: 50000000, // 生成文件的最大体积 maxAssetSize: 30000000, // 只给出 js 文件的性能提示 assetFilter: function (assetFilename) {
return assetFilename.endsWith('.js'); } } }, devServer: { hot: true, disableHostCheck: true, port: 8081, overlay: { warnings: false, errors: true, }, headers: { 'Access-Control-Allow-Origin': '*' } }}
复制代码


3.改造子应用的 main.ts


  1. 子应用的接入需要符合 qiankun 的接入协议

  2. 微应用需要在自己的入口 js (通常就是你配置的 webpack 的 entry js) 导出 bootstrap、mount、unmount 三个生命周期钩子,以供主应用在适当的时机调用。

  3. 子应用不需要额外安装任何其它依赖即可接入主应用


// app1\src\main.ts
import { mockXHR } from '@/mock/index';import '@/plugins/polyfills';import { App as AppType, createApp } from "vue";import App from './App';import { setupComponents } from './components';import { setPager } from './core/app.pager';import { setupDirectives } from './directives';// import { setupHooks } from './hooks';import { setupI18n } from './locales';import { setupAntd, /**setupEcharts,*/ setupMitt, setupVxe } from './plugins';// Tailwind// import "@/assets/css/styles.css";import { setupEcharts } from "./plugins/echarts";import router, { setupRouter } from './router';import { setupStore } from './store';// import 'css-doodle';import './style.less';import { setApp } from './useApp';import { isDevMode, isMockMode } from './utils/env';
// 主应用let app: AppType<Element> | any;
// 生产模式覆盖console方法为空函数function disableConsole() {
// @ts-ignore Object.keys(window.console).forEach(v => window.console[v] = function () { });}!isDevMode() && disableConsole();
// 判断是否为mock模式isMockMode() && mockXHR();
// 封装渲染函数// const loader = (loading: boolean) => render({ loading });const render = (props: any) => { const { appContent, loading, container, pager } = props; if (!app) {
app = createApp(App);
// app setApp(app);
// ui setupAntd(app);
// store setupStore(app);
// router setupRouter(app);
// components setupComponents(app);
// directives setupDirectives(app);
// i18n setupI18n(app);
// report setupEcharts(app);
// EventBus setupMitt(app);
// DataTable setupVxe(app);
// registe pager setPager(pager);
// mount router.isReady().then(() => {
app.mount(container ? container.querySelector('#app') : '#app', true); }); } else { app.content = appContent; app.loading = loading; }}
// 是否运行在微服务环境中const isMicroApp: boolean = (window as any).__POWERED_BY_QIANKUN__;
// 允许独立运行 方便调试isMicroApp || render({});
if (isMicroApp) __webpack_public_path__ = (window as any).__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
// 微服务接入协议export async function bootstrap() {
}
export async function mount(props: any) {
// 订阅主应用全局状态变更通知事件 props.onGlobalStateChange((state, prev) => { // state: 变更后的状态; prev 变更前的状态 console.log(state, prev); });
render(props);}
export async function unmount() { if (app) { app.unmount(); app._container.innerHTML = ''; app = null; }}
复制代码


接入 Angular 子应用 app2(Angular12)

1.创建子应用 App2, Angular 子应用, app2 采用了 Angular12 版本,


ng new app2


2.注册微应用


appbase\src\core\apps.ts


import { RegistrableApp } from "qiankun";const container = '#subapp';const props = {};
export const apps: Partial<RegistrableApp<any>>[] = [ { name: 'app1', entry: 'http://localhost:8081', activeRule: `/portal/app1`, container, props }, { name: 'app2', entry: 'http://localhost:8082', activeRule: `/portal/app2`, container, props }, { name: 'app3', entry: 'http://localhost:8083', activeRule: `/portal/app3`, container, props }];
复制代码


3.配置微应用


ng add single-spa


ng add single-spa-angular


在生成 single-spa 配置后,我们需要进行一些 qiankun 的接入配置。我们在 Angular 微应用的入口文件 main.single-spa.ts 中,导出 qiankun 主应用所需要的三个生命周期钩子函数,代码实现如下:


app2\src\main.single-spa.ts


import { enableProdMode, NgZone } from '@angular/core';import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';import { NavigationStart, Router } from '@angular/router';import { getSingleSpaExtraProviders, singleSpaAngular } from 'single-spa-angular';import { AppModule } from './app/app.module';import { environment } from './environments/environment';import { singleSpaPropsSubject } from './single-spa/single-spa-props';
if (environment.production) { enableProdMode();}const __qiankun__ = (<any>window).__POWERED_BY_QIANKUN__;
if (!__qiankun__) { platformBrowserDynamic().bootstrapModule(AppModule) .catch(err => console.error(err));}
const lifecycles = singleSpaAngular({ bootstrapFunction: singleSpaProps => { singleSpaPropsSubject.next(singleSpaProps); return platformBrowserDynamic(getSingleSpaExtraProviders()).bootstrapModule(AppModule); }, template: '<app-root />', Router, NavigationStart, NgZone});
export const bootstrap = lifecycles.bootstrap;export const mount = lifecycles.mount;export const unmount = lifecycles.unmount;
复制代码



添加启动命令


app2:


"serve:single-spa": "ng s --project app2 --disable-host-check --port 8082 --live-reload false"



接入 Angular 子应用 app3(Angular13)

创建子应用 app3, Angular 子应用, app3 采用了最新版 Angular13,


ng new app3



配置微应用


app3\src\main.ts


import { enableProdMode,NgModuleRef} from '@angular/core';import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';import { Subject } from 'rxjs';import { setPager } from './app/@core/pager';import { AppModule } from './app/app.module';import { environment } from './environments/environment';import './public-path'; 
if (environment.production) { enableProdMode();}
let app: void | NgModuleRef<AppModule>;
async function render() { app = await platformBrowserDynamic() .bootstrapModule(AppModule) .catch((err) => console.error(err));}if (!(window as any).__POWERED_BY_QIANKUN__) { render();}
export async function bootstrap(props: Object) {}
export async function mount(props: any) {
const pager: Subject<any> = props.pager as Subject<any>; setPager(pager); await render();}
export async function unmount(props: Object) { // @ts-ignore await app.destroy();}
复制代码



添加启动命令


"serve:single-spa": "ng s --project app3 --disable-host-check --port 8083 --live-reload false"


接入 React 子应用 app4(React17 craco)

1.创建子应用 app4,React 子应用


npx create-react-app app4 --template typescript



2.子应用改造


app4\craco.config.js


const path = require('path');const resolve = dir => path.resolve(__dirname, dir);const CracoLessPlugin = require("craco-less");const SimpleProgressWebpackPlugin = require('simple-progress-webpack-plugin');const WebpackBar = require('webpackbar');
module.exports = { webpack: { alias: { '@': path.resolve('./src') }, configure: (webpackConfig, { env, paths }) => { paths.appBuild = 'dist';
webpackConfig.output = { ...webpackConfig.output, library: 'app4', libraryTarget: 'umd', path: path.resolve(__dirname, 'dist'), publicPath: 'http://localhost:8084/' };
webpackConfig.plugins = [ ...webpackConfig.plugins, new WebpackBar({ profile: true }), ];
return webpackConfig } }, plugins: [{ plugin: CracoLessPlugin, // 自定义主题配置 options: { lessLoaderOptions: { lessOptions: { modifyVars: { '@primary-color': '#1DA57A' }, javascriptEnabled: true } } } }], //抽离公用模块 optimization: { splitChunks: { cacheGroups: { commons: { chunks: 'initial', minChunks: 2, maxInitialRequests: 5, minSize: 0 }, vendor: { test: /node_modules/, chunks: 'initial', name: 'vendor', priority: 10, enforce: true } } } }, devServer: { port: 8084, headers: { 'Access-Control-Allow-Origin': '*' }, proxy: { '/api': { target: 'https://placeholder.com/', changeOrigin: true, secure: false, xfwd: false, } } }}
复制代码


app4\src\index.tsx


import React from 'react';import ReactDOM from 'react-dom';import { BrowserRouter as Router } from "react-router-dom";import './index.css';import App from './App';import { setPager } from './core/app.pager';import './public_path';
function render(props: any) { const { container, pager } = props; setPager(pager);
ReactDOM.render( <App />, getSubRootContainer(container) );}
function getSubRootContainer(container: any) {
return container ? container.querySelector('#root') : document.querySelector('#root');}
if (!window.__POWERED_BY_QIANKUN__) { render({})}
export async function bootstrap() {
}export async function mount(props: any) {
render(props)}export async function unmount(props: any) { const { container } = props; ReactDOM.unmountComponentAtNode(getSubRootContainer(container));}
复制代码


添加启动命令


"serve:single-spa": "set PORT=8084 && craco start FAST_REFRESH=true"


接入 React 子应用 app5(React17 react-scripts)

1.创建子应用 app5,React 子应用


npx create-react-app app4 --template typescript



2.子应用改造


app5\config-overrides.js


module.exports = {    webpack: (config) => {        config.output.library = 'app5';        config.output.libraryTarget = 'umd';        config.output.publicPath = 'http://localhost:8085/';        return config;    },    devServer: (configFunction) => {        return function (proxy, allowedHost) {            const config = configFunction(proxy, allowedHost);            config.headers = {                "Access-Control-Allow-Origin": '*'            }            return config        }    }}
复制代码


app5\src\index.tsx


import React from 'react';import ReactDOM from 'react-dom';import './index.css';import App from './App';import './public_path';import { setPager } from './core/app.pager';
function render(props: any) { const { container,pager } = props; setPager(pager); ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, getSubRootContainer(container) );}
function getSubRootContainer(container: any) {
return container ? container.querySelector('#root') : document.querySelector('#root');}
if (!window.__POWERED_BY_QIANKUN__) { render({})}
export async function bootstrap() {
}export async function mount(props: any) {
render(props)}export async function unmount(props: any) { const { container } = props; ReactDOM.unmountComponentAtNode(getSubRootContainer(container));}
复制代码


添加启动命令


"serve:single-spa": "react-app-rewired start"


通过二级路由初始化

在实际项目开发中,我们所需要展示的页面通常不是一级路由直接呈现的, 可能会存在多母版页, 多级嵌套场景, 这种情况下可能需要在主应用导航到某个页面之后再呈现子应用的内容.我们可以在某个二级路由进行微应用的初始化.


首先我们需要创建一个二级路由/potal/.(在以 Angular, React 技术为主应用时,实现原理也是一样的.)


appbase\src\views\portal


import { getApp } from '@/useApp';import { isDevMode } from '@/utils/env';import { FrameworkConfiguration, initGlobalState, MicroAppStateActions, start } from 'qiankun';import { defineComponent, onMounted, Ref, ref } from 'vue';import { useRouter } from 'vue-router';import DevPage from '../dev';import Style from './style.module.less';
export default defineComponent({ name: 'portal', setup() { const router = useRouter(); const app: any = getApp();
// 页面loaindg const appLoading: Ref<boolean> = ref(true); const timer: NodeJS.Timer = setInterval(() => { console.log('wathing') if (appLoading.value) { appLoading.value = app.loading; } else { clearInterval(timer); } }, 500)

// 初始化 state const state: Record<string, any> = { 'main.version': 'v0.0.1' }; const actions: MicroAppStateActions = initGlobalState(state); // actions.onGlobalStateChange((state, prev) => { // // state: 变更后的状态; prev 变更前的状态 // console.log(state, prev); // }); actions.setGlobalState(state); // actions.offGlobalStateChange();

// 启动微服务 onMounted(() => { if (!(window as any).qiankunStarted) { (window as any).qiankunStarted = true;
// 启用微服务 const isPrefetch = !isDevMode(); const opts: FrameworkConfiguration = { // 在生产模式下开启预加载 prefetch: isPrefetch, // 此处禁用沙箱 以提高部分性能 sandbox: false }; start(opts); } });
// 改变全局状态 const changeGlobalState = async () => { console.log('change global state'); actions.setGlobalState({ ...state, 'stamp': new Date().getTime() }); }
// 应用跳转 const redirectUrl = (path: string) => {
router.replace(`/portal${path}`); }
return () => ( <> <a-layout> <a-layout-header style={{ 'background-color': '#fff' }}> <h1 style={{ display: 'inline-block' }}>AppBase Portal {appLoading.value && 'loading'}</h1> <DevPage> {{ buttons: () => ( <a-button onClick={changeGlobalState}>修改全局状态</a-button> ) }} </DevPage> </a-layout-header> <a-layout> <a-layout-sider theme="light"> <a-menu> <a-menu-item onClick={() => redirectUrl('/app1')} key="app1"> App1 </a-menu-item> <a-menu-item onClick={() => redirectUrl('/app2')} key="app2"> App2 </a-menu-item> <a-menu-item onClick={() => redirectUrl('/app3')} key="app3"> App3 </a-menu-item> <a-menu-item onClick={() => redirectUrl('/app4')} key="app4"> App4 </a-menu-item> <a-menu-item onClick={() => redirectUrl('/app5')} key="app5"> App5 </a-menu-item> </a-menu> </a-layout-sider> <a-layout-content> { appLoading.value && ( <div class={Style.loadEffect}> <div><span></span></div> <div><span></span></div> <div><span></span></div> <div><span></span></div> </div> ) } <div id="subapp" style={{ 'min-height': '100vh', 'padding': '11px' }}></div> </a-layout-content> </a-layout> <a-layout-footer>Footer</a-layout-footer> </a-layout> </> ) }});
复制代码


经过改造后完整main.ts完整代码如下


// src/main.ts
import { addGlobalUncaughtErrorHandler, FrameworkLifeCycles, registerMicroApps, RegistrableApp, runAfterFirstMounted} from "qiankun";import '@/plugins/polyfills';import { createApp, App as AppType } from "vue";import App from './App';import { setupComponents } from './components';import { setupDirectives } from './directives';// import { setupHooks } from './hooks';import { setupI18n } from './locales';import { setupAntd, /**setupEcharts,*/ setupMitt, setupVxe } from './plugins';import router, { setupRouter } from './router';import { setupStore } from './store';import { getApp, setApp, updateApp } from './useApp';import { mockXHR } from '@/mock/index';import { isDevMode, isMockMode } from './utils/env';// import 'css-doodle';import './style.less';// Tailwind// import "@/assets/css/styles.css";import { setupEcharts } from "./plugins/echarts";import { AppPager as pager } from "./core/app.pager";import { apps } from "./core/apps";
// 主应用let app: AppType<Element> | any;
// 生产模式覆盖console方法为空函数function disableConsole() {
// @ts-ignore Object.keys(window.console).forEach(v => window.console[v] = function () { });}!isDevMode() && disableConsole();

// 判断是否为mock模式isMockMode() && mockXHR();

// 封装渲染函数const loader = (loading: boolean) => render({ loading });const render = (props: any) => { const { appContent, loading } = props; if (!app) {
app = createApp(App);
// app setApp(app);
// ui setupAntd(app);
// store setupStore(app);
// router setupRouter(app);
// components setupComponents(app);
// directives setupDirectives(app);
// i18n setupI18n(app);
// report setupEcharts(app);
// EventBus setupMitt(app);
// DataTable setupVxe(app);
// mount router.isReady().then(() => {
app.mount('#app', true); }); } else { // console.log(app); console.log('loading : ', loading); app.content = appContent; app.loading = loading; updateApp(app); }}
// 主应用渲染render({ loading: true });

// 注册子应用const microApps: RegistrableApp<any>[] = [ ...apps.map(mapp => { return { ...mapp, props: { ...mapp.props, app, pager } } as RegistrableApp<any>; })];const lifeCycles: FrameworkLifeCycles<any> = { // beforeLoad: app => new Promise(resolve => {
// console.log("app beforeLoad", app); // resolve(true); // }), // afterUnmount: app => new Promise(resolve => {
// console.log("app afterUnmount", app); // resolve(true); // })};registerMicroApps(microApps, lifeCycles);
// // 启动微服务// const opts: FrameworkConfiguration = { prefetch: false };// start(opts);
// 第一个子应用加载完毕回调runAfterFirstMounted(() => {
console.log('First App Mounted !!!')});
// 设置全局未捕获异常处理器addGlobalUncaughtErrorHandler(event => {
console.log(event);});
复制代码


130-132 行:去掉了原来在 main.ts 中的启动方式, 该方法调用在 protal 页面中实现.


项目结构

项目总体结构如下项目结构如下


qiankunapp ├─ app1 ├─ app2 ├─ app3 ├─ app4 ├─ app5 └─ appbase


appbase: 基座应用(主应用), vue3 + typescript + tsx + antv


  • app1 - app5 表示不同技术框架的子应用

  • app1: vue3 + typescript + tsx + antv

  • app2: Angular12 + typescript + ngzorro

  • app3: Angular13 + typescript + ngzorro

  • app4: React17 + typescript + craco + antd

  • app5: React17 + typescript + react-scripts +antd


完整功能演示

微服务应用功能演示


进阶

1.全局状态管理


主应用


appbase\src\views\portal\index.tsx


import {   FrameworkConfiguration,   initGlobalState,   MicroAppStateActions,   start } from 'qiankun';
// 初始化 stateconst state: Record<string, any> = { 'main.version': 'v0.0.1'};const actions: MicroAppStateActions = initGlobalState(state);actions.setGlobalState(state);
复制代码


子应用


app1\src\main.ts


export async function mount(props: any) {
// 订阅主应用全局状态变更通知事件 props.onGlobalStateChange((state, prev) => { // state: 变更后的状态; prev 变更前的状态 console.log(state, prev); });
render(props);}
复制代码


2.应用间通讯


这里我们采用了 rxjs 实现应用间的消息发布/订阅


主应用


appbase\src\core\app.pager.ts


import { filter, throttleTime } from 'rxjs/operators';import { Observable, Subject } from "rxjs";import router from '@/router';
// 消息来源export enum PagerEnum { // 主应用 BASE = 1, // 子应用 SUB = 2}
// 消息主体类型export interface PagerMessage { from: PagerEnum; data: any;}
export const AppPager: Subject<PagerMessage> = new Subject();
// 主应用下发消息export const PagerIssue = data => { const msg: PagerMessage = { from: PagerEnum.BASE, data: data }; AppPager.next(msg);}
// 主应用收集子应用的消息export const PagerCollect: Observable<PagerMessage> = AppPager.pipe( throttleTime(500), filter((msg: any) => msg.from == PagerEnum.SUB));

// pager数据处理export const HandlePagerMessage = ({ type, url }) => { switch (type) { case 'navigate': { router.replace(url); } break;
default: console.log('未识别的操作'); break; }}
复制代码


子应用


app1\src\core\app.pager.ts


import { Subject, Observable } from "rxjs";import { filter, throttleTime } from 'rxjs/operators';
let SubAppPager;
export const setPager = (_pager: Subject<any>) => {
SubAppPager = _pager;}
export const getPager = (): Subject<any> => SubAppPager;

// 消息来源export enum PagerEnum { // 主应用 BASE = 1, // 子应用 SUB = 2}
// 消息主体类型export interface PagerMessage { from: PagerEnum; data: any;}
// 子应用上报消息export const SubAppPagerIssue = data => { if (!SubAppPager) SubAppPager = getPager(); const msg: PagerMessage = { from: PagerEnum.SUB, data: data }; SubAppPager.next({ ...msg });}
// 订阅主应用下发的消息export const SubAppPagerCollect = (): Observable<PagerMessage> => { if (!SubAppPager) SubAppPager = getPager(); return SubAppPager.pipe( throttleTime(500), filter((msg: any) => msg.from == PagerEnum.BASE) );}
复制代码


发送消息


主应用下发消息, 子应用接收


// 下发消息import { PagerIssue } from "@/core/app.pager";
const onIssue = () => {
PagerIssue('i am from baseapp');}
复制代码


// 订阅消息import { Subscription } from "rxjs";import { defineComponent, onMounted, onUnmounted } from "vue";import {  SubAppPagerIssue,  SubAppPagerCollect,  PagerMessage,} from "../core/app.pager";
let pagerSub: Subscription = new Subscription();
onMounted(async () => { pagerSub = SubAppPagerCollect().subscribe((msg: PagerMessage) => { if (msg) { // 可在app.pager中实现主应用消息的统一处理 console.log("app1 接收到主应用消息 : ", msg.data); } });});
onUnmounted(() => { pagerSub?.unsubscribe?.();});
复制代码


子应用上报消息, 主应用接收


// app1\src\views\Home.vue<template>  <div class="home">    <a-button type="primary" @click="onIssueMsg">上报消息</a-button>  </div></template>
export default defineComponent({ name: "Home", setup() { let pagerSub: Subscription = new Subscription(); const onIssueMsg = async () => { SubAppPagerIssue("i am from app1"); }; return { onIssueMsg }; },});</script>
复制代码


主应用接收



import { HandlePagerMessage, PagerCollect, PagerIssue, PagerMessage } from "@/core/app.pager";import { defineComponent, inject, onMounted } from 'vue';
export default defineComponent({ name: 'DevPage', setup(props,{ slots }) {
onMounted(async () => {
PagerCollect.subscribe((msg: PagerMessage) => { if (msg) { console.log('接收到子应用上报的消息 : ', msg.data); HandlePagerMessage(msg.data); } }); });
return () => ( <><h1>主应用接收示例</h1></> ) }});
复制代码


3.应用跳转


子应用内部跳转与日常开发方式跳转一样, vue 环境可以通过 router 方法跳转, angular 环境可以通过 this.router.navigateByUrl, react 可以通过 navigate 对象跳转


应用间跳转可以通过 history.pushState 实现应用间跳转


为了实现路由事件的统一处理,通常可以在各子应用需要跳转时,通过消息通知方式告诉主应用, 由主应用统一进行跳转操作


4.子应用切换 Loading 的处理


应用程序加载我们可以通过主应用的加载状态进行处理,各自的子应用也可以进行各自的 loading 监测.


在主应用执行加载子应用未完成初始化阶段我们可以将 loading 的状态挂载到主应用的 app 下.各子应用在获取 props 时可以获取到该 loading 状态进行相关状态展示.



// 封装渲染函数const loader = (loading: boolean) => render({ loading });const render = (props: any) => { const { appContent, loading } = props; if (!app) {
app = createApp(App); // mount router.isReady().then(() => {
app.mount('#app', true); }); } else { // 这里挂载loading app.content = appContent; app.loading = loading; updateApp(app); }}
// 主应用渲染render({ loading: true });
复制代码


5.抽离公共代码


这里说的很明确,不再赘述.https://github.com/umijs/qiankun/issues/627


微服务部署

1.部署到同一服务器


如果服务器数量有限,或不能跨域等原因需要把主应用和微应用部署在一起。通常的做法是主应用部署在一级目录,微应用部署在二/三级目录。但是这样做会增加同一域名下的并发数量, 影响页面加载效率. 另外所有子应用都在一个根目录下, 不方便文件相关的操作.


2.部署到不同服务器


第二种方案主微应用部署在不同的服务器,使用 Nginx 代理进行访问。一般这么做是因为不允许主应用跨域访问微应用。具体思路是将主应用服务器上一个特殊路径的请求全部转发到微应用的服务器上,即通过代理实现“微应用部署在主应用服务器上”的效果。


架构演变

通过本次实践,不禁联想到近些年的前端架构演变, 从 web1.0 到今天的 mvvm 与微服务化, 带来了太多的改变.


简单整理了下架构相关的演变历程.



项目中遇到的问题

1.加载子应用失败


这类问题在刚刚接入微服务进行调试时是经常遇到的,其原因也有很多,官网也给出了一部分可能出现原因。首先我么要确主应用正确的注册了相关子应用,并设置了需要挂在的 DOM 节点,同时也要保证接入的子应用导出了符合规范的生命周期钩子,在满足了这些基本的条件之后仍然加载失败,我们就需要根据具体的报错信息进行定位。可能的涉及原因:


  1. 本地服务器没有设置 CORS 导致 JS 资源无法加载

  2. 子应用自身存在语法错误,我们可以先单独运行子应用来排除此类问题

  3. 如果使用了二级路由进行挂在,可能存在二级路由规则设置问题


2.子应用的图片无法展示


导致图片无法展示或者一些页面引用的资源 404 问题,通常都是浏览器默认了当前子应用的资源在当前主应用域名下。在 webpack 打包的项目中我们通过设置__webpack_public_path__来处理资源问题,在 Angular 框架中我么通过设置统一的管道处理当前资源的引入问题。


3.无法通过 rxjs 实现应用间通讯


可能存在 rxjs 版本过高问题,可以参考本文的示例源码使用。


4.找不到子应用路由


在确保应用的接入环节没有问题后,我们可以在控制台看到对应的资源加载情况。当子应用的资源正确加载后页面仍没有呈现子应用的内容,极大的可能是在子应用中没有添加针对微服务状态下的路由配置,如何判断子应用是在独立状态访问还在运行在微服务框架下?qiankun 为我们提供了 window.__POWERED_BY_QIANKUN__这样的变量用来区分,这样我们就可以在注册子应用路由时候设置路由相关的 base 变量了。


总结

  注:主应用加载子应用时,子应用必须支持跨域加载


  由于 qiankunshi 采用 HTML Entry,localStrage、cookie 可共享, 一些通过主应用保存在本地存储的信息在自应用中可以直接获取到.本文只是对 qiankun 的使用上做了一个基本的介绍, 并对不同技术框架的接入做了基础实践. 未涉及到的性能优化、权限集成、依赖共享、版本管理、团队协作、发布策略、监控等将在后续篇章中陆续发文.


  我们在对 OMS 平台进行微服务化后, 目前在生产环境已经平稳运行超过半年时间, 在时间过程中,我们也遇到了很多事前没有预料到的一些问题, 好在经过团队的努力攻克了各类难点问题,保证了项目的顺利上线与运行.另外, 在我们团队中也已将微服务纳入前端工程化建设中并作为重要 一环.团队工程化建设架构概要如下, 后续文章中我们也将着重介绍团队的工程化建设.



源码

本文示例源码:https://github.com/gfe-team/qiankunapp


参考链接

https://qiankun.umijs.org/

https://single-spa.js.org/docs/ecosystem-angular/

https://micro-frontends.org/

https://zhuanlan.zhihu.com/p/95085796

https://tech.meituan.com/2020/02/27/meituan-waimai-micro-frontends-practice.html

https://xiaomi-info.github.io/2020/04/14/fe-microfrontends-practice/

团队介绍

高灯科技交易合规前端团队(GFE), 隶属于高灯科技(北京)交易合规业务事业线研发部,是一个富有激情、充满创造力、坚持技术驱动全面成长的团队, 团队平均年龄 27 岁,有在各自领域深耕多年的大牛, 也有刚刚毕业的小牛, 我们在工程化、编码质量、性能监控、微服务、交互体验等方向积极进行探索, 追求技术驱动产品落地的宗旨,打造完善的前端技术体系。


  • 愿景: 成为最值得信任、最有影响力的前端团队

  • 使命: 坚持客户体验第一, 为业务创造更多可能性

  • 文化: 勇于承担、深入业务、群策群力、简单开放


Github:github.com/gfe-team


团队邮箱:gfe@goldentec.com


作者:GFE(高灯科技交易合规前端团队)


著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。


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

GFE

关注

还未添加个人签名 2020-04-09 加入

还未添加个人简介

评论

发布
暂无评论
基于qiankun的微服务落地实践_微服务_GFE_InfoQ写作社区