微前端

良好的前端開發很困難。擴充前端開發,讓許多團隊能夠同時處理大型且複雜的產品,更困難。在本文中,我們將描述將前端巨石分解成許多較小、更易於管理的區塊的最新趨勢,以及此架構如何提升處理前端程式碼的團隊的效能和效率。除了討論各種好處和成本外,我們將介紹一些可用的實作選項,並深入探討一個展示此技術的完整範例應用程式。

2019 年 6 月 19 日



近年來,微服務 的人氣激增,許多組織使用這種架構樣式來避免大型、單體後端的限制。雖然關於這種建置伺服器端軟體的樣式已撰寫許多文章,但許多公司仍持續與單體前端程式碼庫奮戰。

或許您想建立漸進式或回應式網頁應用程式,但找不到一個容易開始將這些功能整合到現有程式碼中的地方。或許您想開始使用新的 JavaScript 語言功能(或能編譯為 JavaScript 的眾多語言之一),但您無法將必要的建置工具放入現有的建置流程中。或者您只是想擴充您的開發規模,讓多個團隊可以同時處理單一產品,但現有巨石架構中的耦合和複雜性表示每個人都在踩別人的腳趾。這些都是會對您有效率地提供高品質體驗給客戶的能力造成負面影響的實際問題。

最近我們看到越來越多人注意複雜的現代網頁開發所需的整體架構和組織結構。特別是,我們看到將前端巨石架構分解成更小、更簡單的區塊的模式浮現,這些區塊可以獨立開發、測試和部署,同時在客戶眼中仍然是一個單一的凝聚產品。我們稱這種技術為微前端,我們定義為

「一種架構風格,其中可獨立交付的前端應用程式會組成一個更大的整體」

在 Thoughtworks 技術雷達的 2016 年 11 月號中,我們將微前端列為組織應評估的技術。我們後來將其提升為試用,最後提升為採用,這表示我們認為這是一種已驗證的方法,當有意義時您應該使用它。

A screenshot of micro frontends on the       Thoughtworks tech radar

圖 1:微前端已在技術雷達上出現過好幾次。

我們從微前端看到的一些主要好處是

這些標題優點與微服務可以提供的優點相同,這並非巧合。

當然,在軟體架構中沒有免費的午餐 - 每件事都有代價。有些微前端實作可能會導致依賴項重複,增加我們的使用者必須下載的位元組數。此外,團隊自主性的大幅提升可能會導致團隊工作方式的分歧。儘管如此,我們相信這些風險是可以管理的,而且微前端的好處通常大於成本。

好處

我們不是用特定的技術方法或實作細節來定義微前端,而是強調其所產生的屬性和帶來的優點。

逐步升級

對許多組織來說,這是他們微前端旅程的開始。舊的、大型的前端單體受到過往技術堆疊或在交付壓力下編寫的程式碼所拖累,而且已經到了完全重寫的誘人地步。為了避免完全重寫的風險,我們更願意逐一扼殺舊應用程式,同時繼續向我們的客戶提供新功能,而不會被單體拖累。

這通常會導致微前端架構。一旦一個團隊有經驗將一個功能帶到生產環境,而對舊世界幾乎沒有修改,其他團隊也會想要加入新世界。現有的程式碼仍然需要維護,在某些情況下,繼續為其新增功能可能是合理的,但現在有了選擇。

此處的最終目標是,我們可以更自由地對產品的個別部分做出逐案決策,並對我們的架構、依賴關係和使用者體驗進行增量升級。如果我們的核心架構發生重大的重大變更,則每個微前端都可以在適當的時候升級,而不是被迫停止所有作業並一次升級所有內容。如果我們想嘗試新技術或新的互動模式,我們可以比以前更孤立地進行。

簡單、解耦的程式碼庫

每個個別微前端的原始碼定義上會比單一巨石前端的原始碼小很多。這些較小的程式碼庫往往更簡單,開發人員也更容易使用。特別是,我們避免了不應互相了解的元件之間意外且不適當的耦合所產生的複雜性。透過在應用程式的限界脈絡周圍畫出更粗的線,我們讓這種意外耦合更難發生。

當然,單一的、高階的架構決策(例如「讓我們做微前端」)並不能取代傳統的乾淨程式碼。我們並非試圖豁免自己思考我們的程式碼並努力提高其品質。相反地,我們試著透過讓錯誤的決策變困難,而正確的決策變容易,來設定自己陷入成功的陷阱。例如,跨限界脈絡共用網域模型變得更加困難,因此開發人員不太可能這樣做。同樣地,微前端會促使你明確且審慎地思考資料和事件如何在應用程式的不同部分之間流動,而這正是我們無論如何都應該做的!

獨立部署

