詳解微前端

好的前端開發很難。擴展前端開發,使許多團隊可以同時處理大型複雜產品,這變得更加困難。在本文中,我們將描述將前端整體拆分成許多更小,更易管理的片段的最新趨勢,以及該體系結構如何提高處理前端代碼的團隊的效率和效率。在討論各種收益和成本的同時,我們還將介紹一些可用的實現選項,並且將深入研究一個演示該技術的完整示例應用程序。

近年來,微服務已迅速普及,許多組織都使用這種架構風格來避免大型,整體後端的侷限性。儘管有關構建服務器端軟件這種風格的文章已很多,但許多公司仍在與整體式前端代碼庫作鬥爭。

也許您想構建一個漸進式或響應式Web應用程序,但是找不到一個輕鬆的地方來開始將這些功能集成到現有代碼中。也許您想開始使用新的JavaScript語言功能(或可以編譯爲JavaScript的多種語言之一),但是您無法在現有的構建過程中使用必要的構建工具。或者,也許您只是想擴展您的開發,以便多個團隊可以同時處理一個產品,但是現有整體中的耦合和複雜性意味着每個人都在互相踩腳。這些都是真正的問題,都會對您有效地向客戶提供高質量體驗的能力產生負面影響。

最近,我們看到越來越多的注意力集中在複雜的現代Web開發所必需的總體體系結構和組織結構上。特別是,我們看到了將前端整體分解爲更小,更簡單的塊的模式,這些塊可以獨立開發,測試和部署,同時仍然對客戶而言是一個具有凝聚力的產品。我們稱這種技術爲微前端,我們將其定義爲:

“一種架構風格,可獨立交付的前端應用程序組成了一個更大的整體”

在ThoughtWorks技術雷達的2016年11月號中,我們列出了微前端作爲組織應評估的一種技術。後來我們將其推廣到試用版,最後推廣到採用,這意味着我們認爲它是一種行之有效的方法,應在合理的情況下使用。

圖1:微前端已經多次出現在技術雷達上。

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

  • 較小,更緊密和可維護的代碼庫
  • 解耦的自主團隊可擴展性更高的組織
  • 能夠以比以前更多的增量方式升級,更新甚至重寫前端的功能

這些頭條新聞優勢與微服務可以提供的某些優勢並非偶然。

當然,涉及軟件體系結構時不會有免費的午餐-一切都是有代價的。一些微前端實現可能導致依賴關係重複,從而增加了用戶必須下載的字節數。此外,團隊自主權的急劇增加可能會導致團隊工作方式分散。儘管如此,我們認爲可以控制這些風險,而且微前端的收益往往超過成本。

好處

我們沒有按照特定的技術方法或實施細節來定義微觀前端,而是將重點放在了出現的屬性和它們帶來的好處上。

增量升級

對於許多組織而言,這是其微前端之旅的開始。過去的技術堆棧或在交付壓力下編寫的代碼阻礙了舊的,大型的前端組件的發展,目前正進行着完全重寫的嘗試。爲了避免完全重寫的危險,我們更希望逐個扼殺舊的應用程序,與此同時,繼續爲我們的客戶提供新功能,而不會受到整體功能的影響。

這通常會導致建立微前端架構。一旦一個團隊經歷了將功能一直投入生產且幾乎不對舊世界進行任何修改的經驗,其他團隊也將希望加入新世界。仍然需要維護現有代碼,在某些情況下,繼續爲其添加新功能可能是有意義的,但是現在可以選擇了。

最終的結果是,我們有更大的自由可以對產品的各個部分進行逐案決策,並對我們的體系結構,依賴關係和用戶體驗進行增量升級。如果我們的主框架發生了重大的重大變化,那麼每個微前端都可以在有意義的時候進行升級,而不必被迫停止世界並立即升級所有內容。如果我們想嘗試新技術或新的交互方式,則可以比以前更孤立的方式進行。

簡單,解耦的代碼庫

根據定義,每個單獨的微前端的源代碼都將比單個整體前端的源代碼小得多。這些較小的代碼庫對於開發人員而言更趨於簡單和容易。尤其是,我們避免了彼此不瞭解的組件之間無意和不適當的耦合所引起的複雜性。通過在應用程序的有界上下文周圍繪製粗線,我們使這種偶然的耦合變得更加困難。

