Angular 微前端實踐 之 Single-SPA 手把手教程(上)

Mien發表於2020-03-14

最近自己在研究Angular的微前端實踐,算是比較完整的從零走通了整個流程。瞭解到很多小夥伴也有這方面的需求,所以整理了一些內容希望對各位小夥伴有幫助。

各位看官時間有限,我們直接進入正題。

目標

  • 一個container專案,兩個微前端專案
  • container專案的展示頁面同時載入兩個專案
  • 不同專案之間,資源抽離,減小載入資源量

環境

  • node -v 10.16.3
  • npm -v 6.9.0
  • angular -v 8.2.11
  • VS code

專案準備

  • 確認本地安裝Angular-cli
  • 使用命令 ng new project --prefix=prefix建立三個專案
  • 微前端的專案最好使用不同的prefix這樣在載入專案的時候才不會出錯。

本示例中執行的命令如下:

  • ng new container --prefix=slb
  • ng new app1 --prefix=app1
  • ng new app2 --prefix=app2

container部分

安裝依賴

  • npm i single-spa --save
  • npm i systemjs --save
  • npm i import-map-overrides --save

修改angular.json

將build下的scripts修改如下:

"scripts": [
    "node_modules/systemjs/dist/system.min.js",
    "node_modules/systemjs/dist/extras/amd.min.js",
    "node_modules/systemjs/dist/extras/named-exports.min.js",
    "node_modules/systemjs/dist/extras/named-register.min.js",
    "node_modules/import-map-overrides/dist/import-map-overrides.js"
    ]
複製程式碼

以上我們就完成了container專案的配置工作,下面開始進入程式碼環節。

修改index.html

在head標籤下增加

<meta name="importmap-type" content="systemjs-importmap" />
<script type="systemjs-importmap" src="/assets/import-map.json"></script>
複製程式碼

在body標籤下增加

<import-map-overrides-full></import-map-overrides-full>
複製程式碼

index.html 最終內容如下:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Container</title>
  <base href="/">
  <meta name="importmap-type" content="systemjs-importmap" />
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <script type="systemjs-importmap" src="/assets/import-map.json"></script>
</head>
<body>
  <slb-root></slb-root>
  <import-map-overrides-full></import-map-overrides-full>
</body>
</html>
複製程式碼

細心的小夥伴可能會注意到為我們引入了一個還沒有建立的檔案。

<script type="systemjs-importmap" src="/assets/import-map.json"></script>
複製程式碼

就是上面這行程式碼中的JSON檔案。那麼下一步我們就來建立這個檔案。

建立微前端專案索引檔案

在assets目錄下新建import-map.json檔案,內容如下。

{
    "imports": {
      "app1": "http://localhost:4201/main.js",
      "app2": "http://localhost:4202/main.js"
    }
  }
  
複製程式碼

在demo中我們都是本地服務載入這些檔案,所以這裡的地址都是localhost42014202分別是兩個微前端專案的埠。

建立spa-host component

執行ng g c spa-host

angular-cli 會幫助我們建立一個spa-host component。這個元件會是我們掛載微前端的地方。

修改spa-host component

spa-host.component.html

在html 頁面建立兩個掛載元素。

<div #app1></div>
<div #app2></div>
複製程式碼

掛載點的數量與我們需要掛載的微前端個數一致,在當前demo中我們需要掛載兩個專案,分別為app1和app2。

spa-host.component.ts

先獲取掛載點:

  @ViewChild('app1', { static: true }) private app1: ElementRef;
  @ViewChild('app2', { static: true }) private app2: ElementRef;
複製程式碼

為了上述程式碼能夠執行,我們需要引入依賴。

import { Component, OnInit, ViewChild, ElementRef, OnDestroy } from '@angular/core';
複製程式碼

在獲取掛載點之後,我們就可以將另外的兩個前端專案進行掛在了。

接下來我們需要一個方法來掛載專案。

建立微前端掛載函式

在src下建立service資料夾

建立 single-spa.service.ts

在這裡service中我們需要兩個方法,一個是掛載,一個是解除安裝。

所以這個service的核心方法只有 mountunmount

這裡專案的掛載我們需要依賴single-spa提供的mountRootParcel方法來實現。

mountRootParcel(app, { domElement });
複製程式碼