與微服務一樣,微前端的獨立部署能力是關鍵。這會減少任何特定部署的範圍,進而降低相關風險。無論您的前端程式碼如何或在哪裡託管,每個微前端都應有自己的持續傳遞管線,用於建置、測試並將其部署到生產環境。我們應該能夠在幾乎不考慮其他程式碼庫或管線的當前狀態的情況下部署每個微前端。舊的巨型程式碼庫是採用固定、手動的每季發布週期,或者隔壁團隊已將半成品或損壞的功能推送到其主分支,這都無關緊要。如果給定的微前端已準備好投入生產,它就應該能夠執行,而此決策應由建置和維護它的團隊決定。

A diagram showing 3 applications independently going from source control, through build, test and deployment to production

圖 2:每個微前端獨立部署到生產環境

自主團隊

解耦我們的程式碼庫和發布週期所帶來的更高階好處是,我們可以大幅朝向擁有完全獨立的團隊,他們可以擁有產品的某一部分,從構思到生產,甚至更後面的階段。團隊可以完全擁有他們提供客戶價值所需的一切,這使他們能夠快速而有效地行動。為了達成此目的,我們的團隊需要圍繞業務功能的垂直切片組成,而不是圍繞技術能力組成。執行此操作的簡單方法是根據最終使用者將看到的內容來劃分產品,因此每個微前端都封裝應用程式的單一頁面,並且由單一團隊端到端擁有。與團隊圍繞技術或「水平」問題(例如樣式、表單或驗證)組成相比,這會帶來團隊工作更高的凝聚力。

A diagram showing teams formed around 3 applications, and warning against forming a 'styling' team

圖 3:每個應用程式都應由單一團隊擁有

簡而言之

簡而言之,微前端的重點在於將龐大且可怕的事物切成更小、更易於管理的部分,然後明確它們之間的依賴關係。我們的技術選擇、程式碼庫、團隊和發布流程都應該能夠獨立於彼此運作和演進,而不需要過度的協調。

範例

想像一個網站,客戶可以在其中訂購外送食物。表面上這是一個相當簡單的概念,但如果您想做好,會有驚人的細節

  • 應該有一個著陸頁,客戶可以在其中瀏覽和搜尋餐廳。餐廳應可根據任何數量的屬性進行搜尋和篩選,包括價格、菜系或客戶先前訂購的內容
  • 每家餐廳都需要有自己的頁面,顯示其菜單項目,並允許客戶選擇他們想吃的東西,以及折扣、餐點優惠和特殊要求
  • 客戶應有一個個人資料頁面,他們可以在其中查看訂單記錄、追蹤外送進度並自訂付款選項
A wireframe of a food delivery website

圖 4:外送食物網站可能有多個相當複雜的頁面

每個頁面都夠複雜,我們可以輕易地為每個頁面成立一個專責團隊,而且每個團隊都應該能夠獨立於其他團隊處理自己的頁面。他們應該能夠開發、測試、部署和維護自己的程式碼,而不用擔心與其他團隊的衝突或協調。然而,我們的客戶仍然應該看到一個單一的、無縫的網站。

在本文的其餘部分,我們將在需要範例程式碼或範例時使用這個範例應用程式。

整合方法

根據上述相當寬鬆的定義,有許多方法可以合理地稱為微前端。在本節中,我們將展示一些範例並討論其權衡。所有方法中都出現了一個相當自然的架構 - 通常應用程式中的每個頁面都有微前端,並且有一個單一的容器應用程式,它

  • 呈現常見的頁面元素,例如標頭和頁尾
  • 處理跨領域問題,例如驗證和導覽
  • 將各種微前端組合到頁面上,並告訴每個微前端何時何地呈現自己
A web page with boxes drawn around different sections. One box wraps the whole page, labelling it as the 'container application'. Another box wraps the main content (but not the global page title and navigation), labelling it as the 'browse micro frontend'

圖 5:您通常可以從頁面的視覺結構中推導出您的架構

伺服器端範本組成

我們從一種絕對不新穎的前端開發方法開始 - 從多個範本或片段在伺服器上呈現 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 ^/$ https://127.0.0.1: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 檔案如何出現在網路伺服器上,但假設它們各自有自己的部署管線,這讓我們可以部署一個頁面的變更,而不會影響或考慮任何其他頁面。

為了獲得更大的獨立性,可以有一個獨立的伺服器負責呈現和提供每個微前端,並有一個伺服器在前面向其他伺服器提出要求。透過仔細快取回應,這可以在不影響延遲的情況下完成。

A flow diagram showing a browser making a request to a 'container app server', which then makes requests to one of either a 'browse micro frontend server' or a 'order micro frontend server'

圖 6:這些伺服器可以獨立建置和部署

此範例顯示微前端技術不一定是新技術,也不一定很複雜。只要我們小心我們的設計決策如何影響我們的程式碼庫和團隊的自主性,我們就可以在不論技術堆疊的情況下,獲得許多相同的好處。

建置時間整合

我們有時會看到的一種方法是將每個微前端發布為套件,並讓容器應用程式將它們全部包含為函式庫相依性。以下是容器的 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>

