1. 程式人生 > 其它 >微前端(Micro Frontend ) 落地實施的一些具體例子

微前端(Micro Frontend ) 落地實施的一些具體例子

前文微前端概述(Micro Frontends) 以及相比單體應用,微前端能帶來什麼好處 簡單介紹了微前端的概念,本文來看一個具體的應用例子。

原文地址

想象一個網站,客戶可以在其中訂購外賣食品。從表面上看,這是一個相當簡單的概念,但如果你想做得好,還有驚人的細節:

  • 應該有一個登陸頁面,客戶可以在其中瀏覽和搜尋餐館。 餐廳應該可以通過任意數量的屬性進行搜尋和過濾,包括價格、美食或客戶之前訂購的東西

  • 每家餐廳都需要自己的頁面來顯示其選單項,並允許客戶選擇他們想吃的東西,包括折扣、餐飲優惠和特殊要求

  • 客戶應該有一個個人資料頁面,他們可以在其中檢視他們的訂單歷史記錄、跟蹤交付和自定義他們的付款選項

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

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

Integration approaches

鑑於上面相當鬆散的定義,有許多方法可以合理地稱為微前端。 在本節中,我們將展示一些示例並討論它們的權衡。 所有這些方法都有一個相當自然的架構——通常應用程式中的每個頁面都有一個微前端,並且有一個容器應用程式,它:

  • 呈現常見的頁面元素,例如頁首和頁尾
  • 解決跨領域問題,如身份驗證和導航
  • 將各種微前端放在頁面上,並告訴每個微前端何時何地渲染自己

Server-side template composition

我們從一種絕對不新穎的前端開發方法開始——用多個模板或片段在伺服器上渲染 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 伺服器上,但假設它們每個都有自己的部署管道,這允許我們將更改部署到一個頁面,而不會影響或考慮任何其他頁面。

為了獲得更大的獨立性,可以有一個單獨的伺服器負責呈現和服務每個微前端,其中一個伺服器位於前端,向其他伺服器發出請求。 通過仔細快取響應,這可以在不影響延遲的情況下完成。

這個例子展示了微前端不一定是一種新技術,也不一定很複雜。 只要我們小心我們的設計決策如何影響我們的程式碼庫和我們團隊的自治,無論我們的技術堆疊如何,我們都可以獲得許多相同的好處。

Build-time integration

我們有時看到的一種方法是將每個微前端作為一個包釋出,並讓容器應用程式將它們全部包含為庫依賴項。以下是容器的 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 包,允許我們從各種應用程式中刪除常見的依賴項。但是,這種方法意味著我們必須重新編譯和釋出每個微前端,以便釋出對產品任何單個部分的更改。就像微服務一樣,我們已經看到這種鎖步釋出過程造成的痛苦已經夠多了,我們強烈建議不要使用這種微前端方法。

正因為經歷了將我們的應用程式劃分為可以獨立開發和測試的離散程式碼庫的過程中,遇到了這些麻煩,我們決定不要在釋出階段重新引入所有這些耦合。我們應該找到一種方法在執行時而不是在構建時整合我們的微前端。

Run-time integration via iframes

在瀏覽器中組合應用程式的最簡單方法之一是不起眼的 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 有點“糟糕”所驅動的,但人們有一些很好的理由避免使用它們。 上面提到的簡單隔離確實會使它們不如其他選項靈活。在應用程式的不同部分之間構建整合可能很困難,因此它們使路由、歷史記錄和深層連結更加複雜,並且它們為使您的頁面完全響應提出了一些額外的挑戰。

Run-time integration via 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 包,或者在渲染微前端時傳入和傳出資料。

這種方法的靈活性與獨立的可部署性相結合,使其成為我們的預設選擇,也是我們在實際專案中最常看到的選擇。

Run-time integration via Web Components

前一種方法的一個變體是為每個微前端定義一個 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 style

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

多年來,人們發明了許多方法來使 CSS 更易於管理。有些選擇使用嚴格的命名約定,例如 BEM,以確保選擇器僅適用於預期的地方。其他不喜歡單獨依賴開發人員紀律的人使用前處理器,例如 SASS,其選擇器巢狀可用作名稱空間的一種形式。一種較新的方法是使用 CSS 模組或各種 CSS-in-JS 庫之一以程式設計方式應用所有樣式,以確保僅在開發人員想要的位置直接應用樣式。或者對於更基於平臺的方法,shadow DOM 還提供樣式隔離。

