可能是你見過最完善的微前端解決方案

支付寶技術團隊發表於2019-08-27
Techniques, strategies and recipes for building a modern web app with multiple teams using different JavaScript frameworks.—— Micro Frontends

前言

TL;DR

想跳過技術細節直接看怎麼實踐的同學可以拖到文章底部,直接看最後一節。

目前社群有很多關於微前端架構的介紹,但大多停留在概念介紹的階段。而本文會就某一個具體的型別場景,著重介紹微前端架構可以 帶來什麼價值以及 具體實踐過程中需要關注的技術決策,並輔以具體程式碼,從而能真正意義上幫助你構建一個 生產可用的微前端架構系統。

而對於微前端的概念感興趣或不熟悉的同學,可以通過搜尋引擎來獲取更多資訊,如 知乎上的相關內容,本文不再做過多介紹。

兩個月前 Twitter 曾爆發過關於微前端的“熱烈”討論,參與大佬眾多(Dan、Larkin 等),對“事件”本身我們今天不做過多評論(後面可能會寫篇文章來回顧一下),有興趣的同學可以通過這篇文章( https://zendev.com/2019/06/17/microfrontends-good-bad-ugly.html)瞭解一二。

微前端的價值

微前端架構具備以下幾個核心價值:

技術棧無關:主框架不限制接入應用的技術棧,子應用具備完全自主權

獨立開發、獨立部署:子應用倉庫獨立,前後端可獨立開發,部署完成後主框架自動完成同步更新

獨立執行時:每個子應用之間狀態隔離,執行時狀態不共享

微前端架構旨在解決單體應用在一個相對長的時間跨度下,由於參與的人員、團隊的增多、變遷,從一個普通應用演變成一個巨石應用( Frontend Monolith )後,隨之而來的應用不可維護的問題。這類問題在企業級 Web 應用中尤其常見。

針對中後臺應用的解決方案

中後臺應用由於其應用生命週期長(動輒 3+ 年)等特點,最後演變成一個巨石應用的概率往往高於其他型別的 web 應用。而從技術實現角度,微前端架構解決方案大概分為兩類場景:

單例項:即同一時刻,只有一個子應用被展示,子應用具備一個完整的應用生命週期。通常基於 url 的變化來做子應用的切換。

多例項:同一時刻可展示多個子應用。通常使用 Web Components 方案來做子應用封裝,子應用更像是一個業務元件而不是應用。

本文將著重介紹 單例項場景下的微前端架構實踐方案(基於 single-spa),因為這個場景更貼近大部分中後臺應用。

行業現狀

傳統的雲控制檯應用,幾乎都會面臨業務快速發展之後,單體應用進化成巨石應用的問題。為了解決產品研發之間各種耦合的問題,大部分企業也都會有自己的解決方案。筆者於17年底,針對國內外幾個著名的雲產品控制檯,做過這樣一個技術調研:

可能是你見過最完善的微前端解決方案

MPA 方案的優點在於 部署簡單、各應用之間硬隔離,天生具備技術棧無關、獨立開發、獨立部署的特性。缺點則也很明顯,應用之間切換會造成瀏覽器重刷,由於產品域名之間相互跳轉,流程體驗上會存在斷點。

SPA 則天生具備體驗上的優勢,應用直接無重新整理切換,能極大的保證多產品之間流程操作串聯時的流程性。缺點則在於各應用技術棧之間是強耦合的。

那我們有沒有可能將 MPA 和SPA 兩者的優勢結合起來,構建出一個相對完善的微前端架構方案呢?

jsconf china 2016 大會上,ucloud 的同學分享了他們的基於 angularjs 的方案(單頁應用“聯邦制”實踐),裡面提到的 "聯邦制" 概念很貼切,可以認為是早期的基於耦合技術棧的微前端架構實踐。

微前端架構實踐中的問題

可以發現,微前端架構的優勢,正是 MPA 與 SPA 架構優勢的合集。即保證應用具備獨立開發權的同時,又有將它們整合到一起保證產品完整的流程體驗的能力。

這樣一套模式下,應用的架構就會變成:

可能是你見過最完善的微前端解決方案

Stitching layer 作為主框架的核心成員,充當排程者的角色,由它來決定在不同的條件下啟用不同的子應用。因此主框架的定位則僅僅是: 導航路由 + 資源載入框架。

而具體要實現這樣一套架構,我們需要解決以下幾個技術問題:

路由系統及 FutureStat

我們在一個實現了微前端核心的產品中,正常訪問一個子應用的頁面時,可能會有這樣一個鏈路:

可能是你見過最完善的微前端解決方案

由於我們的子應用都是 lazy load 的,當瀏覽器重新重新整理時,主框架的資源會被重新載入,同時非同步 load 子應用的靜態資源,由於此時主應用的路由系統已經啟用,但子應用的資源可能還沒有完全載入完畢,從而導致路由登錄檔裡發現沒有能匹配子應用 /subApp/123/detail 的規則,這時候就會導致跳 NotFound 頁或者直接路由報錯。

這個問題在所有 lazy load 方式載入子應用的方案中都會碰到,早些年前 angularjs 社群把這個問題統一稱之為 Future State。

解決的思路也很簡單,我們需要設計這樣一套路由機制:

主框架配置子應用的路由為subApp: { url: '/subApp/**', entry:'./subApp.js' },則當瀏覽器的地址為 /subApp/abc 時,框架需要先載入 entry 資源,待 entry 資源載入完畢,確保子應用的路由系統註冊進主框架之後後,再去由子應用的路由系統接管 url change 事件。同時在子應用路由切出時,主框架需要觸發相應的destroy 事件,子應用在監聽到該事件時,呼叫自己的解除安裝方法解除安裝應用,如 React 場景下 destroy = () => ReactDOM.unmountAtNode(container) 。

要實現這樣一套機制,我們可以自己去劫持 url change 事件從而實現自己的路由系統,也可以基於社群已有的 ui router library,尤其是 react-router 在 v4 之後實現了 Dynamic Routing 能力,我們只需要複寫一部分路由發現的邏輯即可。這裡我們推薦直接選擇社群比較完善的相關實踐single-spa。

App Entry

解決了路由問題後,主框架與子應用整合的方式,也會成為一個需要重點關注的技術決策。

1. 構建時組合 VS 執行時組合

微前端架構模式下,子應用打包的方式,基本分為兩種:

可能是你見過最完善的微前端解決方案

兩者的優缺點也很明顯:

可能是你見過最完善的微前端解決方案

很顯然,要實現真正的技術棧無關跟獨立部署兩個核心目標,大部分場景下我們需要使用執行時載入子應用這種方案。

2. JS Entry vs HTMLEntry

在確定了執行時載入的方案後,另一個需要決策的點是,我們需要子應用提供什麼形式的資源作為渲染入口?

JS Entry 的方式通常是子應用將資源打成一個entry script,比如 single-spa 的 example 中的方式。但這個方案的限制也頗多,如要求子應用的所有資源打包到一個 js bundle 裡,包括 css、圖片等資源。除了打出來的包可能體積龐大之外的問題之外,資源的並行載入等特性也無法利用上。

HTML Entry 則更加靈活,直接將子應用打出來 HTML作為入口,主框架可以通過 fetch html 的方式獲取子應用的靜態資源,同時將 HTML document 作為子節點塞到主框架的容器中。這樣不僅可以極大的減少主應用的接入成本,子應用的開發方式及打包方式基本上也不需要調整,而且可以天然的解決子應用之間樣式隔離的問題(後面提到)。想象一下這樣一個場景:

可能是你見過最完善的微前端解決方案

可能是你見過最完善的微前端解決方案

如果是 JS Entry 方案,主框架需要在子應用載入之前構建好相應的容器節點(比如這裡的 "#root" 節點),不然子應用載入時會因為找不到 container 報錯。但問題在於,主應用並不能保證子應用使用的容器節點為某一特定標記元素。而 HTML Entry 的方案則天然能解決這一問題,保留子應用完整的環境上下文,從而確保子應用有良好的開發體驗。

HTML Entry 方案下,主框架註冊子應用的方式則變成:

可能是你見過最完善的微前端解決方案

本質上這裡 HTML 充當的是應用靜態資源表的角色,在某些場景下,我們也可以將 HTML Entry 的方案優化成 Config Entry,從而減少一次請求,如:

可能是你見過最完善的微前端解決方案

總結一下:

可能是你見過最完善的微前端解決方案

3. 模組匯入

微前端架構下,我們需要獲取到子應用暴露出的一些鉤子引用,如 bootstrap、mount、unmout 等(參考 single-spa),從而能對接入應用有一個完整的生命週期控制。而由於子應用通常又有整合部署、獨立部署兩種模式同時支援的需求,使得我們只能選擇 umd 這種相容性的模組格式打包我們的子應用。如何在瀏覽器執行時獲取遠端指令碼中匯出的模組引用也是一個需要解決的問題。

通常我們第一反應的解法,也是最簡單的解法就是與子應用與主框架之間約定好一個全域性變數,把匯出的鉤子引用掛載到這個全域性變數上,然後主應用從這裡面取生命週期函式。

這個方案很好用,但是最大的問題是,主應用與子應用之間存在一種強約定的打包協議。那我們是否能找出一種鬆耦合的解決方案呢?

很簡單,我們只需要走 umd 包格式中的 global export 方式獲取子應用的匯出即可,大體的思路是通過給 window變數打標記,記住每次最後新增的全域性變數,這個變數一般就是應用 export 後掛載到 global 上的變數。實現方式可以參考 systemjs global import,這裡不再贅述。

應用隔離

微前端架構方案中有兩個非常關鍵的問題,有沒有解決這兩個問題將直接標誌你的方案是否真的生產可用。比較遺憾的是此前社群在這個問題上的處理都會不約而同選擇”繞道“的方式,比如通過主子應用之間的一些預設約定去規避衝突。而今天我們會嘗試從純技術角度,更智慧的解決應用之間可能衝突的問題。

1. 樣式隔離

由於微前端場景下,不同技術棧的子應用會被整合到同一個執行時中,所以我們必須在框架層確保各個子應用之間不會出現樣式互相干擾的問題。

Shadow DOM?

針對 "Isolated Styles" 這個問題,如果不考慮瀏覽器相容性,通常第一個浮現到我們腦海裡的方案會是 Web Components。基於 Web Components 的 Shadow DOM 能力,我們可以將每個子應用包裹到一個 Shadow DOM 中,保證其執行時的樣式的絕對隔離。

但 Shadow DOM 方案在工程實踐中會碰到一個常見問題,比如我們這樣去構建了一個在 Shadow DOM 裡渲染的子應用:

可能是你見過最完善的微前端解決方案

由於子應用的樣式作用域僅在 shadow 元素下,那麼一旦子應用中出現執行時越界跑到外面構建 DOM 的場景,必定會導致構建出來的 DOM 無法應用子應用的樣式的情況。

比如 sub-app 裡呼叫了antd modal 元件,由於 modal 是動態掛載到document.body 的,而由於 Shadow DOM 的特性 antd 的樣式只會在 shadow 這個作用域下生效,結果就是彈出框無法應用到 antd 的樣式。解決的辦法是把 antd 樣式上浮一層,丟到主文件裡,但這麼做意味著子應用的樣式直接洩露到主文件了。gg...

CSS Module? BEM?

社群通常的實踐是通過約定 css 字首的方式來避免樣式衝突,即各個子應用使用特定的字首來命名 class,或者直接基於 css module 方案寫樣式。對於一個全新的專案,這樣當然是可行,但是通常微前端架構更多的目標是解決存量/遺產 應用的接入問題。很顯然遺產應用通常是很難有動力做大幅改造的。

最主要的是,約定的方式有一個無法解決的問題,假如子應用中使用了三方的元件庫,三方庫在寫入了大量的全域性樣式的同時又不支援定製化字首?比如 a 應用引入了 antd 2.x,而b 應用引入了 antd 3.x,兩個版本的 antd 都寫入了全域性的 .menu class ,但又彼此不相容怎麼辦?

Dynamic Stylesheet !

解決方案其實很簡單,我們只需要在應用切出/解除安裝後,同時解除安裝掉其樣式表即可,原理是瀏覽器會對所有的樣式表的插入、移除做整個 CSSOM 的重構,從而達到 插入、解除安裝 樣式的目的。這樣即能保證,在一個時間點裡,只有一個應用的樣式表是生效的。

上文提到的 HTML Entry 方案則天生具備樣式隔離的特性,因為應用解除安裝後會直接移除去 HTML 結構,從而自動移除了其樣式表。

比如 HTML Entry 模式下,子應用載入完成的後的 DOM 結構可能長這樣:

可能是你見過最完善的微前端解決方案

當子應用被替換或解除安裝時,subApp節點的innerHTML 也會被複寫,//alipay.com/subapp.css 也就自然被移除樣式也隨之解除安裝了。

2. JS 隔離

解決了樣式隔離的問題後,有一個更關鍵的問題我們還沒有解決:如何確保各個子應用之間的全域性變數不會互相干擾,從而保證每個子應用之間的軟隔離?

這個問題比樣式隔離的問題更棘手,社群的普遍玩法是給一些全域性副作用加各種字首從而避免衝突。但其實我們都明白,這種通過團隊間的“口頭”約定的方式往往低效且易碎,所有依賴人為約束的方案都很難避免由於人的疏忽導致的線上 bug。那麼我們是否有可能打造出一個好用的且完全無約束的 JS 隔離方案呢?

針對 JS 隔離的問題,我們獨創了一個執行時的 JS 沙箱。簡單畫了個架構圖:

可能是你見過最完善的微前端解決方案

即在應用的 bootstrap 及 mount 兩個生命週期開始之前分別給全域性狀態打下快照,然後當應用切出/解除安裝時,將狀態回滾至 bootstrap 開始之前的階段,確保應用對全域性狀態的汙染全部清零。而當應用二次進入時則再恢復至 mount 前的狀態的,從而確保應用在 remount 時擁有跟第一次 mount 時一致的全域性上下文。

當然沙箱裡做的事情還遠不止這些,其他的還包括一些對全域性事件監聽的劫持等,以確保應用在切出之後,對全域性事件的監聽能得到完整的解除安裝,同時也會在 remount 時重新監聽這些全域性事件,從而模擬出與應用獨立執行時一致的沙箱環境。

螞蟻金服微前端落地實踐

自去年年底伊始,我們便嘗試基於微前端架構模式,構建出一套全鏈路的面向中後臺場景的產品接入平臺,目的是解決不同產品之間整合困難、流程割裂的問題,希望接入平臺後的應用,不論使用哪種技術棧,在執行時都可以通過自定義配置,實現不同應用之間頁面級別的自由組合,從而生成一個千人千面的個性化控制檯。

目前這套平臺已在螞蟻生產環境執行半年多,同時接入了多個產品線的 40+ 應用、4+ 不同型別的技術棧。過程中針對大量微前端實踐中的問題,我們總結出了一套完整的解決方案:

可能是你見過最完善的微前端解決方案

在內部得到充分的技術驗證和線上考驗之後,我們決定將這套解決方案開源出來!

qiankun - 一套完整的微前端解決方案

https://github.com/umijs/qiankun

取名 qiankun,意為統一。我們希望通過 qiankun 這種技術手段,讓你能很方便的將一個巨石應用改造成一個基於微前端架構的系統,並且不再需要去關注各種過程中的技術細節,做到真正的開箱即用和生產可用。

對於umi使用者我們也提供了配套的qiankun外掛,以便於 umi 應用能幾乎零成本的接入 qiankun:

@umijs/plugin-qiankun

https://github.com/umijs/umi-plugin-qiankun/

最後歡迎大家點贊使用提出寶貴的意見。

Maybe the most complete micro-frontends solution youever met.

可能是你見過的最完善的微前端架構解決方案。

活動推薦

可能是你見過最完善的微前端解決方案

一年一度的杭州雲棲大會就要來了!在大會第三天,螞蟻金服將首次公開硬核金融技術,包括金融級雲原生理念、共享智慧、安全計算、融合計算等,還有圖計算、SQLFlow等技術實踐,歡迎關注。

掃描二維碼或點選閱讀原文檢視詳情~


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69904796/viewspace-2655109/,如需轉載,請註明出處,否則將追究法律責任。

相關文章