此處的最終結果與前一個範例非常類似,主要差別在於你選擇以「網頁元件的方式」執行。如果你喜歡網頁元件規格,而且喜歡使用瀏覽器提供的功能,那麼這是一個不錯的選擇。如果你偏好定義容器應用程式和微前端之間的介面,那麼你可能比較偏好前一個範例。

樣式

CSS 作為一種語言,本質上是全域性的、繼承性的和層疊性的,傳統上沒有模組系統、命名空間或封裝。其中一些功能現在確實存在,但瀏覽器支援通常不足。在微前端領域,許多這些問題會更嚴重。例如,如果一個團隊的微前端有一個樣式表寫著 h2 { color: black; },而另一個寫著 h2 { color: blue; },而且這兩個選取器都附加到同一個頁面上,那麼一定會有人失望!這不是一個新問題,但由於這些選取器是由不同的團隊在不同的時間編寫的,而且程式碼可能分散在不同的儲存庫中,使得更難發現,因此問題變得更嚴重。

多年來,已經發明了許多方法來讓 CSS 更易於管理。有些人選擇使用嚴格的命名慣例,例如 BEM,以確保選取器僅套用在預期的地方。其他人不願意僅依賴開發人員的紀律,因此使用預處理器,例如 SASS,其選取器巢狀結構可用作一種命名空間。較新的方法是使用 CSS 模組 或各種 CSS-in-JS 函式庫以程式方式套用所有樣式,這可確保樣式僅直接套用在開發人員預期的位置。或者,對於更基於平台的方法,Shadow DOM 也提供樣式隔離。

你選擇的方法並不重要,只要你能找到一種方法來確保開發人員可以獨立撰寫他們的樣式,並確信他們的程式碼在組合成單一應用程式時會表現出可預測的行為即可。

共用元件函式庫

我們在上面提到微前端的視覺一致性很重要,而其中一種方法就是開發一個共用、可重複使用的 UI 元件庫。一般來說,我們認為這是一個好主意,儘管很難做好。建立此類庫的主要好處是透過重複使用程式碼來減少工作量,並確保視覺一致性。此外,您的元件庫可以用作即時樣式指南,並且可以成為開發人員和設計人員之間很好的協作點。

最容易出錯的事情之一就是過早建立過多的這些元件。建立一個基礎平台很誘人,其中包含所有應用程式中需要的共用視覺效果。然而,經驗告訴我們,在實際使用元件之前,很難(如果不是不可能的話)猜測元件的 API 應該是什麼,這會導致元件在早期產生很多變動。因此,我們比較喜歡讓團隊在需要時在自己的程式碼庫中建立自己的元件,即使這最初會造成一些重複。讓模式自然浮現,一旦元件的 API 變得顯而易見,您就可以將重複的程式碼收集到共用庫中,並確信您擁有經過驗證的東西。

最明顯的共用候選元件是「愚蠢的」視覺原語,例如圖示、標籤和按鈕。我們也可以共用更複雜的元件,其中可能包含大量的 UI 邏輯,例如自動完成的下拉式搜尋欄位。或可排序、可篩選、可分頁的表格。但是,請務必確保您的共用元件只包含 UI 邏輯,而沒有商業或領域邏輯。當領域邏輯放入共用庫中時,它會在應用程式之間建立高度耦合,並增加變更的難度。因此,例如,您通常不應該嘗試共用一個ProductTable,其中會包含各種關於「產品」是什麼以及應該如何運作的假設。此類領域建模和商業邏輯屬於微前端的應用程式程式碼,而不是共用庫。

與任何共用的內部程式庫一樣,其所有權和治理存在一些棘手的問題。一種模式是說,作為一個共用資產,「每個人」都擁有它,儘管在實務上這通常表示沒有人擁有它。它很快可能會變成不一致程式碼的大雜燴,沒有明確的慣例或技術願景。在另一個極端,如果共用程式庫的開發完全集中化,那麼建立元件的人和使用元件的人之間將會產生很大的差距。我們見過最好的模式是任何人都可以貢獻程式庫,但有一個保管人(個人或團隊)負責確保這些貢獻的品質、一致性和有效性。維護共用程式庫的工作需要強大的技術技能,但也需要培養跨多個團隊協作的人際技能。

跨應用程式通訊

關於微型前端最常見的問題之一是如何讓它們彼此對話。一般來說,我們建議讓它們盡可能少地進行通訊,因為這通常會重新引入我們一開始想要避免的不適當耦合。

話雖如此,通常需要某種程度的跨應用通訊。自訂事件允許微型前端間接通訊,這是一種最小化直接耦合的好方法,儘管這使得更難確定和強制執行微型前端之間存在的合約。或者,React 的向下傳遞回呼和資料的模式(在這種情況下,從容器應用程式向下傳遞到微型前端)也是一個很好的解決方案,可以讓合約更明確。第三個替代方案是使用網址列作為通訊機制,我們將在稍後更詳細地探討。