當然,一個單一的高層體系結構決策(即“讓我們去做微前端”)不能替代老式的乾淨代碼。我們並非試圖免除自己對代碼的思考,並努力提高其質量。相反,我們試圖通過艱難地做出錯誤的決定,而容易做出好的決定來使自己陷入成功的陷阱。例如,跨有限上下文共享域模型變得更加困難,因此開發人員這樣做的可能性較小。同樣,微前端可以使您明確和審慎地瞭解數據和事件在應用程序不同部分之間的流動方式,無論如何,這是我們應該做的事情!

獨立部署

就像微服務一樣,微前端的獨立部署能力是關鍵。這減小了任何給定部署的範圍,進而降低了相關的風險。無論前端代碼的託管方式或託管位置如何,每個微前端都應具有自己的連續交付管道,該管道將在整個生產過程中對其進行構建,測試和部署。我們應該能夠在不考慮其他代碼庫或管道的當前狀態的情況下部署每個微前端。不管舊的整體式設備是否處於固定的,手動的,每季度發佈的週期,或者隔壁的團隊是否已將半完成或損壞的功能推送到其主分支中,都沒有關係。如果給定的微前端準備好投入生產,那麼它應該能夠進行生產,並且該決定應由構建和維護它的團隊來決定。

圖2:每個微前端都獨立部署到生產中

自治團隊

作爲將我們的代碼庫和發佈週期解耦的更高階優勢,我們對於擁有完全獨立的團隊還有很長的路要走,他們可以擁有從構思到生產再到整個產品的一部分。團隊可以完全擁有爲客戶創造價值所需的一切,從而使他們能夠快速有效地行動。爲此,我們的團隊需要圍繞業務功能的垂直部分而不是技術能力組成。一種簡單的方法是根據最終用戶將看到的產品來精簡產品,因此每個微前端都封裝了應用程序的單個頁面,並由一個團隊端到端擁有。這比團隊圍繞技術或“水平”問題(如樣式,形式或驗證)組成團隊時,具有更高的團隊凝聚力。

圖3:每個應用程序應由一個團隊擁有

簡而言之

簡而言之,微前端就是將大而恐怖的東西切成更小,更易於管理的部分,然後明確地說明它們之間的依賴關係。我們的技術選擇,我們的代碼庫,我們的團隊以及我們的發佈流程都應該能夠彼此獨立地運行和發展,而無需過多的協調。


這個例子

想象一下一個網站,客戶可以在該網站上訂購要交付的食物。從表面上看,這是一個非常簡單的概念,但是如果您想做得好,會有很多令人驚訝的細節:

  • 應該有一個登陸頁面,客戶可以在其中瀏覽和搜索餐館。這些餐廳應該可以通過任何數量的屬性進行搜索和過濾,包括價格,美食或客戶先前訂購的內容
  • 每個餐廳都需要有自己的頁面,顯示其菜單項,並允許客戶選擇自己想喫的東西,折扣,餐飲優惠和特殊要求
  • 客戶應該有一個個人資料頁面,他們可以在其中查看其訂單歷史記錄,跟蹤交貨以及自定義其付款方式

圖4:一個食品配送網站可能會有幾個相當複雜的頁面

每個頁面都有足夠的複雜性,因此我們可以輕鬆地爲每個頁面辯護一個專門的團隊,並且每個團隊都應該能夠獨立於所有其他團隊而在其頁面上工作。他們應該能夠開發,測試,部署和維護其代碼,而不必擔心與其他團隊的衝突或協調。但是,我們的客戶仍然應該看到一個無縫的網站。

在本文的其餘部分中,我們將在需要示例代碼或場景的任何地方使用該示例應用程序。


整合方法

鑑於上面的定義相當寬鬆,可以合理地將許多方法稱爲微前端。在本節中,我們將顯示一些示例並討論它們的取捨。所有方法都有一個相當自然的架構-通常,應用程序中的每個頁面都有一個微前端,並且有一個容器應用程序,該容器可以:

  • 呈現常見的頁面元素,例如頁眉和頁腳
  • 解決認證和導航等跨領域問題
  • 將各種微前端集中到頁面上,並告訴每個微前端何時以及在何處進行渲染

