使用 Angular 打造微前端架構的 ToB 企業級應用

Worktile丨智簡研發發表於2020-02-08

這篇文章其實已經準備了11個月了,因為雖然我們年初就開始使用 Angular 的微前端架構,但是產品一直沒有正式釋出,無法通過生產環境實踐驗證可行性,11月16日我們的產品正式灰度釋出,所以是時候分享一下我們在使用 Angular 微前端這條路上的心得(踩過的坑)了額,希望和 Angular 社群一起成長一起進步,如果你對微前端有一定的瞭解並且已經在專案中嘗試了可以忽略前面的章節。

什麼是微前端

微前端這個詞這兩年很頻繁的出現在大家的視野中,最早提出這個概念的應該是在 ThoughtWork 的技術雷達,主要是把微服務的概念引入到了前端,讓前端的多個模組或者應用解耦,做到讓前端的子模組獨立倉儲,獨立執行,獨立部署。

那麼微前端和微服務到底有什麼區別呢?

下面這張圖是微服務的示意圖,微服務主要是業務模組按照一定的規則拆分,獨立開發,獨立部署,部署後通過 Nginx 做路由轉發,微服務的難點是需要考慮多個模組之間如何呼叫的問題,以及鑑權,日誌,甚至加入閘道器層

image.png

對於微服務來說,模組分開解藕基本就完事了,但是微前端不一樣,前端應用在執行時卻是一個整體,需要聚合,甚至還需要互動,通訊。

image.png

為什麼需要微前端(Micro Front-end)

  1. 系統模組增多,單體應用變得臃腫,開發效率低下,構建速度變慢;
  2. 人員擴大,需要多個前端團隊獨立開發,獨立部署,如果都在一個倉儲中開發會帶來一些列問題;
  3. 解決遺留系統,新模組需要使用最新的框架和技術,舊系統還繼續使用。

微前端的幾種方案對比

方式 描述 優點 缺點 難度係數
路由轉發 路由轉發嚴格意義上不屬於微前端,多個子模組之間共享一個導航即可 簡單,易實現 體驗不好,切換應用整個頁面重新整理 ?
巢狀 iframe 每個子應用一個 iframe 巢狀 應用之間自帶沙箱隔離 重複載入指令碼和樣式 ??
構建時組合 獨立倉儲,獨立開發,構建時整體打包,合併應用 方便依賴管理,抽取公共模組 無法獨立部署,技術棧,依賴版本必須統一 ??
執行時組合 每個子應用獨立構建,執行時由主應用負責應用管理,載入,啟動,解除安裝,通訊機制 良好的體驗,真正的獨立開發,獨立部署 複雜,需要設計載入,通訊機制,無法做到徹底隔離,需要解決依賴衝突,樣式衝突問題 ???
Web Components 每個子應用需要使用 Web Components 技術編寫元件或者使用框架生成 面向未來 不成熟,需要踩坑 ???

上述只是簡單列舉了幾種實現方式的對比,當然這些方案也不是互斥的,選擇哪種方案取決你的業務場景是什麼,以下幾個前提條件對於技術選型至關重要:

  • 是否為 SPA 單體應用?
  • 技術棧是否統一,需要支援跨框架呼叫嗎?
  • 是否需要應用間徹底隔離?

我們是做企業級 SaaS 平臺的,肯定是 SPA 單體應用,技術棧都是 Angular,應用之間不需要徹底隔離,反而需要共享通用樣式和元件,避免重複載入。

所以選擇的是:執行時組合 方案。

Worktile 的微前端技術選型之路

目前市面上的微前端解決方案並不多,關注度和成熟度最高的應該就是 single-spa

國內也有很多團隊都有自己的微前端框架,比如開源了的基於 single-spa 的 qiankun - 可能是你見過最完善的微前端解決方案 , 還有 phodal 的 mooa 以及無數內部的解決方案(最近阿里飛冰也開源 了面向大型工作臺的微前端解決方案 icestark,只支援 React 和 Vue)