無論我們選擇哪種方法,我們都希望我們的微前端透過傳送訊息或事件彼此溝通,並避免擁有任何共用狀態。就像在微服務間共用資料庫一樣,只要我們共用我們的資料結構和領域模型,我們就會建立大量的耦合,而且極難進行變更。

與樣式一樣,有許多不同的方法可以在這裡發揮作用。最重要的事情是深入思考您要引入哪種耦合,以及您將如何隨著時間推移維護該合約。就像微服務之間的整合一樣,您無法對整合進行重大變更,除非您在不同的應用程式和團隊之間擁有協調的升級程序。

您還應該思考如何自動驗證整合不會中斷。功能測試是一種方法,但我們偏好限制我們編寫的功能測試數量,因為實作和維護它們的成本。或者,您可以實作某種形式的消費者驅動合約,以便每個微前端可以指定它對其他微前端的要求,而無需實際整合並在瀏覽器中一起執行它們。

後端通訊

如果我們有獨立作業於前端應用程式的獨立團隊,後端開發怎麼辦?我們堅信全端團隊的價值,他們擁有其應用程式的開發,從視覺程式碼到 API 開發,以及資料庫和基礎架構程式碼。一種在此有幫助的模式是BFF模式,其中每個前端應用程式都有對應的後端,其目的僅是服務於該前端的需求。雖然 BFF 模式最初可能表示每個前端頻道(網路、行動裝置等)的專用後端,但它很容易延伸為表示每個微前端的後端。

這裡有很多變數需要考量。BFF 可能包含自己的業務邏輯和資料庫,或者它可能只是下游服務的聚合器。如果存在下游服務,擁有微型前端及其 BFF 的團隊是否也擁有這些服務中的某些服務可能是合理的。如果微型前端只有一個它會對話的 API,而且該 API 相當穩定,那麼建立 BFF 可能沒有什麼價值。這裡的指導原則是,建立特定微型前端的團隊不應該等待其他團隊為他們建立事物。因此,如果新增到微型前端的每個新功能也需要後端變更,那麼這是一個由同一團隊擁有的 BFF 的有力依據。

A diagram showing three pairs of frontends / backends. The first backend talks only to its own database. The other two backends talk to shared downstream services. Both approaches are valid.

圖 7:有許多不同的方式可以建構您的前端/後端關係

另一個常見的問題是,微型前端應用程式的使用者應該如何向伺服器進行驗證和授權?顯然我們的客戶應該只需要驗證自己一次,因此驗證通常完全屬於容器應用程式應該擁有的橫切關注範疇。容器可能有一些類型的登入表單,我們透過該表單取得某種類型的權杖。該權杖將由容器擁有,並可以在初始化時注入到每個微型前端。最後,微型前端可以將權杖與它對伺服器提出的任何要求一起傳送,而伺服器可以執行任何必要的驗證。

測試

在測試方面,我們沒有看到單體前端和微型前端之間有太大差異。一般來說,您用於測試單體前端的任何策略都可以複製到每個個別微型前端。也就是說,每個微型前端都應該有自己的全套自動化測試,以確保程式碼的品質和正確性。

顯而易見的差距將是各種微型前端與容器應用程式的整合測試。這可以使用您偏好的功能/端到端測試工具(例如 Selenium 或 Cypress)來完成,但不要做得太過分;功能測試應該只涵蓋無法在較低層級的測試金字塔中測試的方面。我們的用意是,使用單元測試來涵蓋您的低層級業務邏輯和呈現邏輯,然後只使用功能測試來驗證頁面是否正確組裝。例如,您可以在特定 URL 中載入完全整合的應用程式,並聲明相關微型前端的硬式編碼標題存在於頁面上。

如果使用者旅程跨越微型前端,那麼您可以使用功能測試來涵蓋這些旅程,但請保持功能測試專注於驗證前端的整合,而不是每個微型前端的內部業務邏輯,這應該已經由單元測試涵蓋了。如上所述,消費者驅動合約可以幫助直接指定微型前端之間發生的互動,而不會出現整合環境和功能測試的脆弱性。

範例詳情

本文的大部分內容將詳細說明我們範例應用程式可以實作的一種方式。我們將主要專注於容器應用程式和微型前端如何使用 JavaScript 整合在一起,因為這可能是最有趣且最複雜的部分。您可以在https://demo.microfrontends.com看到部署的最終結果,並可以在Github上看到完整的原始碼。

A screenshot of the 'browse' landing page of the full micro frontends demo application

圖 8:完整微前端示範應用程式的「瀏覽」著陸頁

示範完全使用 React.js 建置,因此值得一提的是,React 並非此架構的獨佔者。微前端可以使用許多不同的工具或架構來實作。我們在此選擇 React,是因為它很受歡迎,而且我們自己也熟悉它。

容器

我們將從 容器 開始,因為它是客戶的進入點。讓我們看看我們可以從它的 package.json 中了解到什麼

