大前端時代下的微前端架構:增量升級、代碼解耦、獨立部署
想做好前端很難,做出可擴展的前端,從而讓多個團隊可以同時投身于一項復雜的大型產品項目就更難了。本文將介紹前端領域最近的一項變革:單體前端架構正在過渡到許多較小、較易管理的前端架構。我們還會展示這種新的體系結構怎樣提升前端團隊的效率和表現。除了討論這種新趨勢的好處與代價外,我們還將介紹一些可行的實現方案,并深入分析一個完整的微前端應用案例。
微服務近年來大受歡迎,許多組織轉向了微服務以克服大型單體后端架構的局限。但雖然微服務在服務端很流行,很多企業在前端代碼庫上仍然在沿用問題多多的單體架構。
也許你想構建一個漸進式或響應式的 Web 應用,但卻找不到一種將這些功能集成進現有代碼中的簡單途徑;也許你想嘗試 JavaScript 語言的新功能(或者是其他可以編譯為 JS 的某種語言),但你卻無法將關鍵的構建工具融入已有的構建流程;或者你只是想擴展開發流程,讓多個團隊可以同時開發一種產品,但現有單體架構中的耦合度與復雜性讓團隊間的合作變得磕磕絆絆。這些都是很現實的問題,都會影響你們向客戶交付高質量體驗的能力。
微前端的定義
最近業界越來越關注復雜的現代化 Web 開發需要怎樣的整體架構和組織結構這個問題。于是我們開始看到單體前端正在分解為更小、更簡單的模塊,這些模塊可以各自獨立開發、測試和部署,而它們組合在一起仍然對客戶表現為一件單一完整的產品。我們將這種技術稱為 微前端,其定義為:
“微前端是一種架構風格,其中眾多獨立交付的前端應用組合成一個大型整體。”

我們認為微前端的主要好處有:
- 更小,更緊密且更易維護的代碼庫。
- 組織更具擴展能力,其團隊更加獨立自治。
- 能夠以更加增量式的風格來升級、更新前端,甚至重寫部分前端代碼。
這些核心優勢與微服務的優勢基本一致,這也不是什么巧合。
當然,軟件架構領域沒有免費的午餐:一切都要付出代價。一些微前端實現可能導致重復依賴,使用戶不得不下載更多內容。此外,大幅提升的團隊自治水平可能會讓各個團隊的工作愈加分裂。只不過我們認為這些風險都能控制在合理水平上,微前端終究還是利大于弊的。
好處
我們不會從具體的技術方法或實施細節角度來定義微前端,而是重點關注它的屬性和好處。
增量升級
對于許多組織來說,追求增量升級就是他們邁向微前端的***步。對他們來說,老式的大型單體前端要么是用老舊的技術棧打造的,要么就充斥著匆忙寫成的代碼,已經到了該重寫整個前端的時候了。一次性重寫整個系統風險很大,我們更傾向一點一點換掉老的應用,同時在不受單體架構拖累的前提下為客戶不斷提供新功能。
為了做到這一點,解決方案往往就是微前端架構了。一旦某個團隊掌握了在幾乎不影響舊世界的同時為生產環境引入新功能的訣竅,其他團隊就會紛紛效仿。現有代碼仍然需要繼續維護下去,但在某些情況下還要繼續添加新功能,現在總算有了解決方案。
到***,我們就能更隨心所欲地改動產品的各個部分,并逐漸升級我們的架構、依賴關系和用戶體驗。當主框架發生重大變化時每個微前端模塊都可以按需升級,不需要整體下線或一次性升級所有內容。如果我們想要嘗試新的技術或互動模式,也能在隔離度更好的環境下做試驗。
簡潔、解耦的代碼庫
微前端體系下,每個小模塊的代碼庫要比一個單體前端的代碼庫小很多。對開發者來說這些較小的代碼庫處理起來更簡單方便。而且微前端還能避免無關組件之間不必要的耦合,讓代碼更簡潔。我們可以在應用的限界上下文(詳見下方鏈接)處劃出更明顯的界限,更好地避免無意間造成的這類耦合問題。
當然,只靠架構更迭本身(比如說“我們改成微前端吧”)并不能自動為以往的優質代碼生成替代品。我們要做的是設法讓糟糕的決策難以露頭,而讓正確的決策暢通無阻,從而進入邁向成功的良性循環。例如,現在很難跨越限界上下文共享域模型,所以開發者就不太可能這樣做了。類似地,微前端會讓開發者更審慎地把握數據和事件在應用的各個部分之間流動的方式,其實就算沒有微前端我們本來也應該這樣做的!
獨立部署
就像微服務一樣,微前端的一大優勢就是可獨立部署的能力。這種能力會縮減每次部署涉及的范圍,從而降低了風險。不管你的前端代碼是在哪里托管,怎樣托管,各個微前端都應該有自己的持續交付管道;這些管道可以將微前端構建、測試并部署到生產環境中。我們在部署各個微前端時幾乎不用考慮其他代碼庫或管道的狀態;就算舊的單體架構采用了固定、手動的按季發布周期,或者隔壁的團隊在他們的主分支里塞進了一個半成品或失敗的功能,也不影響我們的工作。如果某個微前端已準備好投入生產,那么它就能順利變為產品,且這一過程完全由開發和維護它的團隊主導。