我們在做技術選型的時候首要考慮的就是 single-spamooa, single-spa 成熟度應該最高,示例文件很完善,mooa 為 Angular 打造的主從結構的微前端框架,和我們的業務和技術符合度最高,研究一段時間後最終我們還是選擇了自研一套符合自己的微前端庫(因為比較簡單,不敢稱之為框架),主要是因為我們的業務有以下幾個需求在以上的框架中不滿足或者說很難滿足, 甚至需要高度定製。

  • 產品是主從結構的,Portal 包含左側導航,訊息通知以及子應用管理
  • 需要在多個子應用之間通訊,主應用或者某個子應用需要開啟其他子應用的詳情頁或者路由跳轉
  • 子應用A的某個頁面中可能會載入子應用B的某個元件
  • 基於以上2個特性,所以需要提供並存模式,即當前顯示的雖然是 B 應用,但是要保證 A 應用正常可以呼叫,如果銷燬了就無法被其他應用呼叫
  • 需要提供預載入功能
  • 子應用的樣式也需要獨立載入
  • 路由,不管是在主應用還是子應用,路由體驗要和單體應用一致

我執行了 single-spamooa 的示例,主要是一些簡單的渲染展示,一旦需要滿足以上一些特性還是需要修改很多東西,mooa 實現應該還是比較全面也比較適合我們的,但是它的示例中路由有一些問題,頁面跳轉了但是路由沒有變,打包已經拋棄了 Angular CLI,程式碼層面參考了 single-spa 的很多東西,API 可以再度簡化,既然是為 Angular 定製的,我覺得應該以 Angular 的方式實現更符合,當然不排除作者想要後期支援 React 和 Vue,不可否認的是 phodal 本人對於微前端的理解的確很深,寫的很多不錯的微前端的文章 microfrontends, 甚至出過唯一一本微前端的書《前端架構 - 從入門到微前端》,我在實現微前端的時候也借鑑參考了它的很多思想和實現方式。

使用 Angular 打造微前端應用

使用 Angular 實現微前端其實比 React 和 Vue 更加困難,因為 Angular 包含 AOT 編譯,Module,Zone.js ,Service 共享等等問題,React 和 Vue 直接子應用 JS 載入渲染頁面某個區域即可。

選擇動態載入模組後編譯還是載入整個應用

在 Angular 單體應用中,必須有一個根模組 AppModule,然後是每個特性模組 FeatureModule,每個特性模組可以有自己的路由,當然可以使用路由的惰性載入這些特性模組,但是在微前端架構中,每個子模組都是獨立倉儲的,如何在執行時把子模組載入到根模組就是一個技術選擇難點。

  1. 第一種方案就是把每個子模組當作一個特性模組,然後在打包的時候隨著主應用一起打包編譯,這樣是最簡單的,但是這個無法做到獨立部署,而且每次部署都是全量更新
  2. 第二種方案還是把子模組當作一個特性模組,在主應用通過 SystemJsNgModuleLoader 載入子模組,然後編譯執行,(注:SystemJsNgModuleLoader 在新版本已經遺棄)
  3. 第三種方案就是每個子模組是一個獨立的應用,和主應用一樣,有自己的 AppModule, 路由,選擇這種方案就需要處理多個應用路由同步的問題,還有就是 Angular 目前的依賴庫是無法直接執行時使用的,需要每個子應用一起編譯,無法做到公共依賴庫抽取(可能有其他方案)
  4. 第四種方案就是把所有的子模組編譯成 Web Components 使用,我暫時沒有深入研究過,選擇這種方案直接使用元件肯定沒有問題,但是使用 Web Components 後路由如何處理我不知道。

我們最終選擇了最複雜的第三種方案,因為新的 Ivy 渲染引擎正式釋出後會解決第三方依賴庫執行時直接使用的問題,至於 Web Components 沒有深入研究,因為目前第三種方案執行挺好的。

