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

avatar
前端组件库 @华为
DevUI是一支兼具设计视角和工程视角的团队,服务于华为云DevCloud平台和华为内部数个中后台系统,服务于设计师和前端工程师。
官方网站:devui.design
Ng组件库:ng-devui(欢迎Star)

引言

还记得19年redux的作者Dan Abramov那篇关于微前端的Twitter吗,当时引起了前端届广泛的争论,很多人说微前端是伪命题,但是进入到2020年之后,各种有关微前端的文章和框架层出不穷,又将这个话题推到了风口浪尖,事实证明微前端已经继模块化,组件化之后作为另一种前端架构模式逐渐被业内所接受,在大型的ToB的中后台企业级应用开发场景下,会扮演越来越重要的角色,所以,现在是时候聊一聊微前端了。本篇文章分为上下两个部分,在上部主要探讨微前端的起源,应用场景,DevUI探索微前端的演进以及对single-spa的详细研究,下部分会以DevUI微前端改造过程为例,来详细探讨如何自研一个企业级微前端解决方案,希望本篇文章可以作为微前端研究者入坑的重要参考。

起源

微前端的概念是随着后端微服务的兴起,导致业务团队被分割为不同的小的开发团队,每个团队中有前后端,测试等角色,后端服务间可以通过http或者rpc互相调用,也可以通过api gateway进行接口的集成聚合,随之而来的是希望前端团队也能够独立开发微应用,然后在前端某个阶段(build,runtime)将这些微应用聚合起来,形成一个完整的大型web应用。于是这个概念在2016年thoughtworks技术雷达中被提出来了。

                 

对于微前端概念来讲,其本质还是web应用的复用与集成,尤其是当单页面应用出现后,每个团队不能按照以前那种服务端路由直出套模板的模式去开发页面了,整个路由都是被前端接管,所以最重要的两个问题就是web应用如何集成以及在哪个阶段集成,对应不同选择最终的实现方案也会有很大差异,这取决于你的业务场景,但是对于大多数团队,考虑微前端这种架构模式通常的诉求都是下面这样的:

  • 独立开发,独立部署,增量更新:对应上图,团队A,B,C最好互相无感知,每个子应用完全按照自己的版本节奏去开发,部署,更新。
  • 技术栈无关:团队A,B,C可以按照自己的需要选用任意框架开发,不需要强制保持一致
  • 运行时隔离与共享:在运行时,应用A,B,C组成了一个完整应用,通过主应用入口访问,需要保证A, B,C对应的js以及css互相隔离不受影响,同时有通信机制保证A,B, C能够互相通信或数据共享。
  • 单页面应用的良好体验:从一个子应用切换到另一个子应用的时候,路由变化不会reload整个页面,切换效果如同单页面应用的站内路由切换。

web集成方式

通常应用集成的阶段有两个,即构建时和运行时,不同阶段对应的实现方式也不同,大体来讲主要有下面几个:

  • 构建时集成:通过git sub module或者npm package在构建阶段集成在主应用仓库中,团队A,B,C独立开发,优点是实施简单,依赖也可以共享,缺点是A,B,C无法独立更新,其中一个发生更新,都需要主应用进行构建部署来更新完整应用。如下:

                     

这种方式更适合于小团队,因为当package越来越多的时候,会导致主应用频繁发布更新,此外还会让主应用的构建速度增长,代码维护成本越来越高,所以大多数选择微前端架构的,都是希望能够在运行时集成。

  • 服务端模板集成:在主应用的首页中定义模板,通过类似于nginx的SSI这样的技术让服务端通过路由动态选择集成团队A,B,C哪个子应用,如下:

index.html

<html lang="en" dir="ltr">
  <head>
    <meta charset="utf-8">
    <title>Feed me</title>
  </head>
  <body>
    <h1>content here</h1>
    <!--# include file="$CONTENT.html" -->
  </body>
</html>

对应的nginx配置nginx.conf

server {
    root html;    #ssi配置开始
    ssi on;  
    ssi_silent_errors on;  
    ssi_types text/shtml;  
    #ssi配置结束         
    index index.html index.htm;    rewrite ^/$ http://localhost/appa redirect;

    location /appa {
      set $CONTENT 'appa';
    }
    location /appb {
      set $CONTENT 'appb';    }
    location /appc {
      set $CONTENT 'appc'    }
}

团队A, B,C最终产出的是位于服务器上的某个模板文件,类似于PHP,JSP服务端集成基本上都是这样的原理,在服务端通过路由选择不同模板,拼装好首页内容并返回。这种模式首先是违背前后端分离的大趋势,会造成耦合,同时为了又需要花一定量的工作去维护server端,不适用于大型单页面应用集成的场景。

  • 运行时Iframe集成:可以说这种方式早期应该是最简单有效的,不同团队独立开发部署,只需要一个主应用通过iframe指向应用A, B,C对应的地址即可,如果需要主应用以及子应用之间通信,通过post message也能够很容易做到。,如下:
<html>
  <head>    
  <title>index.html</title>
  </head>
<body>   
 <iframe id="content"></iframe>    
 <script type="text/javascript">        
    const microFrontendsByRoute = {           
    '/appa': 'https://main.com/appa/index.html',            
    '/appb': 'https://main.com/appb/index.html',            
    '/appc': 'https://main.com/appc/index.html',        
  };        
    const iframe = document.getElementById('content');       
    iframe.src = microFrontendsByRoute[window.location.pathname];        
    window.addEventListener("message", receiveMessage, false);       
   function receiveMessage(event) {            
   var origin = event.origin           
   if (origin === "https://main.com") {                
   // do something       
    }             
  }        
 }    
  </script>
 </body>
</html>

但是这种方式缺点也很明显,尤其是用户体验会很差,iframe带来的整个页面的reload以及在某些场景下(例如iframe中某些全局dialog或者modal展示,二级路由状态丢失,session共享,开发调试困难)等问题,还是决定了它无法作为一个微前端模式下首选的web集成方案。

  • 运行时JS集成:这种集成方式一般情况下有两种模式,第一种就是将应用A, B,C打包成为不同的bundle,然后通过loader加载不同bundle,动态运行bundle的逻辑,渲染页面,如下:

                

这个时候应用A, B,C完全是互相无感知的,可以采用任何框架开发,路由切换导致的应用切换也不会造成页面reload,在运行时如果A,B,C想进行通信使用CustomEvent或者自定义EventBus都可以,A,B,C也可以通过不同框架本身的隔离机制或者通过一些沙箱机制实现应用隔离,这种方式看上去很不错

运行时集成的第二种模式就是使用web components,应用A, B,C将自己的业务逻辑最终写成一个 web components并到打包成一个bundle,然后由主应用来加载,执行并渲染,如下:

<html> 
 <body>    
<script src="https://main.com/appa/bundle.js"></script>    
<script src="https://main.com/appb/bundle.js"></script>    
<script src="https://main.com/appc/bundle.js"></script>    
<div id="content"></div>    
<script type="text/javascript">          
const routeTypeTags = {        
'/appa': 'app-a',        
'/appb': 'appb',        
'/appc': 'app-c',     
 };     
 const componentTag = routeTypeTags[window.location.pathname];    
 const content = document.getElementById('content');     
 const component = document.createElement(componentTag);      
 content.appendChild(component);   
 </script>  
 </body>
</html>

这种方式一般会有浏览器兼容性问题(需要引入polyfill),对三大框架的使用者来讲,并不是很友好(组件编写方式,改造成本,webcomponents版本组件库,开发效率,生态等),对于复杂应用来讲,整站选择这种开发模式会遇到很多坑,但是并不是不值得尝试,如果页面某些区域(一小块)需要独立开发部署的话,也可以采用这种集成方式(DevUI目前页面某些区域渲染就采用了web components)。目前有很多框架已经考虑到了上述的一些限制,最大限度的进行了优化,达到开箱即用的效果,这里推荐 stencil ,可以帮助你快速开发。

综上所述,从web应用集成方式来看,当前更适合微前端架构采用的应该是运行时通过JavaScript构造一个主从应用模型结构,然后通过不同路由来集成不同子应用,对应着上述不同方式,DevUI其实也经历了如下的几个阶段。

DevUI前端集成模式演进


如上图所示,devui前端显著地特点是:

1)有很多个服务,每个服务有自己的前端代码仓库,需要独立开发,测试,部署;

2)每个服务的前端都是由header和content内容区域组成的,都是一个基于Angular的单页面应用,服务间只有content内容区域不同,header都是一样的;

简单来讲,就是各业务团队独立的开发,通过路由分发到对应不同的服务,每个服务的前端都是一个完整的单页面应用。针对这样一种业务场景,各服务间集成与复用模式大致经历了以下几个阶段。

阶段一:公共组件化 + 服务间超链接

在这一阶段,我们将每一个服务都使用的header等区域独立出来做成了组件,以此来解决复用问题,服务间跳转仍然是使用最普通的超链接,如下所示:


这个阶段最大的遗留问题是:服务间跳转白屏明显,服务间session管理割裂,服务间跳转需要重新验证,用户体验极差。

出现这个问题的根本原因主要是:
1)基于Angular纯客户端渲染模式,需要等Angular本身的runtime以及header组件自身的静态资源加载完毕才会渲染,通常该过程要持续1秒左右,在这一秒之间,页面上是没有任何元素的,解决办法通常是SSR或者预渲染;
2)各服务子域名不一样,之前的登录状态(sessionId)是基于各服务子域名存储的,无法共享,导致重复登录与验证;

阶段二:App Shell(Pre render) + Session共享