{
  "name": "@micro-frontends-demo/container",
  "description": "Entry point and container for a micro frontends demo",
  "scripts": {
    "start": "PORT=3000 react-app-rewired start",
    "build": "react-app-rewired build",
    "test": "react-app-rewired test"
  },
  "dependencies": {
    "react": "^16.4.0",
    "react-dom": "^16.4.0",
    "react-router-dom": "^4.2.2",
    "react-scripts": "^2.1.8"
  },
  "devDependencies": {
    "enzyme": "^3.3.0",
    "enzyme-adapter-react-16": "^1.1.1",
    "jest-enzyme": "^6.0.2",
    "react-app-rewire-micro-frontends": "^0.0.1",
    "react-app-rewired": "^2.1.1"
  },
  "config-overrides-path": "node_modules/react-app-rewire-micro-frontends"
}

reactreact-scripts 的相依性中,我們可以得出結論,它是一個使用 create-react-app 建立的 React.js 應用程式。更有趣的是沒有什麼:任何提到我們將組合在一起以形成最終應用程式的微前端。如果我們在此將它們指定為程式庫相依性,我們將走向建置時間整合的路徑,如前所述,這往往會在我們的發行週期中造成有問題的耦合。

要了解我們如何選擇和顯示微前端,讓我們看看 App.js。我們使用 React Router 將目前的 URL 與預先定義的路由清單進行比對,並呈現對應的元件

<Switch>
  <Route exact path="/" component={Browse} />
  <Route exact path="/restaurant/:id" component={Restaurant} />
  <Route exact path="/random" render={Random} />
</Switch>

Random 元件沒什麼意思,它只是將頁面重新導向到隨機選取的餐廳 URL。BrowseRestaurant 元件如下所示

const Browse = ({ history }) => (
  <MicroFrontend history={history} name="Browse" host={browseHost} />
);
const Restaurant = ({ history }) => (
  <MicroFrontend history={history} name="Restaurant" host={restaurantHost} />
);

在兩種情況下,我們都會呈現 MicroFrontend 元件。除了歷程記錄物件(稍後會變得重要)之外,我們還指定應用程式的唯一名稱,以及可以從中下載其套件的伺服器。這個由設定檔驅動的 URL 在本地執行時會類似於 https://127.0.0.1:3001,或在製作環境中為 https://browse.demo.microfrontends.com

App.js 中選擇微前端後,我們現在將在 MicroFrontend.js 中呈現它,這只是一個 React 元件

class MicroFrontend extends React.Component {
  render() {
    return <main id={`${this.props.name}-container`} />;
  }
}

這不是整個類別,我們很快就會看到更多它的方法。

在呈現時,我們所做的就是將一個容器元素放在頁面上,其 ID 是微前端唯一的。這是我們將告訴我們的微前端呈現自己的地方。我們使用 React 的 componentDidMount 作為下載和安裝微前端的觸發器

類別 MicroFrontend…

  componentDidMount() {
    const { name, host } = this.props;
    const scriptId = `micro-frontend-script-${name}`;

    if (document.getElementById(scriptId)) {
      this.renderMicroFrontend();
      return;
    }

    fetch(`${host}/asset-manifest.json`)
      .then(res => res.json())
      .then(manifest => {
        const script = document.createElement('script');
        script.id = scriptId;
        script.src = `${host}${manifest['main.js']}`;
        script.onload = this.renderMicroFrontend;
        document.head.appendChild(script);
      });
  }

我們首先檢查是否已下載具有唯一 ID 的相關腳本,如果是,我們就可以立即渲染它。如果不是,我們會從適當的主機中擷取 asset-manifest.json 檔案,以查詢主腳本資產的完整 URL。一旦我們設定好腳本的 URL,剩下的就是將它附加到文件,並使用 onload 處理常式,以渲染微前端

類別 MicroFrontend…

  renderMicroFrontend = () => {
    const { name, history } = this.props;

    window[`render${name}`](`${name}-container`, history);
    // E.g.: window.renderBrowse('browse-container', history);
  };

在上述程式碼中,我們呼叫名為 window.renderBrowse 的全域函式,而這是我們剛才下載的腳本所放置的。我們傳遞微前端應自我渲染的 <main> 元素的 ID,以及我們稍後會說明的 history 物件。此全域函式的簽章是容器應用程式與微前端之間的關鍵合約。這是任何通訊或整合應發生的位置,因此保持其相當輕量化,使其易於維護,並在未來新增微前端。每當我們想要執行需要變更此程式碼的動作時,我們都應仔細思考這對我們的程式碼庫耦合和合約維護的意義。

最後一個部分是處理清理。當我們的 MicroFrontend 元件卸載(從 DOM 中移除)時,我們也希望卸載相關的微前端。每個微前端都定義了一個對應的全域函式,供此目的使用,我們會從適當的 React 生命週期方法呼叫它

類別 MicroFrontend…

  componentWillUnmount() {
    const { name } = this.props;

    window[`unmount${name}`](`${name}-container`);
  }