image.png

應用註冊,載入,銷燬機制

這個是所有微前端應用的基礎和核心,但是我覺得反而是最簡單容易實現的,主要要做的就是:

  • 提供靜態資源動態載入功能
  • 配置好子應用的規則,包含:應用名稱,路由字首,靜態資原始檔
this.planet.registerApps([
   {
       name: 'app1',
       hostParent: '#app-host-container',
       routerPathPrefix: '/app1',
       selector: 'app1-root',
       scripts: ['/static/app1/main.js'],
       styles: ['/static/app1/styles.css']
   },
   // ...
]);
  • 應用載入:根據當前頁面的 URL 找到對應的子應用,然後載入應用的靜態資源,呼叫預定義好的啟動函式直接啟動應用即可,在 Angular 中就是啟動根模組 platformBrowserDynamic().bootstrapModule(AppModule)

  • 應用的預載入:當前應用渲染完畢會預載入其他應用,並啟動,並不會顯示

  • 銷燬應用使用 appModuleRef.destroy();

按照上述的步驟處理簡單的場景基本就足夠了,但是如果希望應用共存就不一樣了,我們的做法是把 bootstrapped 狀態隱藏起來,而不是銷燬,只有 Active 狀態的應用才會顯示在當前頁面中。

路由

因為選擇了每個子應用是獨立的 Angular 應用,同時還可以共存多個子應用,那麼多個應用的路由同步,跳轉就成了難題,而且還要支援應用之間路由跳轉,應用之間通訊,元件渲染等場景。我認為路由是我們在使用微前端架構中遇到的最複雜的問題。

目前我們的做法是主應用的路由中把所有子應用的路由都配置上,元件設定成 EmptyComponent , 這樣在切換到子應用路由的時候,主應用會匹配空路由狀態,不會報錯,每個子應用需要新增一個通用的空路由 EmptyComponent

{
        path: '**',
        component: EmptyComponent
}

除此之外還需要在切換路由的時候同步更新其他應用的路由,否則會造成每個應用的當前路由狀態不一致,切換的時候會有跳轉不成功的問題。

  • 主應用路由切換時,找到所有當前啟動的子應用,使用 router.navigateByUrl 同步跳轉
  • 子應用路由切換時,同步主應用路由,同時同步其他啟動狀態的子路由

我看了很多微前端框架包括 single-spa,基本上路由這一塊沒有處理,完全交給開發者自己去填坑,single-spa 的 Angular 示例基本就是切換就銷燬了 Angular 應用,因為沒有並存,所以也就不需要處理多個應用路由的問題了,當然它作為和框架無關的微前端解決方案,也只能做到這一步了吧。

這個等 Ivy 渲染引擎正式釋出後,可以把子應用編譯成直接可以執行的模組,整個應用如果只有一個路由會簡化很多。

共享全域性服務

對於一些全域性的資料我們一般會儲存在服務中,然後子應用可以直接共享,比如:當前登入使用者多語言服務等,簡單的資料共享可以直接掛載在 window 上即可,為了讓每個子應用使用全域性服務和模組內服務一致,我們通過在主應用中例項化這些服務,但後在每個子應用的 AppModule 中使用 provide 重新設定主應用的 value,當然這些不需要子應用的業務開發人員自己設定,已經封裝到業務元件庫中全域性配置好了。

{
  provide: AppContext,
  useValue: window.portalAppContext
}

應用間通訊

應用間通訊有很多中方式,我們底層使用瀏覽器的 CustomEvent ,在這之上封裝了 GlobalEventDispatcher 服務做通訊(當然你也可以使用在 window 物件上掛載全域性物件實現),場景就是某個子應用要開啟另外一個子應用的詳情頁

// App1
globalEventDispatcher.dispatch('open-task-detail', { taskId: 'xxx' });

// App2
globalEventDispatcher.register('open-task-detail').subscribe((payload) => {
    // open dialog of task detail
});

應用間元件互相呼叫

