[譯] 微前端:將來前端開發的新趨勢 — 第二部分

微前端:將來前端開發的新趨勢 — 第二部分

作好前端開發不是件容易的事情,而比這更難的是擴展前端開發規模以便於多個團隊能夠同時開發一個大型且複雜的產品。本系列文章將描述一種趨勢,能夠將大型的前端項目分解成許多個小而易於管理的部分,也將討論這種體系結構如何提升前端代碼團隊工做的有效性和效率。除了討論各類好處和代價以外,咱們還將介紹一些可用的實現方案和深刻探討一個應用該技術的完整示例應用程序。javascript

建議按照順序閱讀本系列文章:css

示例

想象一個網頁,消費者能夠在上面點外賣。表面上看起來這是一個很簡單的概念,可是若是想把它作好,有很是多的細節須要考慮。html

  • 應該有一個引導頁,消費者能夠在這裏瀏覽和搜索餐廳。這些餐廳能夠經過任意數量的屬性搜索或者過濾,包括價格、菜系或先前訂單。
  • 每個餐廳都須要它本身的頁面來顯示菜單,並容許消費者選擇他們想點什麼,並有折扣、套餐、特殊要求這些選項。
  • 消費者應該有一個用戶頁面來查看訂單歷史、追蹤外賣並自定義支付選項。

一個餐飲外賣網站的線框圖

圖 4:一個餐飲外賣網頁可能會有幾個至關複雜的頁面。前端

每一個頁面都足夠複雜到須要一個團隊來完成。每一個團隊都理應可以獨立地開發他們負責的頁面。他們須要可以開發、測試、部署、維護他們的代碼,並沒有需擔憂與其餘團隊的衝突與協調。咱們的消費者,看到的仍然應該是一個完整、無縫的網頁。java

在文章接下來的部分裏,當咱們須要示例代碼或者情景時,咱們將會使用這個應用做爲例子。android


集成方式

根據前文相對寬鬆的定義,多種方法都能被叫作微前端。在這一節中咱們會看一些例子並討論它們的優劣。這些方法中共有一個相對天然的架構 —— 整體上講,應用中的每個頁面都有一個微前端,而後還有惟一一個容器應用,用於:ios

  • 渲染公用頁面元素,如頁眉頁腳
  • 解決跨頁面的一些需求,如受權和導航
  • 將多個微前端集成到頁面上,並告知每一個微前端什麼時候在哪渲染本身

一個用方框畫出不一樣部分的網頁。一個方框包含了整個頁面,標記爲「容器應用」,另外一個方框包括了主要內容(全局頁面標題和導航除外),標記爲「瀏覽微前端」

圖 5:你一般能夠從頁面結構推出你的架構nginx

服務端模板編寫

咱們從一個很常見的前端開發方法開始 —— 在服務器端基於一些模板和代碼片斷渲染 HTML 頁面。咱們有一個 index.html 文件,包含全部公用的頁面元素,而後咱們用服務器端的 includes 來加入從 HTML 文件片斷提取的頁面內容:git

<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 匹配。github

server {
    listen 8080;
    server_name localhost;

    root /usr/share/nginx/html;
    index index.html;
    ssi on;

    # 重定向 / 至 /browse
    rewrite ^/$ http://localhost:8080/browse redirect;

    # 根據URL肯定要插入哪一個 HTML 片斷
    location /browse {
      set $PAGE 'browse';
    }
    location /order {
      set $PAGE 'order';
    }
    location /profile {
      set $PAGE 'profile'
    }

    # 全部位置都應經 index.html 渲染
    error_page 404 /index.html;
}
複製代碼

這是一個相對標準的服務端組合。咱們可以將其稱爲微前端的緣由是,咱們將代碼分離,這樣每一部分代碼都是一個自我包含的領域概念,並可以被一個獨立的團隊開發。咱們沒有看到的是,這些不一樣的 HTML 文件最後如何到了服務器端,可是咱們假設每個頁面都有它們本身的部署流程,容許咱們對一個頁面部署修改,同時不影響或者無需考慮其餘頁面。

對於更大的獨立性,每個微前端均可以由獨立的服務器來負責渲染,並由一個服務器負責向剩下的發送請求。使用精心設計的緩存來存儲響應,這種實施方案不會影響延遲。

一個流程圖,展現瀏覽器向「容器應用服務器」發送請求,該服務器隨後向「瀏覽微前端服務器」或「訂單微前端服務器」發送請求

圖 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 包,和往常同樣,容許咱們從咱們多樣的應用中解耦公用依賴。然而,這個方法意味着,爲了在產品任意一個部分發布修改,咱們必須從新編譯和發佈每個微前端。如同微服務同樣,咱們已經體會過了這種因循守舊的發佈流程帶來的痛苦,以致於咱們強烈反對在微前端使用一樣的方法。

踩過了將應用分爲離散的、可獨立開發測試的代碼庫帶來的全部的坑,咱們就再也不介紹發佈階段的耦合問題了。咱們須要找到一個在運行時集成微前端的方法,而非構造時方法。

經過 iframes 運行時集成

將應用組合到瀏覽器的一個最簡便的方法即是使用 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>
複製代碼

服務端的引入選項同樣,用 iframes 構建頁面不是一個新的技術,並且可能不是很使人興奮。但若是咱們重溫以前提過的微前端的好處,iframes 幾乎都有,只要咱們仔細考慮如何將應用分紅獨立部分、如何構建團隊。

咱們常常看到不少人不肯意選擇 iframes。雖然部分緣由彷佛是直覺感受 iframe 有點「糟糕」,但人們也有很好的理由不使用它們。上面提到的簡單隔離確實會使它們比其餘選項更不靈活。在應用程序的不一樣部分之間構建集成可能很困難,所以它們使路由,歷史記錄和深層連接變得更加複雜,而且它們對使頁面徹底響應性提出了一些額外的挑戰。