自治團隊
解藕代碼庫、分離發布周期還能帶來一個高層次的好處,那就是大幅提升團隊的獨立性;一支獨立的團隊可以自主完成從產品構思到最終發布的完整流程,有足夠的能力獨立向客戶交付價值,從而可以更快、更高效地工作。為了實現這一目標需要圍繞垂直業務功能,而非技術功能來打造團隊。一種簡單的方法是根據最終用戶將看到的內容來劃分產品模塊,讓每個微前端都封裝應用的某個頁面,并分配給一個團隊完整負責。相比圍繞技術或“橫向”問題(如樣式、表單或驗證)打造的團隊相比,這種團隊能有更高的凝聚力。

小結
簡而言之,微前端是將龐大復雜的整體分割為更小、更易于管理的模塊,然后明確它們之間的依賴關系。我們的技術決策、代碼庫、團隊和發布流程都應該彼此獨立,無需過多協調工作就能自主運行并發展。
案例
假設要做一個食品外賣的網站。乍一看這種網站好像很好做,但想要做好需要在諸多細節上下足功夫:
- 應該有一個引導頁面,讓顧客瀏覽并搜索餐館。顧客應該能按照一系列參數(包括價格、菜品或訂購歷史等)來搜索并過濾餐館。
- 每家餐館都要有自己的頁面,頁面中要展示菜單,允許客戶自主選餐,還要有折扣、套餐和特殊要求選項。
- 顧客應該有自己的主頁,可以用來查看訂單歷史、跟蹤外賣進度并自定義付款選項
每個頁面都非常復雜,都應該分配一個專門團隊來負責,并且每個團隊都應該有足夠的獨立性。各個團隊都應該能獨立開發、測試、部署和維護自己的代碼,而不會與其他團隊發生沖突或需要其他團隊配合。但在客戶這里,整個網站仍然應該是一個無縫的整體。
下面我們就會圍繞這個案例來展示代碼與場景示例。
集成方法
前文對微前端的定義相當松散,所以有很多方法都可以劃入這個范疇。本節將展示一些示例并討論它們的優劣。這些方法在架構上有共通之處——通常應用中的每個頁面都有一個微前端,還有一個 容器應用,它有以下功能:
- 呈現常見的頁面元素,如頁眉和頁腳。
- 解決了身份認證和跳轉等跨領域問題。
- 在頁面上集成多個微前端,并告訴各個微前端該何時何地呈現自己。

服務器端模板組合
先來介紹一種非常新穎的前端開發方法——就是在服務器上使用多個模板或片段呈現 HTML。首先我們要有一個 index.html,其中包含所有常見的頁面元素;然后使用服務器端包含從 HTML 片段文件中插入的特定頁面內容:
- <html lang="en" dir="ltr">
- <head>
- <meta charset="utf-8">
- <title>Feed me</title>
- </head>
- <body>
- <h1> Feed me</h1>
- <!--# include file="$PAGE.html" -->
- </body>
- </html>
我們使用 Nginx 提供此文件,通過匹配正在請求的 URL 來配置 $PAGE 變量:
- server {
- listen 8080;
- server_name localhost;
- root /usr/share/nginx/html;
- index index.html;
- ssi on;
- # Redirect / to /browse
- rewrite ^/$ http://localhost:8080/browse redirect;
- # Decide which HTML fragment to insert based on the URL
- location /browse {
- set $PAGE 'browse';
- }
- location /order {
- set $PAGE 'order';
- }
- location /profile {
- set $PAGE 'profile'
- }
- # All locations should render through index.html
- error_page 404 /index.html;
- }
這是相當標準的服務器端組合方法。它之所以可以算作微前端,是因為我們可以由此來分割代碼,讓每部分代碼代表一個自包含的域概念,并由一個獨立的團隊負責。這里沒有展示各個 HTML 片段文件最終如何在 Web 服務器上呈現,實際上它們都有自己的部署管道,改動某個頁面并不會影響其他內容。
想要更高獨立性的話,可以為每個微前端單獨安排一個服務器負責呈現和服務,再安排一個服務器專門向其他服務器發出請求。如果能緩存好各個響應就不會增大延遲。