在我們的敏捷開發子產品中,一個使用者故事的詳情頁,需要顯示測試管理應用的關聯的測試用例和測試執行情況,那麼這個測試用例列表元件放在 測試管理 子應用是最合適的,那麼使用者故事詳情頁肯定在敏捷開發應用中,如何載入測試管理應用的某個元件就是一個問題。

這一塊使用了 Angular CDK 中的 DomPortalOutlet 動態建立元件,並指定渲染在某個容器中,這樣保證了這個動態元件的建立還是 測試管理 模組的,只是渲染在了其他應用中而已。

const portalOutlet = new DomPortalOutlet(container, componentFactoryResolver, appRef, injector);
const testCasesPortalComponent = new ComponentPortal(TestCasesComponent, null);
portalOutlet.attachComponentPortal(testCasesPortalComponent);

工程化

使用微前端開發應用不僅僅要解決 Angular 的技術問題,還有一些開發,協作,部署等工程化的問題需要解決,比如:

  • 公共依賴庫抽取
  • 本地如何啟動開發
  • 如何打包部署,生成的 hash 資原始檔如何通知主應用

應用公共依賴庫抽取避免類庫重複打包,減少打包體積,這就需要自定義 Webpack Config 實現,起初我們是完全自定義 Webpack 打包 Angular 應用,一旦這麼做就會失去很多 CLI 提供的方便功能,偶爾發現了一個類庫 angular-builders ,他的作用其實就是在 Angular CLI 生成的 Webpack Config 中合併自定義的 Webpack Config,這樣就做到了只需要寫少量的自定義配置,其餘的還是完全使用 CLI 的打包功能,差一點就要自己寫一個類似的工具了。
在主應用中把需要公共依賴包放入 scripts 中,然後在子應用中配置 externals,比如:moment lodash rxjs 這樣的類庫。

const webpackExtraConfig = {
    optimization: {
        runtimeChunk: false // 子應用一定要設定 false,否則會報錯
    },
    externals: {
        moment: 'moment',
        lodash: '_',
        rxjs: 'rxjs',
       'rxjs/operators': 'rxjs.operators',
        highcharts: 'Highcharts'
    },
    devtool: options.isDev ? 'eval-source-map' : '',
    plugins: [new WebpackAssetsManifest()]
};
return webpackExtraConfig;

WebpackAssetsManifest 主要作用是生成 manifest.json 檔案,目的就是讓生成的 Hash 文檔案的對應關係,讓主應用載入正確的資原始檔。

本地開發配置 proxy.conf.js 代理訪問每個子應用的資原始檔,同時包括 API 呼叫。

基於 Angular 的微前端庫 ngx-planet

以上是我們在使用 Angular 打造微前端應用遇到的一些技術難點和我們的解決方案,調研後最終選擇自研一套符合我們業務場景的,同時只為 Angular 量身打造的微前端庫。

Github 倉儲地址:ngx-planet
線上 Demo:http://planet.ngnice.com

不敢說 “你見過最完善的微前端解決方案” ,但至少是 Angular 社群目前我見過完全可用於生產環境的方案,API 符合 Angular Style ,國內很多大廠做微前端方案基本都忽略了 Angular 這個框架的存在,Worktile 四個研發子產品完全基於 ngx-planet 打造開發,經過接近一年的踩坑和實踐,基本完全可用。

image.png

希望 Angular 社群可以多一些微前端的解決方案,一起進步,我們的方案肯定也存在很多問題,也歡迎大家提出改進的建議和吐槽,我們也將繼續在 Angular 微前端的路上繼續深耕下去,如果你正在尋找 Angular 的微前端類庫,不妨試試 ngx-planet。

將來會調研在 Ivy 渲染引擎下的優化和改進方案。

本文作者:Worktile高階工程師 徐海峰

文章來源:Worktile技術部落格

歡迎訪問交流更多關於技術及協作的問題。

文章轉載請註明出處。

相關文章