經過 JavaScript 運行時集成

咱們將要討論的下一個方法多是最靈活的、團隊採用最頻繁的一個。每個微前端都用 <script> 標籤放入頁面,在加載時會暴露一個全局函數做爲入口。容器應用接下來決定掛載哪一個微前端,並調用相關函數告訴一個微前端什麼時候在哪渲染。

<html>
  <head>
    <title>Feed me!</title>
  </head>
  <body>
    <h1>Welcome to Feed me!</h1>

    <!-- 這些腳本不會當即渲染任何元素 -->
    <!-- 相反它們將每個入口函數掛載在 `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">
      // 這些全局函數會經過上面的腳本掛在 window 對象上
      const microFrontendsByRoute = {
        '/': window.renderBrowseRestaurants,
        '/order-food': window.renderOrderFood,
        '/user-profile': window.renderUserProfile,
      };
      const renderFunction = microFrontendsByRoute[window.location.pathname];

      // 決定好入口函數以後,咱們如今調用它,給它提供元素的 ID 來告訴它在哪裏渲染
      renderFunction('micro-frontend-root');
    </script>
  </body>
</html>
複製代碼

以上顯然是一個比較初始的例子,但它演示了基本技術。與構建時集成不一樣,咱們能夠獨立部署每一個 bundle.js 文件。與 iframe 不一樣,咱們有充分的靈活性來以咱們偏好的方式構建微前端之間的集成。咱們能夠經過多種方式擴展上述代碼,例如,只根據須要下載每一個 JavaScript 包,或者在呈現微前端時傳入和傳出數據。

這一方法的靈活性,與獨立部署性結合,使它成爲了咱們的默認選擇,而且是最爲常見的一種選擇。當咱們到了完整示例時咱們將會探索只這面的更多細節。

經過網頁組件運行時集成

前面這種方法的一個變種就是,對於每個微前端,定義一個 HTML 自定義元素讓容器來構建,而非定義一個全局函數來讓容器調用。

<html>
  <head>
    <title>Feed me!</title>
  </head>
  <body>
    <h1>Welcome to Feed me!</h1>

    <!-- 這些腳本不會當即渲染任何元素 -->
    <!-- 相反它們每個都定義一個自定義元素類型 -->
    <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">
      // 這些元素類型是由上述腳本定義的
      const webComponentsByRoute = {
        '/': 'micro-frontend-browse-restaurants',
        '/order-food': 'micro-frontend-order-food',
        '/user-profile': 'micro-frontend-user-profile',
      };
      const webComponentType = webComponentsByRoute[window.location.pathname];

      // 決定了正確的網頁組件,咱們如今建立了一個實體並把它掛在 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 modules 或者衆多 CSS-in-JS 庫的一個來應用,以保證樣式只應用在開發者想要的地方。或者,shadow DOM 也以一種更加基於平臺的方式提供樣式分離。

你選擇的方法並不重要,只要你找到一種方法來確保開發人員能夠彼此獨立地編寫樣式,並確信他們的代碼在組合到單個應用程序中時能夠預測。


共享組件庫

咱們在上面提到過,微前端的視覺一致性很重要,其中一種方法是開發一個共享的,可重用的 UI 組件庫。總的來講,咱們認爲這是一個好主意,雖然很難作好。建立這樣一個庫的主要好處是經過重用代碼減小工做量,並提供視覺一致性。此外,你的組件庫能夠做爲一個樣式​​指南,它能夠是開發人員和設計人員之間的一個很好的協做點。

最容易出錯的地方之一就是過早地建立太多這些組件。建立一個包含全部應用程序所需的全部常見視覺效果的基礎框架頗有吸引力,可是,經驗告訴咱們,在實際使用它們以前,很難(若是不是不可能的話)猜想組件的API應該是什麼,這會致使組件早期的大量波動。出於這個緣由,咱們更願意讓團隊在他們須要的時候在他們的代碼庫中建立本身的組件,即便這最初會致使一些重複。容許模式天然出現,一旦組件的API變得明顯,你可使用 harvest 將重複的代碼放入共享庫中並確信這些已經被證實有效。

最明顯的可供分享的組件是比較「傻」的視覺基元,如圖標,標籤和按鈕。咱們也能夠共享一些複雜組件,他們可能會包含大量的 UI 邏輯,如自動補全和下拉菜單搜索框。或者是可排序、可過濾的分頁表。可是,請務必確保共享組件僅包含 UI 邏輯,而且不包含業務或域邏輯。將域邏輯放入共享庫時,它會在應用程序之間建立高度耦合,並增長更改的難度。所以,例如,一般不該該嘗試共享一個 ProductTable,它會包含關於「產品」到底是什麼以及應該如何表現的各類假設。這種域建模和業務邏輯屬於微前端的應用程序代碼,而不是共享庫中。

與任何共享的內部庫同樣,圍繞其全部權和治理存在一些棘手的問題。一種模式是說做爲共享資產,「每一個人」擁有它,但在實踐中,這一般意味着沒有人擁有它。它很快就會充滿雜亂的風格不一致的代碼,沒有明確的約定或技術願景。另外一方面,若是共享庫的開發徹底集中化,那麼建立組件的人與使用它們的人之間將存在很大的脫節。咱們看到的最好的模型是任何人均可覺得庫作出貢獻的模型,可是有一個保管人(一我的或一個團隊)負責確保這些貢獻的質量,一致性和有效性。維護共享庫的工做須要強大的技術技能,還須要培養許多團隊之間協做所需的人員技能。

建議按照順序閱讀本系列文章:

若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索