微前端在企业级应用中的实践(下)丨【WEB前端大作战】

举报
Kagol 发表于 2021/04/29 23:26:19 2021/04/29
【摘要】 DevUI是一支兼具设计视角和工程视角的团队,服务于华为云DevCloud平台和华为内部数个中后台系统,服务于设计师和前端工程师官方网站:devui.designNg组件库:ng-devui(欢迎Star)上一篇介绍了微前端的一般理论和业界的几种实现方案,本篇将带着大家一起实践其中的single-spa方案,并深入其中的原理。Single-SPA使用整个业内关于微前端实现的解决方案有很多,被...


DevUI是一支兼具设计视角和工程视角的团队,服务于华为云DevCloud平台和华为内部数个中后台系统,服务于设计师和前端工程师

官方网站:devui.design

Ng组件库:ng-devui(欢迎Star)

题图.jpg

上一篇介绍了微前端的一般理论和业界的几种实现方案,本篇将带着大家一起实践其中的single-spa方案,并深入其中的原理。


Single-SPA使用

整个业内关于微前端实现的解决方案有很多,被大家广为接受的首推single-spa,它是基于主从模式下微前端解决方案的最早实现,同时也被后来的各种解决方案所借鉴(如qiankun,mooa等),可以毫不夸张的说,如果要研究微前端,需要先深入研究single-spa及其原理。

关于微前端的分类:single-spa将微前端分为以下三类:

  • single-spa标准子应用:可以通过single-spa对应不同路由渲染不同组件,通常是一个完整的子应用;
  • single-spa parcels:一个parcel通常不和路由关联,仅仅是页面上的某一个区域(类似于上面所说的widget)
  • utility module:独立开发的一些子模块,不渲染页面,只会执行一些公共逻辑。

其中前面两类是我们研究的重点,这里以angular8为例来展示single-spa的使用及上面的概念

step1创建子应用:首先创建一个根目录

mkdir microFE && cd microFE

接着在该目录下使用angular cli生成两个项目如下:

ng new my-app --routing --prefix my-app

在该项目的根目录下引入single-spa-angular(因为single-spa是一个与具体框架无关的微前端框架,不同框架的工程,渲染方式都不一样,为了将每种框架写成的子应用都抽象为一个标准的single-spa子应用,所以需要针对框架做一些改造,single-spa-angular就是针对angular的适配库,其他框架可以参考这里

ng add single-spa-angular

这里的操作主要做了下面几件事情:

1)将一个angular应用的入口从main.ts改造为main.single-spa.ts,如下所示:





import { enableProdMode, NgZone } from '@angular/core';

import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { Router } from '@angular/router';
import { ɵAnimationEngine as AnimationEngine } from '@angular/animations/browser'; 
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
import singleSpaAngular from 'single-spa-angular';
import { singleSpaPropsSubject } from './single-spa/single-spa-props';


if (environment.production) {
  enableProdMode();
}
const lifecycles = singleSpaAngular({
  bootstrapFunction: singleSpaProps => {
    singleSpaPropsSubject.next(singleSpaProps);
    return platformBrowserDynamic().bootstrapModule(AppModule);
  },
  template: '<my-app-root />',
  Router,
  NgZone: NgZone,
  AnimationEngine: AnimationEngine,
});

export const bootstrap = lifecycles.bootstrap;
export const mount = lifecycles.mount;
export const unmount = lifecycles.unmount;

从这里看出,一个标准single-spa子应用需要对外暴露三个生命周期的操作,即bootstrap,mount,unmount三个阶段。

2)在src/single-spa目录下创建了两个文件,一个是single-spa-props用来传递自定义属性,另外一个asset-url.ts用来动态获取当前应用的静态资源路径

3)在src目录下创建了一个空的路由,让单个应用在应用间挑跳转时找不到路由情况下显示空路由

app-routing.module.ts

const routes: Routes = [  { path: '**', component: EmptyRouteComponent }];

4)在package.json中添加了两个命令build:single-spa和serve:single-spa分别用来构建一个single-spa子应用和启动一个single-spa子应用。