圖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來提供此文件,並$PAGE通過與所請求的URL進行匹配來配置變量:

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服務器上,但是假設它們各自具有自己的部署管道,這使我們可以將更改部署到一個頁面上而不會影響或考慮其他頁面。

爲了獲得更大的獨立性,可以有一個單獨的服務器負責渲染和服務每個微前端,其中一個服務器位於前端,向其他服務器發出請求。通過仔細地緩存響應,可以在不影響延遲的情況下完成此操作。

圖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>

就像使用服務器端include選項一樣,從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組件規範,並且喜歡使用瀏覽器提供的功能的想法,那麼這是一個不錯的選擇。如果您希望在容器應用程序和微前端之間定義自己的接口,那麼您可能更喜歡前面的示例。


Styling

CSS作爲一種語言固有地是全局的,繼承的和級聯的,傳統上沒有模塊系統,命名空間或封裝。這些功能中的某些功能現在確實存在,但通常缺乏瀏覽器支持。在微前端環境中,許多問題都變得更加嚴重。例如,如果一個團隊的微前端的樣式表爲h2 { color: black; },而另一個團隊的則爲h2 { color: blue; },而這兩個選擇器都附加在同一頁面上,那麼某個人會很失望的!這不是一個新問題,但是由於這些選擇器是由不同的團隊在不同的時間編寫的,並且使代碼可能分散在不同的存儲庫中,因此使發現變得更加困難,這使情況變得更糟。

多年來,已經發明瞭許多方法來使CSS更易於管理。有些選擇使用嚴格的命名約定,例如BEM,以確保選擇器僅在需要的地方應用。其他一些人則不想單獨依賴開發人員紀律,而是使用預處理器,例如SASS,其選擇器嵌套可以用作命名空間的一種形式。一種較新的方法是通過CSS模塊或各種CSS-in-JS庫之一以編程方式應用所有樣式,以確保僅將樣式直接應用於開發人員想要的位置。或者,對於更基於平臺的方法,shadow DOM還提供了樣式隔離。

只要您找到一種方法來確保開發人員可以彼此獨立地編寫樣式,並確信將其代碼組合到一個應用程序中便可以預測其行爲,那麼您選擇的方法就沒什麼大不了的。


共享組件庫

上面我們提到,跨微前端的視覺一致性很重要,一種解決方法是開發一個共享的,可重複使用的UI組件庫。總的來說,我們認爲這是一個好主意,儘管很難做到。創建這樣一個庫的主要好處是通過重複使用代碼減少了工作量,並實現了視覺一致性。此外,您的組件庫可以充當生活風格指南,並且可以是開發人員和設計師之間進行協作的重要方面。

最容易出錯的事情之一就是太早地創建了太多這些組件。試圖創建一個Foundation Framework,並具有所有應用程序所需的所有常見視覺效果。但是,經驗告訴我們,在現實世界中使用組件之前,很難(即使不是不可能)猜測組件的API應該是什麼,這會導致組件的早期使用大量混亂。因此,我們希望讓團隊根據需要在代碼庫中創建自己的組件,即使這最初會導致某些重複。允許模式自然出現,並且一旦組件的API變得很明顯,您就可以將重複的代碼收集到共享庫中,並確信您已經證明了這一點。

共享最明顯的候選對象是“啞”的視覺原語,例如圖標,標籤和按鈕。我們還可以共享更復雜的組件,這些組件可能包含大量的UI邏輯,例如自動完成的下拉搜索字段。或可排序,可過濾的分頁表格。但是,請注意確保共享的組件僅包含UI邏輯,而不包含業務或域邏輯。將域邏輯放入共享庫後,它將在應用程序之間建立高度的耦合,並增加了更改的難度。因此,例如,您通常不應該嘗試共享一個ProductTable,其中包含有關“產品”的確切含義和行爲方式的各種假設。這樣的域建模和業務邏輯屬於微前端的應用程序代碼,而不是共享庫中。