就其自己的內容而言,容器直接渲染的只有網站的頂層標頭和導覽列,因為這些在所有頁面中都是不變的。這些元素的 CSS 已仔細撰寫,以確保它只會設定樣式給標頭內的元素,因此它不應與微前端內的任何樣式設定碼產生衝突。

這就是容器應用程式的結束!它相當基本,但這為我們提供了一個外殼,可以在執行階段動態下載我們的微前端,並將它們黏合在一起,成為單一頁面上的整體。這些微前端可以獨立部署到生產環境,而無需對任何其他微前端或容器本身進行任何變更。

微前端

繼續這個故事的合乎邏輯位置是我們持續提到的全域渲染函式。我們應用程式的首頁是一個可過濾的餐廳清單,其進入點如下所示

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import registerServiceWorker from './registerServiceWorker';

window.renderBrowse = (containerId, history) => {
  ReactDOM.render(<App history={history} />, document.getElementById(containerId));
  registerServiceWorker();
};

window.unmountBrowse = containerId => {
  ReactDOM.unmountComponentAtNode(document.getElementById(containerId));
};

通常在 React.js 應用程式中,呼叫 `ReactDOM.render` 會在頂層範圍,表示這個腳本檔載入後,會立即開始將內容呈現在硬式編碼的 DOM 元素中。對於這個應用程式,我們需要能控制何時以及在哪裡執行呈現,因此我們將其包裝在一個函式中,接收 DOM 元素的 ID 作為參數,並將該函式附加到全域 `window` 物件。我們也可以看到用於清理的對應解除安裝函式。

雖然我們已經看過在微型前端整合到整個容器應用程式時如何呼叫這個函式,但這裡成功的一項最大準則,是我們可以獨立開發和執行微型前端。因此,每個微型前端都有自己的 `index.html`,其中包含一個內嵌腳本,用於在容器外部的「獨立」模式中呈現應用程式。

<html lang="en">
  <head>
    <title>Restaurant order</title>
  </head>
  <body>
    <main id="container"></main>
    <script type="text/javascript">
      window.onload = () => {
        window.renderRestaurant('container');
      };
    </script>
  </body>
</html>
A screenshot of the 'order' page running as a standalone application outside of the container

圖 9:每個微型前端都可以在容器外部作為獨立應用程式執行。

從這一點開始,微型前端大部分都只是單純的舊式 React 應用程式。「瀏覽」 應用程式從後端擷取餐廳清單,提供 `<input>` 元素,用於搜尋和篩選餐廳,並呈現 React Router `<Link>` 元素,導覽至特定餐廳。在那個時間點,我們會切換到第二個 「訂購」 微型前端,它會呈現單一餐廳及其菜單。

An architecture diagram that shows the sequence of steps for navigation, as described above

圖 10:這些微型前端僅透過路由變更互動,不會直接互動。

關於我們的微型前端,最後值得一提的是,它們都使用 `styled-components` 進行所有造型。這個 CSS-in-JS 函式庫可以輕鬆將樣式與特定元件關聯,因此我們可以保證微型前端的樣式不會外洩,影響容器或其他微型前端。

透過路由進行跨應用程式通訊

我們 先前提到,跨應用程式通訊應盡量減少。在此範例中,我們唯一的需求是瀏覽頁面需要告訴餐廳頁面要載入哪間餐廳。我們將在此看到如何使用用戶端路由來解決這個問題。

參與其中的三個 React 應用程式都使用 React Router 進行宣告式路由,但初始化方式略有不同。對於容器應用程式,我們建立一個 `<BrowserRouter>`,它會在內部實例化一個 `history` 物件。這與我們先前略過的 `history` 物件相同。我們使用這個物件來處理用戶端歷史記錄,我們也可以用它來連結多個 React Router。在我們的微型前端中,我們像這樣初始化 Router

<Router history={this.props.history}>

在這種情況下,我們不是讓 React Router 實例化另一個歷史記錄物件,而是提供容器應用程式傳遞的實例。所有 `<Router>` 實例現在都已連線,因此在其中任何一個觸發的路由變更,都會反映在所有實例中。這讓我們可以透過 URL,輕鬆地從一個微型前端傳遞「參數」到另一個微型前端。例如,在瀏覽微型前端中,我們有一個像這樣的連結

<Link to={`/restaurant/${restaurant.id}`}>

當按一下這個連結時,路由會在容器中更新,容器會看到新的 URL,並判斷應該安裝和呈現餐廳微型前端。該微型前端自己的路由邏輯會從 URL 中擷取餐廳 ID,並呈現正確的資訊。