這個方法接受兩個引數,第一個是需要掛載的專案,第二個是一個options,為我們需要傳的就是這個domElement,也就是我們的掛載點。

這個方法會返回一個掛載的Parcel 物件,內容如下:

  type Parcel = {
    mount(): Promise<null>;
    unmount(): Promise<null>;
    update(customProps: object): Promise<any>;
    getStatus():
      | "NOT_LOADED"
      | "LOADING_SOURCE_CODE"
      | "NOT_BOOTSTRAPPED"
      | "BOOTSTRAPPING"
      | "NOT_MOUNTED"
      | "MOUNTING"
      | "MOUNTED"
      | "UPDATING"
      | "UNMOUNTING"
      | "UNLOADING"
      | "SKIP_BECAUSE_BROKEN"
      | "LOAD_ERROR";
    loadPromise: Promise<null>;
    bootstrapPromise: Promise<null>;
    mountPromise: Promise<null>;
    unmountPromise: Promise<null>;
  };
複製程式碼

從這裡我們可以發現,Parcel是我們解除安裝app的依據。

所以我們在解除安裝應用的時候需要執行的就是Parcel.unmount();

到這裡我們基本清楚我們的掛載和解除安裝的實現了,下面上程式碼:

import { Injectable } from '@angular/core';
import { Parcel, mountRootParcel,  } from 'single-spa';
import { Observable, from } from 'rxjs';
import { mapTo, tap } from 'rxjs/operators';

@Injectable({
  providedIn: 'root'
})
export class SingleSpaService {
  private loadedParcels: {
    [appName: string]: Parcel
  } = {};

  constructor() { }

  mount(appName: string, domElement: HTMLElement): Observable<void> {
    return from(window.System.import(appName))
      .pipe(
        tap(app => {
          this.loadedParcels[appName] = mountRootParcel(app, { domElement });
        }),
        mapTo(null)
      );
  }

  unmount(appName: string): Observable<void> {
    return from(this.loadedParcels[appName].unmount()).pipe(
      tap(() => delete this.loadedParcels[appName]),
      mapTo(null)
    );
  }
}

複製程式碼

在上面的程式碼中我們使用了Window.System.import 方法,但是我們在執行的時候會發現,在window下並不存在System這個物件。

其實這個物件是有的,只是沒有被lint 出來而已,但是我們還是有辦法解決這個難看的報錯的。

src目錄下新建一個types資料夾,然後建立ambient.d.ts檔案,當然換一個你自己喜歡的名字也可以。

內容如下:

import { ParcelConfig } from 'single-spa';

declare global {
  interface Window {
    System: {
      import: (app: string) => Promise<ParcelConfig>;
    };
  }
}

複製程式碼

這樣,我們就不會有報錯了。

tips:

loadedParcels 是我們儲存已經掛載的應用的變數。

建立完成 single-spa service之後我們回到 spa-host元件來完成我們頁面的掛載和解除安裝。

spa-host.component.ts
例項化spa-service
constructor(private service: SingleSpaService) { }
複製程式碼
掛載
this.service.mount('app1', this.app1.nativeElement).subscribe();
this.service.mount('app2', this.app2.nativeElement).subscribe();
複製程式碼

在我們的demo 中,因為是假的專案和固定的掛載數目,所以我將掛載方法寫在了onInit 方法內,但是在實際的專案中掛載方法的執行應該是在你獲取到資料之後。

解除安裝
zip(
    this.service.unmount('app1'),
    this.service.unmount('app2')
).toPromise();
複製程式碼

關於解除安裝的處理如果專案是掛載一次的,那麼都應該在onDestory 的時候統一解除安裝所有掛載應用。如果是頁面動態變化的,那麼解除安裝也會發生在onChange的時候。

完整程式碼

import { Component, OnInit, ViewChild, ElementRef, OnDestroy } from '@angular/core';
import { SingleSpaService } from '../../service/single-spa.service';
import { zip } from 'rxjs';

@Component({
  selector: 'slb-spa-host',
  templateUrl: './spa-host.component.html',
  styleUrls: ['./spa-host.component.scss']
})
export class SpaHostComponent implements OnInit, OnDestroy {

  constructor(private service: SingleSpaService) { }

  @ViewChild('app1', { static: true }) private app1: ElementRef;
  @ViewChild('app2', { static: true }) private app2: ElementRef;