與任何共享內部庫一樣,圍繞其所有權和治理也存在一些棘手的問題。一種模式是說,“所有人”都擁有它作爲共享資產,儘管實際上這通常意味着沒有人擁有它。如果沒有明確的約定或技術遠見,它很快就會成爲不一致代碼的大雜燴。在另一個極端,如果完全集中共享庫的開發,則在創建組件的人員和使用這些組件的人員之間將存在很大的脫節。我們看到的最好的模型是任何人都可以爲圖書館做出貢獻的模型,但是有一個託管人(一個人或一個團隊)負責確保這些貢獻的質量,一致性和有效性。維護共享庫的工作需要強大的技術技能,但也需要培養許多團隊之間的協作所必需的人員技能。


跨應用程序通信

關於微前端的最常見問題之一是如何讓它們彼此交談。通常,我們建議讓他們儘可能少地進行交流,因爲這通常會重新引入我們一開始要避免的那種不適當的耦合。

也就是說,經常需要某種程度的跨應用程序通信。定製事件允許微前端進行間接通信,這是使直接耦合最小化的一種好方法,儘管這樣做確實使確定和執行微前端之間存在的合同變得更加困難。另外,向下傳遞迴調和數據(在這種情況下,從容器應用程序向下傳遞到微前端)的React模型也是使合同更加明確的一種很好的解決方案。第三種選擇是使用地址欄作爲一種通信機制,我們將在後面詳細探討。

如果您使用的是redux,則通常的方法是爲整個應用程序使用單個全局共享存儲。但是,如果每個微前端都應該是自己的獨立應用程序,那麼每個微前端都有自己的redux存儲是有意義的。Redux文檔甚至提到“將Redux應用程序隔離爲更大的應用程序中的組件”是擁有多個商店的有效理由。

無論我們選擇哪種方法,我們都希望我們的微前端通過彼此發送消息或事件進行通信,並避免具有任何共享狀態。就像跨微服務共享數據庫一樣,一旦我們共享數據結構和域模型,我們就會創建大量的耦合,並且進行更改變得極爲困難。

與樣式一樣,這裏有幾種不同的方法可以很好地起作用。最重要的是,要認真思考正在引入的耦合類型,以及隨着時間的推移如何維護該合同。就像微服務之間的集成一樣,如果沒有跨不同應用程序和團隊的協調升級過程,您將無法對集成進行重大更改。

您還應該考慮如何自動驗證集成沒有中斷。功能測試是一種方法,但是由於實現和維護它們的成本,我們更傾向於限制編寫的功能測試的數量。或者,您可以實施某種形式的消費者驅動的合同,以便每個微前端可以指定它對其他微前端的要求,而無需實際將它們全部集成在一起並在瀏覽器中運行。


後端通訊

如果我們有獨立的團隊在前端應用程序上獨立工作,那麼後端開發又如何呢?我們堅信全棧團隊的價值,他們擁有從可視代碼一直到API開發以及數據庫和基礎結構代碼的所有應用程序開發。一種在這裏有用的模式是BFF模式,其中每個前端應用程序都有一個相應的後端,其目的僅僅是爲了滿足該前端的需求。雖然BFF模式最初可能意味着每個前端通道(Web,移動等)的專用後端,但可以輕鬆擴展爲每個微前端的後端。

這裏有很多變量要說明。 BFF可能是獨立包含其自己的業務邏輯和數據庫的,也可能只是下游服務的聚合器。如果有下游服務,則擁有微前端及其BFF的團隊也擁有其中一些服務可能沒有意義。如果微前端只有一個與之通信的API,並且該API相當穩定,那麼構建BFF可能根本沒有太大價值。這裏的指導原則是,構建特定的微前端的團隊不必等待其他團隊爲他們構建事物。因此,如果添加到微前端的每個新功能也需要後端更改,那麼對於由同一團隊擁有的BFF來說,這就是一個很好的例子。

圖7:有很多不同的方式來構建前端/後端關係

另一個常見的問題是,微前端應用程序的用戶應如何通過服務器進行身份驗證和授權?顯然,我們的客戶只需要對自己進行一次身份驗證,因此授權通常完全屬於應該由容器應用程序擁有的橫切關注點類別。容器可能具有某種登錄形式,我們可以通過該登錄形式獲得某種令牌。該令牌將歸容器所有,並可以在初始化時注入到每個微前端中。最後,微前端可以將令牌及其發出的任何請求發送到服務器,服務器可以執行所需的任何驗證。


測試

在測試方面,我們看不到單片前端和微前端之間的太大區別。通常,用於測試單片前端的任何策略都可以在每個單獨的微前端上重現。也就是說,每個微前端都應具有自己的全面的自動化測試套件,以確保代碼的質量和正確性。