希望此範例流程能展現 URL 的靈活性和強大功能。除了可用於分享和加入書籤外,在這個特定架構中,它可以是跨微型前端傳達意圖的有用方式。使用頁面 URL 來達到此目的,符合許多條件

  • 其結構為定義良好的開放標準
  • 頁面上的任何程式碼都可以全球存取
  • 其大小有限,鼓勵只傳送少量資料
  • 它面向使用者,鼓勵建立忠實反映網域的結構
  • 它是宣告式的,而非命令式的。亦即「我們在這裡」,而非「請執行這項工作」
  • 它迫使微型前端間接通訊,且不直接了解或依賴彼此

當使用路由作為微型前端間的通訊模式時,我們選擇的路由構成一個合約。在本例中,我們已將餐廳可以在 /restaurant/:restaurantId 檢視的想法寫死,且我們無法變更該路由,除非更新所有參考它的應用程式。考量到此合約的重要性,我們應該有自動化測試來檢查是否遵守合約。

共用內容

雖然我們希望我們的團隊和微型前端盡可能獨立,但有些事情應該是共通的。我們之前寫過關於共用元件函式庫如何協助微型前端保持一致性,但對於這個小型示範,元件函式庫會過於複雜。因此,我們有一個共用內容小型儲存庫,包括影像、JSON 資料和 CSS,透過網路提供給所有微型前端。

我們可以選擇在微型前端間共用另一件事:函式庫相依性。正如我們將稍後說明,相依性重複是微型前端的常見缺點。儘管在應用程式間共用這些相依性會帶來一些困難,但對於此示範應用程式,值得討論如何執行。

第一步是選擇要共用的相依性。對我們的編譯程式碼進行快速分析顯示,約有 50% 的套件是由 reactreact-dom 提供。除了大小之外,這兩個函式庫是我們最「核心」的相依性,因此我們知道所有微型前端都可以從萃取它們中受益。最後,這些是穩定、成熟的函式庫,通常會在兩個主要版本間引入重大變更,因此跨應用程式升級工作不應太困難。

至於實際萃取,我們只需要在 webpack 設定中將函式庫標記為外部,我們可以使用類似於先前說明的重新連接來執行此動作。

module.exports = (config, env) => {
  config.externals = {
    react: 'React',
    'react-dom': 'ReactDOM'
  }
  return config;
};

然後我們在每個 index.html 檔案中新增幾個 script 標籤,從我們的共用內容伺服器擷取兩個函式庫。

<body>
  <noscript>
    You need to enable JavaScript to run this app.
  </noscript>
  <div id="root"></div>
  <script src="%REACT_APP_CONTENT_HOST%/react.prod-16.8.6.min.js"></script>
  <script src="%REACT_APP_CONTENT_HOST%/react-dom.prod-16.8.6.min.js"></script>
</body>

跨團隊共用程式碼始終是一件棘手的事情。我們需要確保我們只共用我們真正希望共用且希望一次在多個地方變更的事物。但是,如果我們小心處理我們共用和不共用的內容,那麼我們可以獲得真正的優點。

基礎架構

此應用程式架設在 AWS 上,核心基礎架構(S3 儲存區、CloudFront 分配、網域、憑證等)全部使用集中式儲存庫的 Terraform 程式碼一次性提供。然後,每個微型前端都有自己的原始碼儲存庫,並在Travis CI上使用自己的持續部署管線,將其靜態資源建置、測試和部署到這些 S3 儲存區。這平衡了集中式基礎架構管理的便利性和獨立部署的靈活性。

請注意,每個微前端(和容器)都會有自己的儲存區。這表示它可以自由控制儲存區內的內容,而我們不用擔心物件名稱衝突,或來自其他團隊或應用程式的存取管理規則衝突。

缺點

在本文開頭,我們提到微前端有取捨,就像任何架構一樣。我們提到的好處確實有代價,我們將在此說明。

酬載大小

獨立建置的 JavaScript 程式集可能會導致常見相依項重複,增加我們必須透過網路傳送給最終使用者的位元組數。例如,如果每個微前端都包含自己的 React 副本,那麼我們就會強迫我們的客戶下載 React n 次。網頁效能和使用者參與/轉換之間有直接關係,而且世界上許多地方的網路基礎設施運作速度遠低於高度開發城市所習慣的速度,因此我們有許多理由重視下載大小。

這個問題不容易解決。我們希望讓團隊獨立編譯他們的應用程式,以便他們可以自主作業,以及我們希望以一種讓他們可以共用常見相依項的方式來建置我們的應用程式,這兩者之間存在著固有的緊張關係。一種方法是將常見相依項從我們的編譯程式集中外化,如我們為示範應用程式所述。不過,一旦我們走上這條路,我們就重新將一些建置時間耦合引入我們的微前端。現在它們之間有一個隱含的合約,內容為「我們都必須使用這些相依項的這些確切版本」。如果相依項中有重大變更,我們最終可能需要進行大型協調升級工作和一次性的同步發布事件。這正是我們一開始使用微前端想要避免的一切!