  ngOnInit() {
    this.service.mount('app1', this.app1.nativeElement).subscribe();
    this.service.mount('app2', this.app2.nativeElement).subscribe();
  }

  async ngOnDestroy() {
    await zip(
      this.service.unmount('app1'),
      this.service.unmount('app2')
    ).toPromise();
  }
}

複製程式碼

至此,我們就做完了spa-host component 的全部改動。

我們既然已經建立完這個component,接下來當然是讓它起作用。

檢視app.module.ts

確認 SpaHostComponent已經被引入並宣告完成。如果沒有那就手動完成一下。

引入component

import { SpaHostComponent } from './spa-host/spa-host.component';
複製程式碼

加到declarations 中

  declarations: [
    AppComponent,
    SpaHostComponent
  ],
複製程式碼

完整程式碼:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';

import { SpaHostComponent } from './spa-host/spa-host.component';

@NgModule({
  declarations: [
    AppComponent,
    SpaHostComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule
  ],
  providers: [
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

複製程式碼

修改路由

SpaHostComponent掛在跟路由下

const routes: Routes = [
  {
    path: '',
    component: SpaHostComponent
  }
];
複製程式碼

完整程式碼

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { SpaHostComponent } from './spa-host/spa-host.component';


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

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

複製程式碼

app.component.html

刪除預設新增的內容只剩餘router-outlet

<router-outlet></router-outlet>
複製程式碼

main.js啟動single-spa

在main.js 中新增下列程式碼,啟動single-spa

import * as singleSpa from 'single-spa';

singleSpa.start();
複製程式碼

上面就是全部的container 專案的改動了。

微前端部分

下面我們開始修改微前端專案。在我們demo 裡面兩個微前端專案是完全相同的,所以下面我們以app1來舉例。

載入single-spa

執行命令 ng add single-spa-angular

這條命令會幫我們完成一下內容

  • 安裝 single-spa-angular
  • 建立 src/main.single-spa.ts
  • 建立 src/single-spa/single-spa-props.ts
  • 建立 src/single-spa/asset-url.ts
  • 建立 EmptyRouteComponent並引入到app-routing.module.ts
  • 增加npm script build:single-spaserve:single-spa
  • 建立 extra-webpack.config.js

tips

關於webpack config這部分Angular 的7以及之前版本和8+的處理上不同。

修改埠

上面的命令增加了兩個npm script, 但是裡面的埠號是預設的4200,我們需要修改為我們真正使用的。這裡4200是我們的container的埠號,所以這裡我們使用4201.

將這兩個指令碼修改為:

"build:single-spa": "ng build --prod --deploy-url http://localhost:4201/",
"serve:single-spa": "ng serve --disable-host-check --port 4201 --deploy-url http://localhost:4201/ --live-reload false",
複製程式碼

修改路由

將路由指向我們建立的EmptyRouteComponent,修改路由為如下。

const routes: Routes = [
  {
    path: '**',
    component: EmptyRouteComponent
  }
];
複製程式碼

providers 修改為如下

providers: [{ provide: APP_BASE_HREF, useValue: '/' }]
複製程式碼

完整程式碼:

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { APP_BASE_HREF } from '@angular/common';
import { EmptyRouteComponent } from './empty-route/empty-route.component';

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

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

複製程式碼

修改app.component.html

最後,我們修改一下app.component.html,刪除之前的內容。

修改為

<h1>Mien's first Micro Front-end project</h1>
複製程式碼

這就是為前端部分的全部改動。同樣的我們需要對app2也做同樣的修改。

然後讓我們執行一下看看吧~

告訴我,你也看到了下面的內容對嗎?

Angular 微前端實踐 之 Single-SPA 手把手教程(上)

寫在後面

以上便是Angular 微前端實踐 之 Single-SPA 手把手教程(上) 的全部內容的,本文的下半部分還在整理中,如果感興趣的話請評論告訴我。

對本文中的問題,也歡迎留言提問。

如有錯誤,歡迎指正。

下半部分預告(計劃)

  • 路由處理
  • 依賴抽離
  • 動態掛載
  • SPA功能實現分析
  • 問題回答

另外還有不使用single-spa 的微前端實現,如果這些有人看就再整理一篇文章。

第一次在掘金髮文章,希望小夥伴們多多支援啊。

相關文章