也聊聊微前端(上)

DevUI團隊發表於2020-04-06
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微前端改造過程去深入探討,講述如何自研一個企業級微前端解決方案。

加入我們

我們是DevUI團隊,歡迎來這裡和我們一起打造優雅高效的人機設計/研發體系。招聘郵箱:muyang2@huawei.com。

文/DevUI myzhibie

往期文章推薦

《敏捷設計,高效協同,凸顯設計端雲協同價值》

《現代富文字編輯器Quill的模組化機制》


相關文章