這種固有的緊張關係很困難,但並非都是壞消息。首先,即使我們選擇不處理重複的相依項,每個個別網頁仍然有可能比我們建置單一巨型前端時載入得更快。原因在於,透過獨立編譯每個網頁,我們有效地實作了我們自己的程式碼分割形式。在傳統的巨集中,當應用程式中的任何網頁載入時,我們通常會一次下載所有網頁的原始碼和相依項。透過獨立建置,任何單一網頁載入只會下載該網頁的原始碼和相依項。這可能會導致更快的初始網頁載入,但隨著使用者被迫在每個網頁上重新下載相同的相依項,後續導覽會變慢。如果我們有紀律地不讓我們的微前端過度膨脹不必要的相依項,或者如果我們知道使用者通常只會在應用程式中堅持使用一或兩個網頁,我們很可能會在效能方面獲得淨收益,即使有重複的相依項。

前一段落中有許多「可能」和「或許」,這凸顯了一個事實,即每個應用程式永遠都有其獨特的效能特性。如果您想要確實知道特定變更的效能影響,沒有任何方法能取代執行實際測量,理想情況是在製作環境中進行。我們看過團隊為額外的幾 KB JavaScript 焦頭爛額,結果卻下載了數 MB 的高解析度影像,或對非常緩慢的資料庫執行昂貴的查詢。因此,雖然考量每個架構決策的效能影響很重要,但請務必知道真正的瓶頸在哪裡。

環境差異

我們應該能夠開發單一微前端,而不用思考其他團隊開發的所有其他微前端。我們甚至可以在空白頁面上以「獨立」模式執行我們的微前端,而不是在製作環境中容納它的容器應用程式內部執行。這可以讓開發變得更簡單,特別是當實際容器是複雜的舊式程式碼庫時,而這通常是我們使用微前端從舊世界逐漸移轉到新世界時的情況。但是,在與製作環境截然不同的環境中進行開發存在風險。如果我們的開發時間容器的行為與製作容器不同,那麼我們可能會發現我們的微前端已損毀,或在我們部署到製作環境時行為不同。特別令人擔憂的是容器或其他微前端可能帶來的全域樣式。

此處的解決方案與我們必須擔心環境差異的任何其他情況並無不同。如果我們在非製作環境中進行本地開發,我們需要確保定期整合並將我們的微前端部署到類似製作環境的環境中,並且我們應該在這些環境中進行測試(手動和自動),以盡早發現整合問題。這不會完全解決問題,但最終這是我們必須權衡的另一個取捨:簡化開發環境的生產力提升是否值得承擔整合問題的風險?答案將取決於專案!

營運和治理複雜性

最後一個缺點與微服務有直接的平行關係。作為一種更分散的架構,微前端不可避免地會導致需要管理更多「東西」 - 更多儲存庫、更多工具、更多建置/部署管線、更多伺服器、更多網域等。因此,在採用此類架構之前,您應該考慮幾個問題

  • 您是否有足夠的自動化機制,可行地提供並管理額外的所需基礎架構?
  • 您的前端開發、測試和發布流程是否能擴展到許多應用程式?
  • 您是否能接受有關工具和開發實務的決策變得更加分散且較不可控?
  • 您將如何確保在許多獨立的前端程式碼庫中,達到最低程度的品質、一致性或治理?

我們可能可以再填寫另一整篇文章來討論這些主題。我們想提出的重點是,當您選擇微型前端時,您在定義上選擇建立許多小東西,而不是一個大東西。您應該考慮您是否有技術和組織成熟度,可以在不造成混亂的情況下採用這種方法。

結論

隨著前端程式碼庫在這些年來持續變得更複雜,我們看到對更多可擴充架構的需求不斷增加。我們需要能夠畫出明確的界線,以建立技術和網域實體之間適當程度的耦合和凝聚力。我們應該能夠在獨立的自主團隊中擴充軟體交付。

雖然遠非唯一的方法,但我們已經看到許多實際案例,微型前端提供了這些好處,而且我們能夠隨著時間的推移逐漸將此技術應用於舊有程式碼庫以及新的程式碼庫。無論微型前端是否適合您和您的組織,我們只能希望這將成為持續趨勢的一部分,其中前端工程和架構會受到我們所知的重視。


致謝

非常感謝 Charles Korn、Andy Marks 和 Willem Van Ketwich 的徹底審查和詳細的回饋。

也要感謝 Bill Codding、Michael Strasser 和 Shirish Padalkar 在 Thoughtworks 內部郵件清單上提供的意見。

也要感謝 Martin Fowler 的回饋,以及讓這篇文章在他的網站上有一個家。

最後,感謝 Evan Bottcher 和 Liauw Fendy 的鼓勵和支持。

重大修訂

2019 年 6 月 19 日:發布缺點的最終分期付款

2019 年 6 月 17 日:發布包含範例的分期付款

2019 年 6 月 13 日:發布包含從樣式設定到測試的章節的分期付款。

2019 年 6 月 11 日:發布有關整合方法的分期付款。

2019 年 6 月 10 日:發布第一期:涵蓋好處