5)在根目录下创建了一个自定义的webpack配置文件,引入了single-spa-angular的webpack配置(其中内容我们后面会分析)

接着需要在app-routing.module.ts中添加一个base href / ,如下,避免让整个子应用在angular路由切换的时候和整个angular路由发生冲突:





@NgModule({  
 imports: [RouterModule.forRoot(routes)],  
 exports: [RouterModule],  
 providers: [{ provide: APP_BASE_HREF, useValue: '/' }]})
 export class AppRoutingModule { }

这个时候如果使用npm run serve:single-spa命令就会在对应的端口(这里是4201)启动一个single-spa子应用,如下:


页面上并没有渲染出来任何内容,,只是将对应的single-spa作为一个main.js的bundle构建并映射在4201端口。

同时按照上述的步骤再创建另外一个应用my-app2,并将它的bundle映射在端口4202,这时候我们的目录结构如下:

  • my-app: single-spa子应用1
  • my-app2: single-spa子应用2
step2创建主应用:

我们在项目根目录下创建一个root-html,生成一个package.json文件

npm init -y && npm i serve -g

{  "name": "root-html",  
   "version": "1.0.0",  
   "description": "", 
    "main": "index.js",  
    "scripts": {    
      "start": "serve -s -l 4200"  
     }, 
   "keywords": [],  
   "author": "",  
    "license": "ISC"
}

在scripts里面会调用serve去开启一个web服务器映射该目录下面的内容

在该目录下创建一个index.html,内容如下:

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Security-Policy" content="default-src *  data: blob: 'unsafe-inline' 'unsafe-eval'; script-src * 'unsafe-inline' 'unsafe-eval'; connect-src * 'unsafe-inline'; img-src * data: blob: 'unsafe-inline'; frame-src *; style-src * data: blob: 'unsafe-inline'; font-src * data: blob: 'unsafe-inline';">
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title>Your application</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="importmap-type" content="systemjs-importmap">
    <script type="systemjs-importmap">
      {
        "imports": {
          "app1": "http://localhost:4201/main.js",
          "app2": "http://localhost:4202/main.js",
          "single-spa": "https://cdnjs.cloudflare.com/ajax/libs/single-spa/4.3.5/system/single-spa.min.js"
        }
      }
    </script>
    <link rel="preload" href="https://cdnjs.cloudflare.com/ajax/libs/single-spa/4.3.5/system/single-spa.min.js" as="script" crossorigin="anonymous" />
    <script src='https://unpkg.com/core-js-bundle@3.1.4/minified.js'></script>
    <script src="https://unpkg.com/zone.js"></script>
    <script src="https://unpkg.com/import-map-overrides@1.6.0/dist/import-map-overrides.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/4.0.0/system.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/4.0.0/extras/amd.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/4.0.0/extras/named-exports.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/4.0.0/extras/named-register.min.js"></script>
  </head>
  <body>
    <script>
      System.import('single-spa').then(function (singleSpa) {
        singleSpa.registerApplication(
          'app1',
          function () {
            return System.import('app1');
          },
          function (location) {
            return location.pathname.startsWith('/app1');
          }
        );

        singleSpa.registerApplication(
          'app2',
          function () {
            return System.import('app2');
          },
          function (location) {
            return location.pathname.startsWith('/app2');
          }
        )
        
        singleSpa.start();
      })
    </script>
    <import-map-overrides-full></import-map-overrides-full>
  </body>
</html>
页面刷新时,我们使用systemjs先加载single-spa,当这个文件加载成功时,我们定义这两个子应用一和二的入口文件,每个子应用需要提供一个activity 函数供single-spa来判断当前路由下哪个子应用处于active状态,loading function ,当切换到对应的子应用时,需要加载哪些静态资源,其中systemjs以及import maps对应的相关知识可以自行查看,在这里就简单理解为一个bundle loader即可,其实简单情况下你使用动态script标签插入也是能达到同样效果的。接着使用 npm run start即可在4200启动主应用,通过localhost:4200/app1结果如下:

这个时候已经可以在app1和app2之间通过路由做到类似于站内跳转的效果了,如果要配置子应用的二级路由,可以参考文章后面的代码。

step3创建parcel应用:

上面两步实现了不同路由下子应用的切换,如果希望某个团队独立开发一个页面片段并集成到上述任意一个应用中那么如何实现呢,single-spa在5.X之后提供了parcel的概念,可以通过这种方式将一个其他框架编写的组件加载并展示在任意一个子应用中。

我们首先在根目录下使用vue-cli创建一个新的项目:

vue create my-parcel

接着在该项目下添加single-spa(具体操作这里不详细介绍了,可以看文档做了些啥)

vue add single-spa

接着构建并启动parcel应用,npm run serve

这个时候同样会在localhost:8080端口启动一个vue项目打包出来的子应用bundle,我们将它配置在root应用的index.html中让systemjs能够找到它。


接着我们在my-app2中去加载展示它。

my-app2的app.component.ts

import { Component,ViewChild, ElementRef, OnInit, AfterViewInit } from '@angular/core';
import { Parcel, mountRootParcel } from 'single-spa';
import { from } from 'rxjs';
@Component({  
selector: 'my-app2-root', 
templateUrl: './app.component.html',  
styleUrls: ['./app.component.css']})
export class AppComponent implements OnInit, AfterViewInit {
  title = 'my-app2';  
  @ViewChild('parcel', { static: true }) private parcel: ElementRef;  
  ngOnInit() {    
     from(window.System.import('parcel')).subscribe(app => { 
      mountRootParcel(app, { domElement :this.parcel.nativeElement});   
   })  
  } 
}

在init时,我们取得组件上某个parcel的挂载点,加载vue子应用bundle,然后调用single-spa提供的 mountRootParcel方法,来挂载子组件(应用),这个方法传递的第二个参数是挂载点的dom元素,第一个参数是parcel子应用,一个parcel子应用和single-spa子应用的重要区别是parcel应用可以对外暴露一个可选的update方法

vue项目的main.js

import './set-public-path';
import Vue from 'vue';
import singleSpaVue from 'single-spa-vue';
import App from './App.vue';
Vue.config.productionTip = false;
const vueLifecycles = singleSpaVue({
 Vue,
 appOptions: {render: (h) => h(App),
},
});
export const bootstrap = vueLifecycles.bootstrap;
export const mount = vueLifecycles.mount;
export const unmount = vueLifecycles.unmount;

效果如下,当我们切换到App2子应用的时候,发现我们的view组件也被展示了出来:


Single-SPA原理分析

在上一章节中我们使用single-spa实现了不同子应用通过路由切换和非路由模式下加载parcel应用。对single-spa是什么及其使用都有了一定了解,这个时候大家一定很好奇single-spa内部做了什么,能够实现这样一套机制,我们就来分析一下single-spa及single-spa-angular的内部逻辑。


applicaitons及parcels模块:首先singles-spa对外暴露了两种API,一种是applicaitons api,直接通过从single-spa中引入即可使用,通常是对子应用及主应用的操作,另一种是parcels api,通常是对parcel的操作,分别对应这两个模块,相关的api可以参考这里

devtools模块:在single-spa5之后提供了一个devtools,可以通过chrome直接查看当前子应用的状态等等,所以devtools模块主要是将开发者工具需要用到的一些api包起来赋值给window.__SINGLE_SPA_DEVTOOLS__.exposedMethods变量,供devtools调用;

utils模块:utils模块主要是为了浏览器兼容性,实现了一些方法函数;

lifecycles模块:lifecycles模块主要是将single-spa子应用和parcel子应用的生命周期抽象,定义了如下几个阶段:

  • 对于single-spa子应用:load-> bootstrap->Mount ->Unmount->Unload
  • 对于parcel子组件(应用):bootstrap->Mount->Unmount ->Update