您選擇的方法並不重要,只要您找到一種方法來確保開發人員可以獨立地編寫他們的樣式,並確信他們的程式碼在組合成單個應用程式時的行為是可預測的。

Shared component libraries

我們在上面提到過微前端的視覺一致性很重要,一種方法是開發一個共享的、可重用的 UI 元件庫。總的來說,我們認為這是一個好主意,雖然很難做好。建立這樣一個庫的主要好處是通過重用程式碼和視覺一致性來減少工作量。此外,您的元件庫可以作為一個生動的樣式​​指南,它可以成為開發人員和設計人員之間的一個很好的協作點。

最容易出錯的事情之一就是過早地建立過多的這些元件。建立一個具有所有應用程式所需的所有公共視覺效果的基礎框架是很誘人的。但是,經驗告訴我們,在實際使用元件之前,很難甚至不可能猜測它們的 API 應該是什麼,這會導致元件早期生命週期中的大量流失。出於這個原因,我們更願意讓團隊根據需要在他們的程式碼庫中建立自己的元件,即使這最初會導致一些重複。讓模式自然出現,一旦元件的 API 變得明顯,您就可以將重複的程式碼收集到共享庫中,並確信您已經證明了一些東西。

最明顯的共享候選物件是“愚蠢的”視覺原語,例如圖示、標籤和按鈕。我們還可以共享可能包含大量 UI 邏輯的更復雜的元件,例如自動完成的下拉搜尋欄位。或者一個可排序、可過濾、分頁的表格。但是,請注意確保您的共享元件僅包含 UI 邏輯,而不包含業務或域邏輯。當域邏輯被放入共享庫時,它會在應用程式之間產生高度耦合,並增加更改的難度。因此,例如,您通常不應該嘗試共享 ProductTable,其中包含有關“產品”究竟是什麼以及應該如何表現的各種假設。這樣的域建模和業務邏輯屬於微前端的應用程式程式碼,而不是共享庫。

與任何共享的內部庫一樣,其所有權和治理也存在一些棘手的問題。一種模型認為,作為共享資產,“每個人”都擁有它,但實際上這通常意味著沒有人擁有它。它可以很快成為沒有明確約定或技術願景的不一致程式碼的大雜燴。在另一個極端,如果共享庫的開發完全集中,那麼建立元件的人和使用它們的人之間就會出現很大的脫節。我們見過的最好的模型是任何人都可以為圖書館做出貢獻的模型,但有一個保管人(一個人或一個團隊)負責確保這些貢獻的質量、一致性和有效性。維護共享庫的工作需要強大的技術技能,還需要培養跨多個團隊的協作所需的人員技能。

Cross-application communication

關於微前端最常見的問題之一是如何讓它們相互互動。一般而言,我們建議讓他們儘可能少地互動,因為這通常會重新引入我們最初試圖避免的那種不適當的耦合。

也就是說,通常需要某種程度的跨應用程式通訊。自定義事件允許微前端間接通訊,這是最小化直接耦合的好方法,儘管它確實使確定和執行微前端之間存在的契約變得更加困難,想想 SAP Spartacus Popover Component 和 Directive 的事件通訊?或者,向下傳遞迴調和資料的 React 模型(在這種情況下從容器應用程式向下傳遞到微前端)也是一個很好的解決方案,它使合同更加明確。第三種選擇是使用位址列作為通訊機制,稍後我們將更詳細地探討這種機制。

如果您使用的是 redux,通常的方法是為整個應用程式建立一個單一的、全域性的、共享的儲存。然而,如果每個微前端都應該是自己獨立的應用程式,那麼每個微前端都有自己的 redux 儲存是有意義的。 redux 文件甚至提到“將 Redux 應用程式隔離為更大應用程式中的一個元件”作為擁有多個商店的正當理由。

無論我們選擇哪種方法,我們都希望我們的微前端通過相互發送訊息或事件來進行通訊,並避免任何共享狀態。就像跨微服務共享資料庫一樣,一旦我們共享了我們的資料結構和域模型,就會產生大量耦合,並且很難進行更改。

與樣式一樣,有幾種不同的方法可以在這裡很好地工作。最重要的事情是仔細考慮你要引入什麼樣的耦合,以及你將如何隨著時間的推移保持這種契約。就像微服務之間的整合一樣,如果沒有跨不同應用程式和團隊的協調升級過程,您將無法對整合進行重大更改。

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

Backend communication

