Techniques, strategies and recipes for building a modern web app with multiple teams using different JavaScript frameworks. — Micro Frontendscss
TL;DRhtml
想跳過技術細節直接看怎麼實踐的同窗能夠拖到文章底部,直接看最後一節。前端
目前社區有不少關於微前端架構的介紹,但大多停留在概念介紹的階段。而本文會就某一個具體的類型場景,着重介紹微前端架構能夠帶來什麼價值以及具體實踐過程當中須要關注的技術決策,並輔以具體代碼,從而能真正意義上幫助你構建一個生產可用的微前端架構系統。react
而對於微前端的概念感興趣或不熟悉的同窗,能夠經過搜索引擎來獲取更多信息,如 知乎上的相關內容, 本文再也不作過多介紹。webpack
兩個月前 Twitter 曾爆發過關於微前端的「熱烈」討論,參與大佬衆多(Dan、Larkin 等),對「事件」自己咱們今天不作過多評論(後面可能會寫篇文章來回顧一下),有興趣的同窗能夠經過這篇文章瞭解一二。git
微前端架構具有如下幾個核心價值:angularjs
微前端架構旨在解決單體應用在一個相對長的時間跨度下,因爲參與的人員、團隊的增多、變遷,從一個普通應用演變成一個巨石應用(Frontend Monolith)後,隨之而來的應用不可維護的問題。這類問題在企業級 Web 應用中尤爲常見。github
中後臺應用因爲其應用生命週期長(動輒 3+ 年)等特色,最後演變成一個巨石應用的機率每每高於其餘類型的 web 應用。而從技術實現角度,微前端架構解決方案大概分爲兩類場景:web
本文將着重介紹單實例場景下的微前端架構實踐方案(基於 single-spa),由於這個場景更貼近大部分中後臺應用。npm
傳統的雲控制檯應用,幾乎都會面臨業務快速發展以後,單體應用進化成巨石應用的問題。爲了解決產品研發之間各類耦合的問題,大部分企業也都會有本身的解決方案。筆者於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 做爲主框架的核心成員,充當調度者的角色,由它來決定在不一樣的條件下激活不一樣的子應用。所以主框架的定位則僅僅是:導航路由 + 資源加載框架。
而具體要實現這樣一套架構,咱們須要解決如下幾個技術問題:
咱們在一個實現了微前端內核的產品中,正常訪問一個子應用的頁面時,會有這樣一個鏈路:
graph LR
A[訪問 //app.alipay.com] --> B(點擊導航中的子產品)
B --> C[//app.alipay.com/subApp]
C --> D[subApp 渲染並默認 redirect 到 list 頁]
D --> E[//app.alipay.com/subApp/list]
E --> F[查看列表中某一項信息]
F --> G[瀏覽器 url 變爲 //app.alipay.com/subApp/:id/detail]
複製代碼
此時瀏覽器的地址多是 https://app.alipay.com/subApp/123/detail
,想象一下,此時咱們手動刷新一下瀏覽器,會發生什麼狀況?
因爲咱們的子應用都是 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。
解決了路由問題後,主框架與子應用集成的方式,也會成爲一個須要重點關注的技術決策。
微前端架構模式下,子應用打包的方式,基本分爲兩種:
方案 | 特色 |
---|---|
構建時 | 子應用經過 Package Registry (能夠是 npm package,也能夠是 git tags 等其餘方式) 的方式,與主應用一塊兒打包發佈。 |
運行時 | 子應用本身構建打包,主應用運行時動態加載子應用資源。 |
二者的優缺點也很明顯:
方案 | 優勢 | 缺點 |
---|---|---|
構建時 | 主應用、子應用之間能夠作打包優化,如依賴共享等 | 子應用與主應用之間產品工具鏈耦合。工具鏈也是技術棧的一部分。 子應用每次發佈依賴主應用從新打包發佈 |
運行時 | 主應用與子應用之間徹底解耦,子應用徹底技術棧無關 | 會多出一些運行時的複雜度和 overhead |
很顯然,要實現真正的技術棧無關跟獨立部署兩個核心目標,大部分場景下咱們須要使用運行時加載子應用這種方案。
在肯定了運行時載入的方案後,另外一個須要決策的點是,咱們須要子應用提供什麼形式的資源做爲渲染入口?
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,這裏再也不贅述。
微前端架構方案中有兩個很是關鍵的問題,有沒有解決這兩個問題將直接標誌你的方案是否真的生產可用。比較遺憾的是此前社區在這個問題上的處理都會不約而同選擇」繞道「的方式,好比經過主子應用之間的一些默認約定去規避衝突。而今天咱們會嘗試從純技術角度,更智能的解決應用之間可能衝突的問題。
因爲微前端場景下,不一樣技術棧的子應用會被集成到同一個運行時中,因此咱們必須在框架層確保各個子應用之間不會出現樣式互相干擾的問題。
針對 "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 前綴的方式來避免樣式衝突,即各個子應用使用特定的前綴來命名 class,或者直接基於 css module 方案寫樣式。對於一個全新的項目,這樣固然是可行,可是一般微前端架構更多的目標是解決存量/遺產 應用的接入問題。很顯然遺產應用一般是很難有動力作大幅改造的。
最主要的是,約定的方式有一個沒法解決的問題,假如子應用中使用了三方的組件庫,三方庫在寫入了大量的全局樣式的同時又不支持定製化前綴?好比 a 應用引入了 antd 2.x,而 b 應用引入了 antd 3.x,兩個版本的 antd 都寫入了全局的 .menu class
,但又彼此不兼容怎麼辦?
解決方案其實很簡單,咱們只須要在應用切出/卸載後,同時卸載掉其樣式表便可,原理是瀏覽器會對全部的樣式表的插入、移除作整個 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
也就天然被移除樣式也隨之卸載了。
解決了樣式隔離的問題後,有一個更關鍵的問題咱們尚未解決:如何確保各個子應用之間的全局變量不會互相干擾,從而保證每一個子應用之間的軟隔離?
這個問題比樣式隔離的問題更棘手,社區的廣泛玩法是給一些全局反作用加各類前綴從而避免衝突。但其實咱們都明白,這種經過團隊間的」口頭「約定的方式每每低效且易碎,全部依賴人爲約束的方案都很難避免因爲人的疏忽致使的線上 bug。那麼咱們是否有可能打造出一個好用的且徹底無約束的 JS 隔離方案呢?
針對 JS 隔離的問題,咱們首創了一個運行時的 JS 沙箱。簡單畫了個架構圖:
即在應用的 bootstrap 及 mount 兩個生命週期開始以前分別給全局狀態打下快照,而後當應用切出/卸載時,將狀態回滾至 bootstrap 開始以前的階段,確保應用對全局狀態的污染所有清零。而當應用二次進入時則再恢復至 mount 前的狀態的,從而確保應用在 remount 時擁有跟第一次 mount 時一致的全局上下文。
固然沙箱裏作的事情還遠不止這些,其餘的還包括一些對全局事件監聽的劫持等,以確保應用在切出以後,對全局事件的監聽能獲得完整的卸載,同時也會在 remount 時從新監聽這些全局事件,從而模擬出與應用獨立運行時一致的沙箱環境。
自去年年末伊始,咱們便嘗試基於微前端架構模式,構建出一套全鏈路的面向中後臺場景的產品接入平臺,目的是解決不一樣產品之間集成困難、流程割裂的問題,但願接入平臺後的應用,不論使用哪一種技術棧,在運行時均可以經過自定義配置,實現不一樣應用之間頁面級別的自由組合,從而生成一個千人千面的個性化控制檯。
目前這套平臺已在螞蟻生產環境運行半年多,同時接入了多個產品線的 40+ 應用、4+ 不一樣類型的技術棧。過程當中針對大量微前端實踐中的問題,咱們總結出了一套完整的解決方案:
在內部獲得充分的技術驗證和線上考驗以後,咱們決定將這套解決方案開源出來!
取名 qiankun,意爲統一。咱們但願經過 qiankun 這種技術手段,讓你能很方便的將一個巨石應用改形成一個基於微前端架構的系統,而且再也不須要去關注各類過程當中的技術細節,作到真正的開箱即用和生產可用。
對於 umi 用戶咱們也提供了配套的 qiankun 插件 @umijs/plugin-qiankun ,以便於 umi 應用能幾乎零成本的接入 qiankun。
最後歡迎你們點贊使用提出寶貴的意見。👻
Maybe the most complete micro-frontends solution you ever met🧐.
多是你見過的最完善的微前端架構解決方案。