不管对于parcel还是single-spa子应用来讲,都要对外暴露至少三个阶段的方法,即 bootstrap,mount以及unmount阶段的操作,供single-spa在应用间切换时不同生命周期过程中调用,不同框架对于这三个阶段的实现都不一样,single-spa无法抹平这种差异,只能通过额外的single-spa-angular或者single-spa-vue这种库函数实现。

navigation模块:当一个单页面应用路由切换时,通常会触发两种不同事件,即hashchange和popstate,同时对应hash路由和history路由,single-spa在navigation模块中对于全局监听这些事件,当某个子应用路由切换时(匹配到该路由),首先进入到index.html,会执行single-spa对当前路由的接管,会按照当前路由调用子应用注册时配置的activity函数,判断属于哪个子应用,接着调用loading函数加载子应用,子应用按照之前的生命周期流转,卸载unmount掉当前路由下对应的旧应用,同时调用bootstrap启动新的应用,mount新的应用,同时singles-spa还提供了手动触发应用切换的api,和被动路由刷新的机制是一样的。此外这个模块还提供了一个reroute方法作为入口,当路由切换时,该方法依次执行以上操作。

jquery-support.js:由于jquery使用事件代理,会将很多事件代理绑定到window上,如果有使用jquery注册的hashchange和popstate事件的话,需要特殊处理。

start.js:将navigation中的reroute逻辑全部引入进来,显式启动single-spa。

single-spa.js:作为single-spa的入口将上述几个模块对外暴露的api聚集起来,导出供外部调用。

所以从上述来看,不管是使用什么框架写的应用,只要接入single-spa,一定需要实现生命周期三个方法bootstrap,mount及Unmout供single-sap调用,如下所示:


我们用systemjs加载的app2的module中存在bootstrap,mount及unmount方法。

按照上述分析,single-spa大致原理及流程如下:


其中5,7,8三步由于存在框架差异,需要借助类似于single-spa-angular这样的库来实现,下面我们来看看single-spa-angular里面是怎么实现的。

Single-SPA-Angular分析

single-spa-angular一共分为四个部分,src目录结构如下:


其中每个部分对应在上一节我们使用ng add single-spa-angular所做的操作:

webpack目录:index.ts内容如下:




import * as webpackMerge from 'webpack-merge';
import * as path from 'path'

export default (config, options) => {
  const singleSpaConfig = {
    output: {
      library: 'app3',
      libraryTarget: 'umd',
    },
    externals: {
      'zone.js': 'Zone',
    },
    devServer: {
      historyApiFallback: false,
      contentBase: path.resolve(process.cwd(), 'src'),
      headers: {
          'Access-Control-Allow-Headers': '*',
      },
    },
    module: {
      rules: [
        {
          parser: {
            system: false
          }
        }
      ]
    }
  }
  // @ts-ignore
  const mergedConfig: any = webpackMerge.smart(config, singleSpaConfig)
  removePluginByName(mergedConfig.plugins, 'IndexHtmlWebpackPlugin');
  removeMiniCssExtract(mergedConfig);

  if (Array.isArray(mergedConfig.entry.styles)) {
    // We want the global styles to be part of the "main" entry. The order of strings in this array
    // matters -- only the last item in the array will have its exports become the exports for the entire
    // webpack bundle
    mergedConfig.entry.main = [...mergedConfig.entry.styles, ...mergedConfig.entry.main];
  }

  // Remove bundles
  delete mergedConfig.entry.polyfills;
  delete mergedConfig.entry.styles;
  delete mergedConfig.optimization.runtimeChunk;
  delete mergedConfig.optimization.splitChunks;

  return mergedConfig;
}
function removePluginByName(plugins, name) {
  const pluginIndex = plugins.findIndex(plugin => plugin.constructor.name === name);
  if (pluginIndex > -1) {
    plugins.splice(pluginIndex, 1);
  }
}
function removeMiniCssExtract(config) {
  removePluginByName(config.plugins, 'MiniCssExtractPlugin');
  config.module.rules.forEach(rule => {
    if (rule.use) {
      const cssMiniExtractIndex = rule.use.findIndex(use => typeof use === 'string' && use.includes('mini-css-extract-plugin'));
      if (cssMiniExtractIndex >= 0) {
        rule.use[cssMiniExtractIndex] = {loader: 'style-loader'}
      }
    }
  });
}