如果我們有獨立的團隊在前端應用程式上獨立工作,那麼後端開發呢?我們堅信全棧團隊的價值,他們負責從視覺化程式碼到 API 開發以及資料庫和基礎架構程式碼的應用程式開發。在這裡有幫助的一種模式是 BFF 模式,其中每個前端應用程式都有一個相應的後端,其目的只是為了滿足該前端的需求。雖然 BFF 模式最初可能意味著每個前端渠道(網路、移動等)的專用後端,但它可以很容易地擴充套件為每個微前端的後端。

這裡有很多變數需要解釋。 BFF 可能是自包含其自己的業務邏輯和資料庫,或者它可能只是下游服務的聚合器。如果有下游服務,對於擁有微前端及其 BFF 的團隊來說,擁有其中一些服務可能有意義也可能沒有意義。如果微前端只有一個與之通訊的 API,並且該 API 相當穩定,那麼構建 BFF 可能根本沒有多大價值。這裡的指導原則是,構建特定微前端的團隊不應該等待其他團隊為他們構建東西。因此,如果新增到微前端的每個新功能也需要後端更改,那麼對於由同一團隊擁有的 BFF 來說,這是一個強有力的案例。

另一個常見的問題是,微前端應用程式的使用者應該如何通過伺服器進行身份驗證和授權? 顯然,我們的客戶只需要對自己進行一次身份驗證,因此身份驗證通常屬於容器應用程式應擁有的橫切關注點類別。 容器可能有某種登入表單,通過它我們可以獲得某種令牌。 該令牌將由容器擁有,並且可以在初始化時注入到每個微前端。 最後,微前端可以將令牌連同它向伺服器發出的任何請求一起傳送,伺服器可以執行任何需要的驗證。

Testing

在測試方面,我們認為單體前端和微前端之間沒有太大區別。一般來說,您用來測試單體前端的任何策略都可以在每個單獨的微前端中重現。也就是說,每個微前端都應該有自己全面的自動化測試套件,以確保程式碼的質量和正確性。

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

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

Demo 網址:https://demo.microfrontends.com/

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

從對 react 和 react-scripts 的依賴,我們可以得出結論,這是一個使用 create-react-app 建立的 React.js 應用程式。更有趣的內容並沒有在這裡出現在 package.json 的字面上:任何提及我們將組合在一起以形成最終應用程式的微前端。如果我們在這裡將它們指定為庫依賴項,我們將走上構建時整合的道路。如前所述,這往往會在我們的釋出週期中導致耦合問題。

要了解我們如何選擇和顯示微前端,讓我們看看 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 是微前端唯一的。 這是我們將告訴我們的微前端渲染自身的地方。 我們使用 React 的 componentDidMount 作為下載和掛載微前端的觸發器:

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 處理程式:

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

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

在上面的程式碼中,我們呼叫了一個名為 window.renderBrowse 之類的全域性函式,它由我們剛剛下載的指令碼放置在那裡。我們將微前端應該呈現的 main 元素的 ID 和一個歷史物件傳遞給它,我們將很快解釋。這個全域性函式的簽名是容器應用程式和微前端之間的關鍵契約。這是任何通訊或整合應該發生的地方,因此保持相當輕量級使其易於維護,並在未來新增新的微前端。每當我們想要做一些需要更改此程式碼的事情時,我們應該仔細考慮它對我們的程式碼庫的耦合以及合約的維護意味著什麼。

還有最後一件,就是處理清理。當我們的 MicroFrontend 元件解除安裝(從 DOM 中刪除)時,我們也想解除安裝相關的微前端。為此,每個微前端定義了一個相應的全域性函式,我們從相應的 React 生命週期方法中呼叫它:

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

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

就其自身的內容而言,容器直接呈現的只是站點的頂級標題和導航欄,因為它們在所有頁面中都是不變的。 這些元素的 CSS 已被仔細編寫,以確保它只會為標題中的元素設定樣式,因此它不應與微前端中的任何樣式程式碼衝突。

這就是容器應用程式的結束! 這是相當初級的,但這為我們提供了一個 shell,可以在執行時動態下載我們的微前端,並將它們粘合在一起,形成一個頁面上的內聚力。 這些微前端可以一直獨立部署到生產環境,而無需對任何其他微前端或容器本身進行更改。

The micro frontends

繼續這個故事的合乎邏輯的地方是我們一直提到的全域性渲染函式。 我們應用程式的主頁是一個可過濾的餐館列表,其入口點如下所示:

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>

