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

原文鏈接:https://zhuanlan.zhihu.com/p/...

Techniques, strategies and recipes for building a modern web app with multiple teams using different JavaScript frameworks. — Micro Frontends

前言

TL;DR

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

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

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

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

微前端的價值

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

  • 技術棧無關
    主框架不限制接入應用的技術棧,子應用具備完全自主權
  • 獨立開發、獨立部署
    子應用倉庫獨立,前後端可獨立開發,部署完成後主框架自動完成同步更新
  • 獨立運行時
    每個子應用之間狀態隔離,運行時狀態不共享

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

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

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

  • 單實例:即同一時刻,只有一個子應用被展示,子應用具備一個完整的應用生命週期。通常基於 url 的變化來做子應用的切換。
  • 多實例:同一時刻可展示多個子應用。通常使用 Web Components 方案來做子應用封裝,子應用更像是一個業務組件而不是應用。

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

行業現狀

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

產品 架構(截止 2017-12) 實現技術
google cloud 純 SPA 主 portal angularjs,部分頁面 angular(ng2)。
aws 純 MPA 架構 首頁基於 angularjs。各系統獨立域名。
七牛 SPA & MPA 混合架構 入口 dashboard 及 個人中心模塊爲 spa,使用同一 portal 模塊(AngularJs(1.5.10) + webpack)。其他模塊自治,或使用不同版本 portal,或使用其他技術棧。
又拍雲 純 SPA 架構 基於 angularjs 1.6.6 + ui-bootstrap。控制檯內容較簡單。
ucloud 純 SPA 架構 angularjs 1.3.12

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

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

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

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

微前端架構實踐中的問題

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

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

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

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

路由系統及 Future State

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

由於我們的子應用都是 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

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

構建時組合 VS 運行時組合

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

方案 特點
構建時 子應用通過 Package Registry (可以是 npm package,也可以是 git tags 等其他方式) 的方式,與主應用一起打包發佈。
運行時 子應用自己構建打包,主應用運行時動態加載子應用資源。

兩者的優缺點也很明顯:

方案 優點 缺點
構建時 主應用、子應用之間可以做打包優化,如依賴共享等 子應用與主應用之間產品工具鏈耦合。工具鏈也是技術棧的一部分。
子應用每次發佈依賴主應用重新打包發佈
運行時 主應用與子應用之間完全解耦,子應用完全技術棧無關 會多出一些運行時的複雜度和 overhead

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

JS Entry vs HTML Entry

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

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

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

<!-- 子應用 index.html -->
<script src="//unpkg/antd.min.js"></script>
<body>
  <main id="root"></main>
</body>
// 子應用入口
ReactDOM.render(<App/>, document.getElementById('root'))

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

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

framework.registerApp('subApp1', { entry: '//abc.alipay.com/index.html'})

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

framework.registerApp('subApp1', { html: '', scripts: ['//abc.alipay.com/index.js'], css: ['//abc.alipay.com/index.css']})

總結一下:

App Entry 優點 缺點
HTML Entry 1. 子應用開發、發佈完全獨立
2. 子應用具備與獨立應用開發時一致的開發體驗
1. 多一次請求,子應用資源解析消耗轉移到運行時
2. 主子應用不處於同一個構建環境,無法利用 bundler 的一些構建期的優化能力,如公共依賴抽取等
JS Entry 主子應用使用同一個 bundler,可以方便做構建時優化 1. 子應用的發佈需要主應用重新打包
2. 主應用需爲每個子應用預留一個容器節點,且該節點 id 需與子應用的容器 id 保持一致
3. 子應用各類資源需要一起打成一個 bundle,資源加載效率變低

模塊導入

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

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

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

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

應用隔離

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

樣式隔離

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

Shadow DOM?

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

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

const shadow = document.querySelector('#hostElement').attachShadow({mode: 'open'});
shadow.innerHTML = '<sub-app>Here is some new text</sub-app><link rel="stylesheet" href="//unpkg.com/antd/antd.min.css">';

由於子應用的樣式作用域僅在 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 結構可能長這樣:

<html>
  
  <body>
    <main id="subApp">
      // 子應用完整的 html 結構
      <link rel="stylesheet" href="//alipay.com/subapp.css">
      <div id="root">....</div>
    </main>
  </body>
  
</html>

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

JS 隔離

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

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

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

架構圖 (8)

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

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

螞蟻的微前端落地實踐

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

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

圖片.png

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

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

https://github.com/umijs/qiankun

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

對於 umi 用戶我們也提供了配套的 qiankun 插件 @umijs/plugin-qiankun ,以便於 umi 應用能幾乎零成本的接入 qiankun。

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

Maybe the most complete micro-frontends solution you ever met🧐.

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章