我们上节通过一个webpack自定义配置文件引入了这个配置,让angular-cli去使用这个配置打包,这个配置所做的事情就是将我们最后输出的bundle以umd格式打包,同时给他一个exports叫做app3,将zone,js抽取出来,在index.html里面直接共享,同时为了不让webpack覆盖system全局变量,制定parser下面的system为false,剩下的操作就是把所有的入口包括全局css都去掉,只保留一个main入口,这样保证最终一个angular子应用打包出来的只有一个main.js。

schmatics目录:关于schematics如果不了解可以暂且认为它可以扩展或者覆盖angular cli的add命令,在add命令上执行一些自定义操作。schematics目录下执行的核心代码就不贴了,其实结果就是你输入ng add single-spa-angular的时候,它会执行四件事:

1)更新项目根目录下的package.json,写入single-spa-angular相关的依赖,如@angular-builders/custom-webpack,single-spa-angular等。

2)会以内置模板创建四个文件:main.single-spa.ts,single-spa-props.ts,asset-url.ts,extra-webpack.config.js;

3)会更新angular.json,让它使用@angular-builders/custom-webpack:browser 和@angular-builders/custom-webpack:dev-server builder

4)更新packages.json新增两个命令:build:single-spa和serve:single-spa用来构建和启动single-spa子应用。

builder目录:什么是angular的builder这里也不多做介绍,你只需要了解使用builder可以覆盖获扩展angular cli 的build及serve命令即可,build:single-spa和serve:single-spa这两个命令的操作在angular8之前是使用builder实现的,angular8之后直接使用custom-webpack来实现了,如果你使用的是angular8及以上,这里不会执行这些代码。

browser-lib目录:核心代码如下




/* eslint-disable @typescript-eslint/no-use-before-define */
import { AppProps, LifeCycles } from 'single-spa'

const defaultOpts = {
  // required opts
  NgZone: null,
  bootstrapFunction: null,
  template: null,
  // optional opts
  Router: undefined,
  domElementGetter: undefined, // only optional if you provide a domElementGetter as a custom prop
  AnimationEngine: undefined,
  updateFunction: () => Promise.resolve()
};

export default function singleSpaAngular(userOpts: SingleSpaAngularOpts): LifeCycles {
  if (typeof userOpts !== "object") {
    throw Error("single-spa-angular requires a configuration object");
  }

  const opts: SingleSpaAngularOpts = {
    ...defaultOpts,
    ...userOpts,
  };

  if (typeof opts.bootstrapFunction !== 'function') {
    throw Error("single-spa-angular must be passed an opts.bootstrapFunction")
  }

  if (typeof opts.template !== "string") {
    throw Error("single-spa-angular must be passed opts.template string");
  }

  if (!opts.NgZone) {
    throw Error(`single-spa-angular must be passed the NgZone opt`);
  }

  return {
    bootstrap: bootstrap.bind(null, opts),
    mount: mount.bind(null, opts),
    unmount: unmount.bind(null, opts),
    update: opts.updateFunction
  };
}

function bootstrap(opts, props) {
  return Promise.resolve().then(() => {
    // In order for multiple Angular apps to work concurrently on a page, they each need a unique identifier.
    opts.zoneIdentifier = `single-spa-angular:${props.name || props.appName}`;

    // This is a hack, since NgZone doesn't allow you to configure the property that identifies your zone.
    // See https://github.com/PlaceMe-SAS/single-spa-angular-cli/issues/33,
    // https://github.com/single-spa/single-spa-angular/issues/47,
    // https://github.com/angular/angular/blob/a14dc2d7a4821a19f20a9547053a5734798f541e/packages/core/src/zone/ng_zone.ts#L144,
    // and https://github.com/angular/angular/blob/a14dc2d7a4821a19f20a9547053a5734798f541e/packages/core/src/zone/ng_zone.ts#L257
    opts.NgZone.isInAngularZone = function() {
      // @ts-ignore
      return window.Zone.current._properties[opts.zoneIdentifier] === true;
    }

    opts.routingEventListener = function() {
      opts.bootstrappedNgZone.run(() => {
        // See https://github.com/single-spa/single-spa-angular/issues/86
        // Zone is unaware of the single-spa navigation change and so Angular change detection doesn't work
        // unless we tell Zone that something happened
      })
    }
  });
}