從這一點開始,微前端大多隻是普通的舊 React 應用程式。 “瀏覽”應用程式從後端獲取餐廳列表,提供用於搜尋和過濾餐廳的 input 元素,並呈現導航到特定餐廳的 React Router Link 元素。 那時我們將切換到第二個“訂單”微前端,它會呈現一個帶有選單的餐廳。

從這一點開始,微前端大多隻是普通的舊 React 應用程式。 “瀏覽”應用程式從後端獲取餐廳列表,提供用於搜尋和過濾餐廳的 元素,並呈現導航到特定餐廳的 React Router 元素。 那時我們將切換到第二個“訂單”微前端,它會呈現一個帶有選單的餐廳。

Cross-application communication via routing

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

這裡涉及的所有三個 React 應用程式都使用 React Router 進行宣告式路由,但以兩種略有不同的方式初始化。 對於容器應用程式,我們建立了一個 BrowserRouter,它會在內部例項化一個歷史物件。 這與我們之前一直在掩蓋的歷史物件相同。我們使用這個物件來操作客戶端歷史,我們也可以使用它來將多個 React Router 連結在一起。 在我們的微前端中,我們像這樣初始化路由器:

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

在這種情況下,我們不是讓 React Router 例項化另一個歷史物件,而是為它提供容器應用程式傳入的例項。 所有 Router 例項現在都已連線,因此在其中任何一個例項中觸發的路由更改都將反映在所有例項中。 這為我們提供了一種通過 URL 將“引數”從一個微前端傳遞到另一個微前端的簡單方法。 例如在瀏覽微前端,我們有一個這樣的連結:

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

單擊此連結時,容器中的路由將更新,容器將看到新的 URL 並確定應安裝和呈現餐廳微前端。 然後,該微前端自己的路由邏輯將從 URL 中提取餐廳 ID 並呈現正確的資訊。

希望這個示例流程展示了不起眼的 URL 的靈活性和強大功能。 除了對共享和書籤有用之外,在這個特定的架構中,它可以成為跨微前端交流意圖的有用方式。為此目的使用頁面 URL 有許多優點:

  • 它的結構是一個定義明確的開放標準
  • 頁面上的任何程式碼都可以全域性訪問它
  • 其有限的大小鼓勵僅傳送少量資料
  • 它是面向使用者的,鼓勵忠實地建模領域的結構
  • 它是宣告性的,而不是強制性的。也就是說,“這就是我們所在的地方”,而不是“請做這件事”
  • 它迫使微前端間接通訊,而不是直接相互瞭解或依賴

當使用路由作為微前端之間的通訊模式時,我們選擇的路由構成了一個契約。 在這種情況下,我們確定了可以在 /restaurant/:restaurantId 處檢視餐廳的想法,並且我們無法在不更新所有引用它的應用程式的情況下更改該路由。 鑑於此合約的重要性,我們應該進行自動化測試來檢查合約是否得到遵守。

Common content

雖然我們希望我們的團隊和我們的微前端儘可能獨立,但有些事情應該是共同的。我們之前寫過共享元件庫如何幫助實現跨微前端的一致性,但對於這個小演示,元件庫會有點過分。因此,我們擁有一個小型公共內容儲存庫,包括影象、JSON 資料和 CSS,這些內容通過網路提供給所有微前端。

我們可以選擇在微前端之間共享另一件事:庫依賴項。正如我們將很快描述的那樣,重複依賴是微前端的一個常見缺點。儘管跨應用程式共享這些依賴項有其自身的一系列困難,但對於這個演示應用程式,值得討論如何做到這一點。

第一步是選擇要共享的依賴項。對我們編譯的程式碼的快速分析表明,大約 50% 的包是由 react 和 react-dom 貢獻的。除了它們的大小之外,這兩個庫是我們最“核心”的依賴項,因此我們知道所有微前端都可以從提取它們中受益。最後,這些是穩定、成熟的庫,通常會在兩個主要版本之間引入重大更改,因此跨應用程序升級工作應該不會太困難。

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

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

然後我們向每個 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>

跨團隊共享程式碼總是一件很難做好的事情。 我們需要確保我們只分享我們真正希望成為共同點的東西,並且我們希望一次在多個地方改變。 然而,如果我們謹慎對待我們分享的內容和不分享的內容,就會獲得真正的好處。

Infrastructure

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