顯而易見的差距是容器應用程序對各種微前端的集成測試。可以使用您首選的功能/端到端測試工具(例如Selenium或Cypress)來完成此操作,但是不要太過分。功能測試應該只涵蓋無法在較低的測試金字塔水平上進行測試的方面。意思是說,使用單元測試來覆蓋您的低級業務邏輯和呈現邏輯,然後使用功能測試來驗證頁面是否正確組裝。例如,您可以在特定的URL上加載完全集成的應用程序,並斷言頁面上存在相關的微前端的硬編碼標題。

如果存在跨越微前端的用戶旅程,那麼您可以使用功能測試來涵蓋這些旅程,但是將功能測試的重點放在驗證前端的集成上,而不是在每個微前端的內部業務邏輯上進行驗證被單元測試所覆蓋。如上所述,消費者驅動的合同可以幫助直接指定微前端之間發生的交互,而不會造成集成環境和功能測試的脆弱性。


詳細的例子

本文的其餘大部分內容將僅對示例應用程序的一種實現方式進行詳細說明。我們將主要關注容器應用程序和微前端如何使用JavaScript集成在一起,因爲這可能是最有趣和最複雜的部分。您可以在
https://demo.microfrontends.com上實時查看最終部署的結果,完整的源代碼可以在Github上看到。

圖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"
}

在版本1中react-scripts,可能有多個應用程序共存於一個頁面上而沒有衝突,但是版本2使用了一些webpack功能,當兩個或多個應用程序試圖在一個頁面上呈現自己時,這些功能會導致錯誤。因此,我們使用react-app-rewired覆蓋的一些內部webpack配置react-scripts。這樣可以解決這些錯誤,並讓我們繼續依靠它react-scripts來管理構建工具。

從依賴關係react和react-scripts,我們可以得出結論,這是與創建React.js應用create-react-app。更有趣的是沒有什麼:我們將一起組成最終應用程序的任何微前端的提及。如果我們在這裏將它們指定爲庫依賴項,那麼我們將走在構建時集成的道路上,如前所述,構建時集成往往會在我們的發佈週期中引起問題耦合。

要查看如何選擇和顯示微前端,讓我們看一下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。在Browse和Restaurant組件是這樣的:

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


在這兩種情況下,我們都渲染一個MicroFrontend組件。除了歷史記錄對象(稍後將變得很重要)之外,我們還指定應用程序的唯一名稱,以及可以從中下載其捆綁軟件的主機。此配置驅動的URL類似於http://localhost: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對於微前端是唯一的。這是我們告訴微前端進行渲染的地方。我們使用ReactcomponentDidMount作爲下載和安裝微前端的觸發器:

componentDidMount 是React組件的生命週期方法,在第一次將組件實例“安裝”到DOM後,框架便會調用該方法。

類 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);
      });
  }

componentDidMount 是React組件的生命週期方法,在第一次將組件實例“安裝”到DOM後,框架便會調用該方法。

首先,我們檢查是否已經下載了具有唯一ID的相關腳本,在這種情況下,我們可以立即對其進行渲染。如果不是,我們asset-manifest.json從適當的主機獲取文件,以查找主腳本資產的完整URL。設置腳本的URL後,剩下的就是將其附加到文檔,並帶有一個onload呈現微前端的處理程序:

我們必須從資產清單文件中獲取腳本的URL,因爲react-scripts輸出的編譯JavaScript文件的文件名中帶有哈希值以方便緩存。

類 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>

圖9:每個微前端都可以在容器外部作爲獨立的應用程序運行。

從現在開始,微前端大多隻是普通的舊React應用程序。在“瀏覽”應用程序讀取的從後端的餐館列表,提供<input>搜索和過濾餐廳元素,並呈現陣營路由器<Link>元素,導航到特定餐廳。到那時,我們將切換到第二個“訂單”微前端,該前端將顯示一個帶有菜單的餐廳。

圖10:這些微前端僅通過路由更改進行交互,而不是直接進行交互

關於我們的微前端,最後值得一提的是它們都styled-components用於所有樣式。通過CSS-in-JS庫,可以輕鬆地將樣式與特定組件相關聯,因此我們保證微前端的樣式不會泄漏並影響容器或其他微前端。