這個例子說明微前端不一定是一種新技術,也不一定很復雜。只要我們的設計決策能為代碼庫和團隊賦予更多自主權,那么不管怎樣的技術棧都能為我們帶來類似的收益。
構建時集成
還有一種方法是將每個微前端作為一個包來發布,并讓容器應用將它們全部作為庫依賴包含進去。下面展示了容器的 package.json 查找本文示例應用的方法:
- {
- "name": "@feed-me/container",
- "version": "1.0.0",
- "description": "A food delivery web app",
- "dependencies": {
- "@feed-me/browse-restaurants": "^1.2.3",
- "@feed-me/order-food": "^4.5.6",
- "@feed-me/user-profile": "^7.8.9"
- }
- }
這種辦法初看上去挺不錯。它通常會生成一個可部署的 Javascript 包,允許我們從各種應用中刪除常見的重復依賴。但這意味著我們修改產品的任何部分時都必須重新編譯和發布所有微前端。這種 齊步走的發布流程 在微服務里已經夠讓我們好受了,所以我們強烈建議不要用它來實現微前端架構。我們好不容易在開發和測試階段實現了解耦和獨立,可別再在發布階段又繞回去了。我們得在運行時中也集成微前端。
通過 iframe 在運行時集成
想要在瀏覽器中組合應用,一種最簡單的方法就是用 iframe。iframe 可以輕松地用一系列獨立的子頁面構建整個頁面。它們的樣式和全局變量也能充分隔離,不會互相干擾。
- <html>
- <head>
- <title>Feed me!</title>
- </head>
- <body>
- <h1>Welcome to Feed me!</h1>
- <iframe id="micro-frontend-container"></iframe>
- <script type="text/javascript">
- const microFrontendsByRoute = {
- '/': 'https://browse.example.com/index.html',
- '/order-food': 'https://order.example.com/index.html',
- '/user-profile': 'https://profile.example.com/index.html',
- };
- const iframe = document.getElementById('micro-frontend-container');
- iframe.src = microFrontendsByRoute[window.location.pathname];
- </script>
- </body>
- </html>
就像前文提到的服務器端包含方法一樣,用 iframe 構建頁面并不是一種激動人心的新技術。但只要我們能精心分割好應用并組建好團隊,那么用 iframe 就能實現前面提到的一系列好處。
很多人不喜歡 iframe,它也的確有一些缺陷。上面提到的簡單隔離方式確實降低了它的靈活性。用 iframe 在應用的各個部分之間構建集成可能會很困難,從而讓路由、歷史記錄和深層鏈接變得更加復雜;它還會影響頁面的響應速度。
通過 JavaScript 在運行時集成
這個方法非常靈活,應用廣泛。每個微前端都使用<script>標記包含在頁面上,并在加載時暴露全局函數作為其入口點。接下來容器應用決定應該加載哪個微前端,并調用相關函數來告訴微前端該何時何地呈現自己。
- <html>
- <head>
- <title>Feed me!</title>
- </head>
- <body>
- <h1>Welcome to Feed me!</h1>
- <!-- These scripts don't render anything immediately -->
- <!-- Instead they attach entry-point functions to `window` -->
- <script src="https://browse.example.com/bundle.js"></script>
- <script src="https://order.example.com/bundle.js"></script>
- <script src="https://profile.example.com/bundle.js"></script>
- <div id="micro-frontend-root"></div>
- <script type="text/javascript">
- // These global functions are attached to window by the above scripts
- const microFrontendsByRoute = {
- '/': window.renderBrowseRestaurants,
- '/order-food': window.renderOrderFood,
- '/user-profile': window.renderUserProfile,
- };
- const renderFunction = microFrontendsByRoute[window.location.pathname];
- // Having determined the entry-point function, we now call it,
- // giving it the ID of the element where it should render itself
- renderFunction('micro-frontend-root');
- </script>
- </body>
- </html>
上面是一個簡單的示例,展示了基本的技巧。相比構建時集成,這里我們可以獨立部署各個 bundle.js 文件。相比 iframe,我們在構建微前端之間的集成時有充分的靈活度。我們可以用多種方式擴展上述代碼,例如按需下載各個 JavaScript 包,或者在呈現微前端時傳遞出入數據。
這種方法同時具備靈活性與獨立可部署能力,是我們的***方案。后文將詳細探討這個方法。
通過 Web 組件在運行時集成
之前方法的一個變體是為每個微前端定義用于容器實例化的 HTML 自定義元素,而非定義要調用的容器的全局函數。
- <html>
- <head>
- <title>Feed me!</title>
- </head>
- <body>
- <h1>Welcome to Feed me!</h1>
- <!-- These scripts don't render anything immediately -->
- <!-- Instead they each define a custom element type -->
- <script src="https://browse.example.com/bundle.js"></script>
- <script src="https://order.example.com/bundle.js"></script>
- <script src="https://profile.example.com/bundle.js"></script>
- <div id="micro-frontend-root"></div>
- <script type="text/javascript">
- // These element types are defined by the above scripts
- const webComponentsByRoute = {
- '/': 'micro-frontend-browse-restaurants',
- '/order-food': 'micro-frontend-order-food',
- '/user-profile': 'micro-frontend-user-profile',
- };
- const webComponentType = webComponentsByRoute[window.location.pathname];
- // Having determined the right web component custom element type,
- // we now create an instance of it and attach it to the document
- const root = document.getElementById('micro-frontend-root');
- const webComponent = document.createElement(webComponentType);
- root.appendChild(webComponent);
- </script>
- </body>
- </html>
最終結果與前面的示例很像,主要區別在于這里以“Web 組件方式”操作。如果你喜歡 Web 組件規范,喜歡使用瀏覽器提供的功能,那么這也是個不錯的選擇。如果你更喜歡在容器應用和微前端之間定義自己的接口,那么前面的示例可能更合適。
樣式
CSS 本質上是一種全局、繼承和級聯的語言,傳統 CSS 也沒有模塊系統、命名空間或封裝;有些功能現在也可用了,但往往缺乏瀏覽器支持。在微前端領域,這些問題往往變得更為嚴重。例如,如果一個團隊的微前端有一個樣式表,上面寫著 h2 { color: black; },另一個微前端的樣式表卻寫著 h2 { color: blue; },并且這兩個選擇器都附加到了同一個頁面,后果就很嚴重了!這類問題古已有之,但在微前端體系中由于這些選擇器是由不同團隊在不同時間編寫的,而且代碼可能分散在不同的存儲庫中,所以就更難發現了。
多年來業界發明了很多方法來更好地管理 CSS。有些人會使用嚴格的命名約定(例如 BEM 規范:http://getbem.com/)以確保選擇器只在正確的位置起作用。還有人會使用 SASS(https://sass-lang.com/)之類的預處理器,其嵌套的選擇器可以用作一種命名空間。一種較新的方法是使用 CSS 模塊或某種 CSS-in-JS 庫(詳見下方鏈接),以編程方式應用所有樣式,確保樣式只會直接應用在開發者想要的位置上。此外還有 shadow DOM(詳見下方鏈接),它也提供樣式隔離。
具體用哪種方法并不重要,只要讓開發者可以獨立編寫樣式,然后各個樣式集成到同一個應用中時不起沖突就行了。
共享組件庫
前文提到微前端的視覺一致性是很重要的。一種實現方法是開發一個共享的、可重用的 UI 組件庫。創建這樣一個庫的主要好處是通過重用代碼和來減少工作量,同時實現視覺一致性。此外,這個組件庫可以當作樣式指南來用,它可以是開發者和設計人員之間的一個很好的協作橋梁。
但最容易犯的一個錯誤就是過早地搞出來一大堆組件。我們都想創建一個基礎框架,其中包含所有應用所需的所有常見視覺效果。但其實我們很難提前判斷組件應該用什么 API,結果很多組件都是在白費功夫。所以我們更愿意讓團隊在需要的時候再去創建自己的組件,就算因此產生了一些重復工作也沒關系。應該順其自然,等組件的 API 都確定下來以后再把重復代碼收集到共享庫里,這樣就不會徒勞無功了。
最常見的共享組件是“無聲”的視覺基本元素,如圖標、標簽和按鈕等。我們還可以共享可能包含大量 UI 邏輯的復雜組件,例如自動完成、下拉搜索字段等;或者是可排序、可過濾的分頁表。但是,請確保共享組件僅包含 UI 邏輯,而不包含業務或域邏輯。將域邏輯放入共享庫會給應用之間帶來高度耦合,改動起來也更困難。例如,一般來說不該共享 ProductTable,因為它會包含關于“產品”的定義及表現的各種內容。這種域建模和業務邏輯應該屬于微前端的應用代碼,不應該放到共享庫里。
作為一種內部共享庫來說,它的所有權和管理也自然存在一些棘手的問題。一種管理模式是將其視為共享資產,讓“每個人”都擁有它——實踐中這通常意味著沒人能真正擁有它。這個共享庫很快就會變成一堆不一致的代碼集合,也沒有明確的約定或技術愿景。反過來說,如果共享庫的開發工作完全中心化,那么組件的創建者與使用者之間就會嚴重脫節。***的模式應該是允許所有人為庫做貢獻,但要有一個保管人(一個人或一個團隊)負責確保這些貢獻的質量、一致性和有效性。維護共享庫需要強大的技術技能,還需要能協調眾多團隊的管理技能。
跨應用通信
關于微前端最常見的一個問題是如何讓這些微前端互相通信。一般來說,我們建議盡可能減少這類通信需求,因為它通常會重新引入我們本想避免的耦合度。
換句話說,某種程度的跨應用通信是必要的。自定義事件(詳見下方鏈接)允許微前端之間間接通信,從而盡量減少直接耦合;但它也會讓各個微前端之間已有的合約更難確定和增強。另一種方法是將回調和數據向下傳遞的 React 模型(這里是從容器應用向下傳遞到微前端),它能讓模塊之間的合約更加明確。第三種方法是使用地址欄作為通信機制,我們將在后面詳細介紹。
如果你在使用 redux,常見方案是為整個應用提供單個全局共享存儲。但如果每個微前端都是自包含的應用,那么它們都應該有自己的 redux 存儲。Redux 文檔甚至給出了“在大型應用中將 Redux 應用隔離為組件”的說明。
無論選擇哪種方法,我們都希望微前端可以彼此發送消息或事件來通信,同時避免任何共享狀態。就像在微服務之間共享數據庫一樣,一旦我們共享了數據結構和域模型就會引入大量的耦合,改動起來也會更困難。
這里也有幾種不錯的方案選項。最重要的是要時刻考慮你正在引入怎樣的耦合,以及如何持續維持模塊之間的合約。就像微服務之間的集成一樣,你需要在不同應用和團隊之間協調升級流程,才能對集成做出重大改動。
你還應該考慮如何自動驗證集成的工作狀態。一種方法是功能測試,但它們的實施和維護成本很高。或者你可以實現某種形式的消費者驅動合約(詳見下方鏈接),這樣一來,無需在瀏覽器中集成全部微前端并運行應用,就能讓每個微前端確定其他微前端需要哪些內容。
后端通信
前端應用開發倒是分配給各個獨立團隊了,可后端呢?這里就是全棧團隊的價值所在了,他們從可視代碼到 API 開發及數據庫和基礎架構代碼都能自己搞定。有一種不錯的模式叫 BFF 模式,其中每個前端應用都有一個對應的后端,后者只用來滿足前者的需求。
這里有很多變量需要考慮。BFF 可能是自包含的,具有自己的業務邏輯和數據庫,或者它可能只是下游服務的聚合器。負責微前端及其 BFF 的團隊是否應該負責一部分下游服務也是個問題。如果微前端只有一個與之對話的 API,并且該 API 相當穩定,那么可能就不用構建 BFF 了。這里的指導原則是,構建某個微前端的團隊不應該依賴其他團隊為他們構建內容。因此,如果每個添加到微前端的新功能都需要改動后端,那么讓同一個團隊負責 BFF 就很合適了。

另一個常見問題是,如何通過服務器對微前端應用的用戶進行身份驗證和授權操作?顯然,客戶應該只需進行一次身份驗證過程,因此身份驗證往往是跨領域問題,應該由容器應用負責。容器可能有某種登錄形式,我們通過它獲得某種令牌。該令牌將由容器控制,并且可以在初始化時注入各個微前端。***,微前端可以把令牌及其發送的請求發給服務器,而服務器可以按需完成驗證操作。
測試
在測試方面,單體前端和微前端之間沒有太大區別。一般來說在單體前端上使用的測試策略都能用在各個微前端上;也就是說每個微前端都應該有自己的自動化全面測試套件,以確保代碼的質量和正確性。
不一樣的是各種微前端與容器應用的集成測試。可以使用你最喜歡的功能 / 端到端測試工具(例如 Selenium 或 Cypress)來做這部分測試;應該使用單元測試來覆蓋你的低級業務邏輯和呈現邏輯,然后使用功能測試來驗證頁面是否正確組裝。例如,你可以在特定 URL 上加載完全集成的應用,并用硬編碼標題來在頁面上聲明相關的微前端。
如果有跨越不同微前端的用戶操作,那么就可以使用功能測試來覆蓋它們。但記住功能測試的重點是驗證前端的集成,而非每個微前端的內部業務邏輯,后者應該已經被單元測試覆蓋了。如上所述,消費者驅動的合同可以用來直接指定微前端之間的交互,而不會破壞集成環境和功能測試。