关于javascript:基于qiankun的微服务落地实践

54次阅读

共计 27174 个字符,预计需要花费 68 分钟才能阅读完成。

前言

  近些年, 前端倒退炽热, 百家争鸣, 各种技术层出不穷, 现在的前端曾经不再像以前一样就是简略的写页面、调款式、解决 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 的接入协定

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

  2. 子利用不须要额定装置任何其它依赖即可接入主利用

    // 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';

// 初始化 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. 抽离公共代码
这里说的很明确, 不再赘述.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(高灯科技交易合规前端团队)
著作权归作者所有。商业转载请分割作者取得受权,非商业转载请注明出处。

正文完
 0