function mount(opts, props) {
  return Promise
    .resolve()
    .then(() => {
      const domElementGetter = chooseDomElementGetter(opts, props);
      if (!domElementGetter) {
        throw Error(`cannot mount angular application '${props.name || props.appName}' without a domElementGetter provided either as an opt or a prop`);
      }

      const containerEl = getContainerEl(domElementGetter);
      containerEl.innerHTML = opts.template;
    })
    .then(() => {
      const bootstrapPromise = opts.bootstrapFunction(props)
      if (!(bootstrapPromise instanceof Promise)) {
        throw Error(`single-spa-angular: the opts.bootstrapFunction must return a promise, but instead returned a '${typeof bootstrapPromise}' that is not a Promise`);
      }

      return bootstrapPromise.then(module => {
        if (!module || typeof module.destroy !== 'function') {
          throw Error(`single-spa-angular: the opts.bootstrapFunction returned a promise that did not resolve with a valid Angular module. Did you call platformBrowser().bootstrapModuleFactory() correctly?`)
        }
        opts.bootstrappedNgZone = module.injector.get(opts.NgZone)
        opts.bootstrappedNgZone._inner._properties[opts.zoneIdentifier] = true;
        window.addEventListener('single-spa:routing-event', opts.routingEventListener)

        opts.bootstrappedModule = module;
        return module;
      });
    });
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
function unmount(opts, props) {
  return Promise.resolve().then(() => {
    if (opts.Router) {
      // Workaround for https://github.com/angular/angular/issues/19079
      const routerRef = opts.bootstrappedModule.injector.get(opts.Router);
      routerRef.dispose();
    }
    window.removeEventListener('single-spa:routing-event', opts.routingEventListener)
    opts.bootstrappedModule.destroy();
    if (opts.AnimationEngine) {
      const animationEngine = opts.bootstrappedModule.injector.get(opts.AnimationEngine);
      animationEngine._transitionEngine.flush();
    }
    delete opts.bootstrappedModule;
  });
}

这里核心是实现了bootstrap,mount以及unmout三个方法,其中boostrap阶段只是在子应用loading完成之后做了多实例angular应用的标志并告诉zonejs single-spa触发了子应用切换,需要启动变更检测。mount阶段调用了angular的platformBrowserDynamic().bootstrapModule(AppModule)方法手动启动angular应用,并将启动的module实例保存了下来。在unmout阶段,调用启动的module实例的destroy方法,销毁子应用,并针对特殊情况做了一些处理。这里的核心点在于挂载。

总结:

在这篇文章的上部分我们讲述了微前端的起源以及web应用的多种集成方式,通过讲述DevUI的web集成模式案例,加深了对这部分内容的理解,同时使用single-spa实现了一个微前端模型并对single-spa进行了原理分析,在下半部分我们将围绕DevUI微前端改造过程去深入探讨,讲述如何自研一个企业级微前端解决方案。代码https://github.com/myzhibie/microFE-single-spa


加入我们

我们是DevUI团队,欢迎来这里和我们一起打造优雅高效的人机设计/研发体系。招聘邮箱:muyang2@huawei.com。

文/DevUI myzhibie

往期文章推荐

《微前端在企业级应用中的实践(上)》

《号外号外!DevUI Admin V1.0 发布啦!》

《2021 年最值得推荐的 7 个 Angular 前端组件库 - DevUI》




【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。