关于单页面应用渲染白屏的问题业内是有标准解决办法的,通常使用的是SSR(服务端渲染)以及预渲染(Prerender),两者的区别就是SSR会在服务端(通常是Node)执行一些逻辑,将当前路由对应的HTML 首先生成,然后再返回给浏览器,而Prerender通常是在build阶段根据一些规则已经生成了对应的HTML内容,用户访问的时候直接返回给浏览器,如下:


单纯从用户体验和效果上讲,SSR无疑是最优的,但是如果全站都SSR,成本是很大的(每个服务都需要增加一层Node渲染层,而且SSR对于代码质量要求很高,Angular本身的SSR也不够成熟),所以互相权衡之下,我们选择了Prerender,通过在build阶段生成一个App Shell来解决白屏问题,如下:


在这个阶段,我们将header等各个服务都有的一些逻辑分为了两部分,一部分是当页面刷新时可以直观看到的header左侧部分,这一部分连同一些全局状态以及一个内置的event bus(用于通信)全部做成了一个npm包,在构建的时候统一注入在业务的人index.html中,header右侧部分依然是一个Angular组件(下拉菜单等需要用户操作才能看到的区域,即使延迟渲染也不会影响体验),需要业务引入在自己的组件树中,在运行阶段,用户访问index.html,整个应用的shell部分先渲染出来,然后接着加载angular对应的静态资源,接着渲染右侧header下拉菜单以及业务内容,header通过event bus与业务通信,当header右侧下拉菜单等内容渲染成功之后,我们将这些内容append到整个header区域中。

同时,我们又通过通过子域名session共享的方式也解决了服务间跳转之间重新登录校验的问题,通过这种渐进式渲染 + 预渲染模型,提升了用户体验,虽然是多页面应用,但是服务间跳转经过优化给人的感觉还是站内跳转,同时又能够保证不同团队独立开发,部署。这个阶段最大的遗留问题是,header等公共组件仍然作为npm包的形式下发给到不同服务,一旦header上的公共逻辑更新,会导致每个业务都要被动发布版本,造成人力浪费,所以大家都希望能够把公共组件解耦出来。

阶段三:widget(微应用)

至此,类似于header这样的公共组件已经是一个代表devui大部分公共逻辑的很复杂的组件了,它除了自身的一些view需要渲染之外,还需要执行大部分公共逻辑,缓存接口请求数据等供业务消费,所以在这个阶段,我们希望header这样的组件能够由公共团队独立开发,部署,在运行时与每个业务集成,形成一个完整的应用,如下:


我们希望的是业务开发自己的逻辑,header开发公共逻辑,互不干扰,独立发布更新,然后在运行时,业务通过一个类似于header-loader的东西将header引用进来(注意这里是运行时引用),通过这样一种方式,就能够免去header公共逻辑更新带给业务的被动升级工作,业务对header无感知。所以这里核心的问题就是header如何被集成,按照上一章节,这里是有两种方式的,即使用iframe集成及使用javascript动态渲染。iframe显然不太适合这样的场景,无论是从实现效果还是与业务通信复杂度来讲,所以这里我们通过类似于web components这种方式来实现(实际过程中你可以选用任何框架,只要它能满足加载bundle,执行逻辑并渲染这样的模式即可)


在这一阶段,我们解决了公共逻辑更新导致业务被动更新的问题,大大减少了业务的工作并大大提升了公共逻辑更新回退的响应速度,业务与公共逻辑独立开发部署。基本上满足了在一个大的应用内部,各个子业务与公共团队友好共存。但是挑战永远是存在的,业务又提出了更高的目标。

阶段四:跨应用的编排与集成

设想这样一个场景,对于一个大型企业来讲,内部有很多个中后台应用,可以把它想象为一个应用池(应用市场),对于某些业务,我希望从应用市场中拿出来 C,D ,E把它们都整合起来,形成一个大型业务A,供用户使用,同时我又希望从应用市场拿出来D, E, F,把他们整合成一个大型业务B,提供统一入口供用户使用,其中,A, B,C,D,E,F这些应用都是由不同团队开发维护的。在这种情况下,就需要有一种机制去定义一个标准的子应用需要去遵循什么样的规则,主应用如何去集成(加载,渲染,执行逻辑,隔离,通信,响应路由,依赖共享,框架无关等等)


这样一种机制,就是微前端本身需要去探讨的内容,在这个阶段,其实如何实现取决于你的业务复杂度,它可以很简单,也可以很复杂,甚至可以做一个服务化的产品并提供一整套解决方案来帮大家实现这样的目标(参考微前端架构体系),目前整个DevUI也处于这样一个探索阶段,会在本文的下半部分讲述其中的一部分要点。目前按照这样的要求,我们首先需要研究这种主从应用模式下微前端如何实现。

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

往期文章推荐

《敏捷设计,高效协同,凸显设计端云协同价值》

《现代富文本编辑器Quill的模块化机制》