通過路由進行跨應用程序通信

前面我們提到過,應將跨應用程序通信保持在最低限度。在此示例中,我們唯一的要求是瀏覽頁面需要告訴餐廳頁面要加載哪個餐廳。在這裏,我們將看到如何使用客戶端路由來解決此問題。

這裏涉及的所有三個React應用程序都使用React Router進行聲明式路由,但是以兩種略有不同的方式進行初始化。對於容器應用程序,我們創建一個<BrowserRouter>,它會在內部實例化一個history對象。這是history我們之前討論過的相同對象。我們使用該對象來處理客戶端歷史記錄,也可以使用它來將多個React 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%的捆綁包是由react和貢獻的react-dom。除了它們的大小之外,這兩個庫是我們最“核心”的依賴項,因此我們知道所有微前端都可以從提取它們中受益。最後,它們是穩定,成熟的庫,通常會在兩個主要版本中引入重大更改,因此跨應用程序升級的工作應該不會太困難。

至於實際的提取,我們需要做的就是在我們的webpack配置中將庫標記爲外部庫,我們可以通過與前面所述類似的重新佈線來完成。

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

然後,我們script向每個index.html文件添加幾個標籤,以從共享內容服務器中獲取兩個庫。

<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>

在團隊之間共享代碼始終是一件棘手的事情。我們需要確保我們只共享我們真正希望成爲共同的東西,並且希望一次在多個地方進行更改。但是,如果我們對共享的內容和不共享的內容保持謹慎,則將獲得真正的好處。

基礎設施

該應用程序託管在具有核心基礎架構(S3存儲桶,CloudFront發行版等)的AWS上,並使用Terraform代碼的集中式存儲庫一次進行配置。然後,每個微前端都有自己的源存儲庫,並在Travis CI上具有自己的連續部署管道,該管道將靜態資產構建,測試並部署到這些S3存儲桶中。這在集中式基礎架構管理的便利性與獨立部署性的靈活性之間取得了平衡。

請注意,每個微前端(和容器)都有自己的存儲桶。這意味着它可以自由支配其中的內容,而我們不必擔心來自另一個團隊或應用程序的對象名稱衝突或訪問管理規則衝突。


缺點

在本文的開頭,我們提到了與任何前端一樣的微前端折衷。我們提到的好處確實伴隨着成本,我們將在這裏介紹。

有效負載大小

獨立構建的JavaScript捆綁包可能導致重複的公共依賴關係,從而增加了我們必須通過網絡發送給最終用戶的字節數。例如,如果每個微前端都包含自己的React副本,那麼我們將迫使客戶下載n次React 。頁面性能和用戶參與/轉換之間存在直接關係,世界上許多地方的互聯網基礎設施運行速度遠比高度發達城市的互聯網基礎設施慢,因此我們有很多理由在乎下載大小。

這個問題不容易解決。在我們希望團隊獨立地編譯應用程序以使其能夠自主工作的渴望與我們在構建我們的應用程序以共享共同依賴關係的願望之間存在着內在的張力。一種方法是從我們的編譯包中外部化常見的依賴關係,如我們所述用於演示應用程序。但是,一旦走上這條路,我們就重新引入了一些構建時耦合到我們的微前端的方法。現在,它們之間存在一個隱式契約,其中規定:“我們所有人都必須使用這些依賴項的這些確切版本”。如果依賴項發生重大變化,我們可能最終需要進行大量的協調升級工作並一次性完成鎖步釋放事件。這就是我們最初嘗試使用微前端時要避免的一切!

這種內在的緊張是一個困難的局面,但這並不是所有的壞消息。首先,即使我們選擇不對重複的依賴項做任何事情,也有可能每個單獨頁面的加載速度都比構建單個整體式前端要快。原因是通過獨立地編譯每個頁面,我們有效地實現了我們自己的代碼分割形式。在經典的Monolith中,當加載應用程序中的任何頁面時,我們通常一次下載所有頁面的源代碼和依賴項。通過獨立構建,任何單個頁面加載都只會下載該頁面的源和依賴項。這可能會導致初始頁面加載速度更快,但隨後的導航速度會變慢,因爲用戶被迫在每個頁面上重新下載相同的依賴項。如果我們的紀律是不要在不必要的依賴項上膨脹我們的微前端,或者如果我們知道用戶通常只停留在應用程序中的一兩個頁面,那麼我們很可能會實現淨收入。即使有重複的依賴關係,也可以提高性能。