請注意,每個微前端(和容器)都有自己的儲存桶。 這意味著它可以自由支配那裡的內容,我們無需擔心來自其他團隊或應用程式的物件名稱衝突或衝突的訪問管理規則。

Downsides

在本文開頭,我們提到微前端需要權衡,就像任何架構一樣。 我們提到的好處確實需要付出代價,我們將在此處介紹。

Payload size

獨立構建的 JavaScript 包會導致常見依賴項的重複,從而增加我們必須通過網路傳送給終端使用者的位元組數。例如,如果每個微前端都包含自己的 React 副本,那麼我們將迫使我們的客戶下載 React n 次。頁面效能和使用者參與度/轉化率之間存在直接關係,世界上大部分地區執行在網際網路基礎設施上的速度比高度發達城市所習慣的要慢得多,因此我們有很多理由關心下載大小。

這個問題並不容易解決。我們希望讓團隊獨立編譯他們的應用程式以便他們可以自主工作,而我們希望以一種他們可以共享公共依賴項的方式構建我們的應用程式,這兩者之間存在內在的緊張關係。一種方法是從我們編譯的包中外部化公共依賴項,正如我們在演示應用程式中描述的那樣。不過,一旦我們沿著這條路走下去,我們就重新引入了一些構建時耦合到我們的微前端。現在它們之間有一個隱含的契約,上面寫著“我們都必須使用這些依賴項的這些確切版本”。如果依賴項發生重大變化,我們最終可能需要大量協調升級工作和一次性鎖步釋出事件。這就是我們首先試圖避免使用微前端的一切!

這種固有的緊張是一個困難的問題,但也不全是壞訊息。首先,即使我們選擇對重複的依賴不做任何處理,每個頁面的載入速度仍然可能比我們構建一個單一的前端更快。原因是通過獨立編譯每個頁面,我們有效地實現了我們自己的程式碼拆分形式。在經典的單體應用中,當載入應用程式中的任何頁面時,我們通常會一次性下載每個頁面的原始碼和依賴項。通過獨立構建,任何單個頁面載入都只會下載該頁面的原始碼和依賴項。這可能會導致初始頁面載入速度更快,但後續導航會更慢,因為使用者被迫在每個頁面上重新下載相同的依賴項。如果我們有紀律地不讓我們的微前端因不必要的依賴而膨脹,或者如果我們知道使用者通常只關注應用程式中的一兩個頁面,我們很可能會在效能方面獲得淨收益,即使存在重複的依賴。

上一段中有很多“可能”和“可能”,這突出了一個事實,即每個應用程式始終都有自己獨特的效能特徵。如果您想確定特定更改會對效能產生什麼影響,則無法替代進行實際測量,最好是在生產中。我們已經看到團隊為多出幾 KB 的 JavaScript 而苦惱,結果卻是去下載數兆位元組的高解析度影象,或者對非常慢的資料庫執行昂貴的查詢。因此,儘管考慮每個架構決策對效能的影響很重要,但請確保您知道真正的瓶頸在哪裡。

Environment differences

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

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

Operational and governance complexity

最後一個缺點是與微服務直接並行。作為一個更加分散式的架構,微前端將不可避免地導致需要管理更多的東西——更多的儲存庫、更多的工具、更多的構建/部署管道、更多的伺服器、更多的域等等。所以在採用這樣的架構之前,你有幾個問題應該考慮:

  • 您是否有足夠的自動化來切實可行地配置和管理額外的所需基礎設施?
  • 您的前端開發、測試和釋出流程是否可以擴充套件到許多應用程式?
  • 您是否對圍繞工具和開發實踐變得更加分散和不那麼可控的決策感到滿意?
  • 您將如何確保眾多獨立前端程式碼庫的質量、一致性或治理達到最低水平?

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

Conclusion

多年來,隨著前端程式碼庫變得越來越複雜,我們看到對更具可擴充套件性的架構的需求不斷增長。 我們需要能夠劃清界限,在技術實體和領域實體之間建立正確的耦合和內聚級別。 我們應該能夠跨獨立、自治的團隊擴充套件軟體交付。

雖然遠不是唯一的方法,但我們已經看到了許多真實世界的案例,其中微前端提供了這些好處,並且隨著時間的推移,我們已經能夠將該技術逐漸應用於遺留程式碼庫和新程式碼庫。 無論微前端是否適合您和您的組織,我們只能希望這將成為持續趨勢的一部分,前端工程和架構得到我們認為應得的認真對待。

更多Jerry的原創文章,盡在:"汪子熙":