基于qiankun的微服务落地实践

前言

  近些年,前端发展火热, 百家争鸣, 各种技术层出不穷,如今的前端已经不再像以前一样就是简单的写页面、调样式、处理DOM等,现在的前端工作内容越来越复杂,技术点也越来越丰富。
  当前,基于 Vue、React、Angular 的单页应用开发模式已经成为业界主流, 基本上成为近几年前端项目必备技术, 其前端生态也逐渐完善, 我们可以利用这些技术与生态快速构建一个新的应用, 这也大大缩短了项目的研发周期.
  但是随着公司业务的不断发展,前端业务越来越复杂,SPA模式势必会导致项目文件越来越多, 构建速度越来越慢, 代码和业务逻辑也越来越难以维护,应用开始变得庞大臃肿,逐渐成为一个巨石应用
面对一个庞大,具有悠久历史的项目, 在日常开发、上线时都需要花费不少的时间来构建项目,这样的现象对开发人员的开发效率和体验都造成了极不好的影响, 因此解决巨石问题, 迫在眉睫, 势在必行.
  因此我们需要选择一个合适的方案,能不影响现有项目的继续迭代, 能兼容新的的技术栈,能支持老项目的增量升级, 新技术的加入不影响线上的稳定运行,
  如果有这样一个应用, 每个独立模块都有一个独立仓库,独立开发、独立部署、独立访问、独立维护,还可以根据团队的特点自主选择适合自己的技术栈,这样就能够解决我们所面临的问题, 还能极大的提升开发人员的效率和体验.

业务背景

  运营平台是我们内部使用的一套管理系统, 并一直跟随业务保持着两周一个版本的迭代工作, 后期的需求也很多.
  因历史原因框架选型采用Angular8.x开发,并且累计超过 10+ 位开发者参与业务开发,是一个页面数量多、逻辑关系混乱、注释信息不够完整、技术规范不统一、代码量的庞大,构建、部署的低效等问题的“巨石应用”。
  考虑到组件复用以及降低维护成本,在想怎么可以做到及时止损,,控制住项目指数级的野蛮生长,并把 Vue 技术运用到项目中同时使用。
  因此统一技术栈、工程拆分、规范化开发提上了工作日程,并期望各工程原有访问地址维持不变,使用户感知不到变化。
​   系统是采用传统的布局结构,头部导航展示一级菜单, 左侧展示一级菜单下的二级菜单, 所有页面内容都呈现在中间白色区域。

页面结构.png

项目文件统计
运营前端代码统计.png
页面与组件总数已经超过1000个, 代码总量超过17万行.

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

面临问题

从可行性、成本、技术方案、事故、回归验证等方面考虑以下问题
1.需要将现有项目按照业务或其它一定的规则进行拆分
2.现有项目的迭代计划不受影响
3.不能影响线上用户使用
4.框架需要支持原有的Angular技术与新接入的Vue2/Vue3技术
5.总体项目性能不能牺牲过大,会影响使用.
6.对于拆分改造,能否输出文档
7.全盘改造的成本评估是否合理
8.改动的影响范围是否可控
9.团队成员是否需要提前进行相关技术学习培训
10.首次上线是否存在突发事故风险与应对方案
11.如何进行回归测试.
12.微服务化后团队合作需要做出哪些改变.

目标

1、能够实现增量升级,尽可能减少对现有迭代开发的进度影响
2、保持原有访问地址不变,让用户无感知变化,不带来任何多余麻烦
3、大型模块可以分开开发、独立部署,实现工程拆分
4、删除无用代码,精简代码以及实现前端代码的规范化,易于后期维护
5、整理出组件库,实现内网部署,实现公共组件复用目的
6、提高页面加载性能以及预期达到整体项目性能的提高
7、增加监控体系,能有效收集到线上遗留异常问题
8、清晰梳理各模块业务,提高团队成员对项目、业务等的认识

方案对比

微服务方案对比.png

qiankun介绍

  qiankun 是一个基于 single-spa微前端实现库,旨在帮助大家能更简单、无痛的构建一个生产可用微前端架构系统。
  qiankun 孵化自蚂蚁金融科技基于微前端架构的云产品统一接入平台,在经过一批线上应用的充分检验及打磨后,我们将其微前端内核抽取出来并开源,希望能同时帮助社区有类似需求的系统更方便的构建自己的微前端系统,同时也希望通过社区的帮助将 qiankun 打磨的更加成熟完善。
目前 qiankun 已在蚂蚁内部服务了超过 200+ 线上应用,在易用性及完备性上,绝对是值得信赖的。 ------摘自qiankun官网介绍

Single-SPA的简要介绍

  2018年 Single-SPA诞生了,single-spa是一个用于前端微服务化的JavaScript前端解决方案(本身没有处理样式隔离,js执行隔离)实现了路由劫持和应用加载;目前已经发展到5.x版本.官网: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框架). 新增的子应用用于承载后续的业务需求开发。
微服务前端架构.png

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

vue create appbase
复制代码

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

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

安装完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-default-page.png
是的,没有错,还是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的接入协议

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

  1. 子应用不需要额外安装任何其它依赖即可接入主应用
// 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

完整功能演示

qiankun-app.gif

进阶

1.全局状态管理
主应用
appbase\src\views\portal\index.tsx

import { 
  FrameworkConfiguration, 
  initGlobalState, 
  MicroAppStateActions, 
  start 
} from 'qiankun';

// 初始化 state
const 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.抽离公共代码
这里说的很明确,不再赘述.github.com/umijs/qiank…


微服务部署

1.部署到同一服务器
如果服务器数量有限,或不能跨域等原因需要把主应用和微应用部署在一起。通常的做法是主应用部署在一级目录,微应用部署在二/三级目录。但是这样做会增加同一域名下的并发数量, 影响页面加载效率. 另外所有子应用都在一个根目录下, 不方便文件相关的操作.
2.部署到不同服务器
第二种方案主微应用部署在不同的服务器,使用Nginx代理进行访问。一般这么做是因为不允许主应用跨域访问微应用。具体思路是将主应用服务器上一个特殊路径的请求全部转发到微应用的服务器上,即通过代理实现“微应用部署在主应用服务器上”的效果。

架构演变

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

项目中遇到的问题

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

源码


本文示例源码:github.com/gfe-team/qi…


参考链接

qiankun.umijs.org/
single-spa.js.org/docs/ecosys…
micro-frontends.org/
zhuanlan.zhihu.com/p/95085796
tech.meituan.com/2020/02/27/…
xiaomi-info.github.io/2020/04/14/…

团队介绍

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

  • 愿景: 成为最值得信任、最有影响力的前端团队
  • 使命: 坚持客户体验第一, 为业务创造更多可能性
  • 文化: 勇于承担、深入业务、群策群力、简单开放

Github:github.com/gfe-team
团队邮箱:[email protected]

作者:GFE(高灯科技交易合规前端团队)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

猜你喜欢

转载自juejin.im/post/7069576257893040142