上一段中有很多“可能的”和“可能的”,這凸顯了一個事實,即每個應用程序將始終具有自己獨特的性能特徵。如果您想確定特定更改對性能的影響,那麼最好在生產中進行實際測量是無可替代的。我們已經看到團隊苦苦掙扎了超過數千KB的JavaScript,只是去下載許多MB的高分辨率圖像,或者對一個非常慢的數據庫運行昂貴的查詢。因此,儘管考慮每個架構決策對性能的影響很重要,但請確保您知道真正的瓶頸在哪裏。

環境差異

我們應該能夠開發單個微前端,而無需考慮其他團隊正在開發的所有其他微前端。我們甚至可以在空白頁上以“獨立”模式運行微前端,而不是在將其存儲在生產環境中的容器應用程序內部運行。這可以使開發更加簡單,尤其是當實際容器是複雜的舊代碼庫時,當我們使用微前端進行從舊世界到新世界的逐步遷移時,通常就是這種情況。但是,在與生產環境完全不同的環境中進行開發存在風險。如果我們在開發時的容器的行爲與生產時的容器不同,那麼我們可能會發現我們的微前端已損壞,或者在部署到生產中時的行爲有所不同。特別令人關注的是容器或其他微前端可能帶來的全局樣式。

這裏的解決方案與我們不得不擔心環境差異的任何其他情況沒有什麼不同。如果我們在這不是一個環境中本地發展生產樣,我們需要確保我們經常集成和我們的微前端部署到環境在這些環境中,如生產,我們應該做的測試(手動和自動),以儘早發現集成問題。這不能完全解決問題,但是最終這是我們必須權衡的另一個權衡:簡化開發環境的生產率提高是否值得承擔集成問題的風險?答案將取決於項目!

運營和治理複雜性

最後的缺點是與微服務直接相似的缺點。作爲一個分佈更廣泛的體系結構,微前端將不可避免地導致要管理更多的東西-更多的存儲庫,更多的工具,更多的構建/部署管道,更多的服務器,更多的域等。因此在採用這種體系結構之前,您需要提出一些問題應該考慮:

  • 您是否有足夠的自動化措施來可行地配置和管理所需的其他基礎架構?
  • 您的前端開發,測試和發佈過程是否可以擴展到許多應用程序?
  • 您是否對圍繞工具和開發實踐的決策變得更加分散和難以控制感到滿意?
  • 您將如何確保跨多個獨立的前端代碼庫的最低質量,一致性或治理水平?

我們可能還會再寫整篇討論這些主題的文章。我們要提出的主要觀點是,當您選擇微前端時,根據定義,您選擇創建的是許多小東西,而不是一個大東西。您應該考慮是否具備在不造成混亂的情況下采用這種方法所需的技術和組織成熟度。


結論

多年來,隨着前端代碼庫的不斷複雜化,我們看到了對更具可擴展性的體系結構的日益增長的需求。我們需要能夠劃清界限,以建立技術實體和領域實體之間正確的耦合和凝聚力級別。我們應該能夠在獨立的自治團隊之間擴展軟件交付。

儘管遠非唯一的方法,但我們已經看到了許多微前端提供這些好處的實際案例,並且隨着時間的推移,我們已經能夠逐漸將這種技術應用於舊代碼庫和新代碼庫。無論微前端對您和您的組織是否是正確的方法,我們只能希望這將成爲持續趨勢的一部分,在這種趨勢下,前端工程和體系結構將得到我們應有的重視。

 


致謝

非常感謝Charles Korn,Andy Marks和Willem Van Ketwich的詳盡評論和詳細反饋。

也要感謝Bill Codding,Michael Strasser和Shirish Padalkar在ThoughtWorks內部郵件列表中提供的意見。

還要感謝Martin Fowler的反饋,並在他的網站上爲本文提供了家。

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

 

(本文翻譯自Cam Jackson的文章《Micro Frontends》,轉載請註明出處,原文鏈接:https://martinfowler.com/articles/micro-frontends.html)

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