微前端說明書

NO IMAGE

為什麼寫

互聯網公司技術選型三定律

  1. 流行即正義
  2. 新鮮即正義
  3. 複雜即正義
    —— 我

因為最近被問起當前公司的前端產品有沒有聚合為微前端的可能性,所以又重新開始審視“微前端”這個話題。差不多一年前寫過一篇反駁美團微前端方案的文章。那篇文章更多的是關於“沒有必要這麼做”,但是“應該如何做”我也並沒有給出更好的方案。最近在參考了很多資料之後,對這個的問題的答案有了輪廓

本文分為兩個部分:“戰略”和“戰術”。前者關於為什麼以及在什麼場景下使用微前端,後者關於採用什麼的技術實施微前端。這篇文章裡我會反對某些方案,贊成某些方案,僅代表個人意見

開頭的“互聯網公司技術選型三定律”是我個人總結的,也是我在這篇文章裡極力反對的。這三條定律的產生有行業的原因也有程序員職業的原因。三定律的存在導致了某些技術的被曲解和濫用,其中就有微前端。在本文中也會引用這三定律做說明

戰略篇

實現微前端一點都不難,我相信你也看過無數種微前端實施方案。但問題不在於我們能不能做,而是我們為什麼要做。

Dan Abramov(你應該知道他是誰) 在 Twitter 上提出過一個問題,他認為微前端解決的問題通過好的組件模式就能解決,為什麼需要微前端?

微前端說明書

有時候甚至不用通過組件,通過一個門戶網站將不同功能的站點收集在一個頁面上某種意義上也算微前端。所以我們談論的微前端究竟是什麼?

微前端的概念衍生自微服務。在我看來微服務帶來的改進是是架構上的解耦,比如靈活替換和獨立部署發佈。注意這樣的解耦是架構上的而不是功能上的,在實際的的工作中,常常一個功能的迭代會帶來多個微服務的鏈式修改。在一個惡劣設計的極端情況下,你劃分了十個微服務,但是每次功能修改都需要對十個微服務同時修改,那這和一個單體應用有什麼區別?在單體應用中如果你設計的足夠優秀,單體內部也可以存在好的功能解耦。所以在當今微服務作為標配的情況下,微服務也並不是絕對正義

微前端和微服務相似,它帶來的也是僅僅是架構上的解耦。關於功能解耦我在戰術部分詳述

組件化的確是目前前端普遍的開發模式,但並不是所有的前端功能都需要走組件化這一條路。比如文檔性質的站點可以通過 static site generator 生成;絢爛的活動頁面更適合利用動畫特效類庫進行編程。我想表達的是:微前端不是跨世代的通用解決方案,它也不是用於代替先用的組件模式。它只是給了我們一個讓不同技術棧不同團隊開發同一個產品的機會。這個定義來自於 Luca Mezzalira 對 Dan Abramov 質疑的回覆,我非常贊同:

Let’s start with this, Micro-frontends are not trying to replace components, it’s a possibility we have that doesn’t fit in all the projects like components are not the answer for everything.

微前端適用於不同技術棧不同團隊需要對同一產品進行修改的開發模式,比如 Google Cloud:

微前端說明書

從菜單欄我們可以看出谷歌雲提供不同類型的服務,但是這些服務之間都相互獨立,有的是通用性質的,有的是雲計算相關的,即使是在雲計算一欄下又劃分了不同類型的計算服務。(我猜測)不同的服務來自不同的團隊進行開發,雖然它們不相互干擾,但是又需要同一個產品予以體現。那麼使用微前端是最好的方式

注意這裡“同一產品”的定義僅僅是從視覺形態和用戶體驗方面考慮。如果 A 網站只是要用到 B 網站的數據,那麼通過接口提供就好了。

你可能會注意到騰訊旗下的(所有)站點的登陸框都是使用 iframe 集成。這也算是一種微前端:其他的團隊只負責自己業務相關的頁面,而“登陸框”團隊負責維護統一登陸框供大家調用。他們之間不需要關心對方的技術棧,迭代週期(甚至甩鍋也變得方便了)。如果有一天 iframe 變成了統一的 Web Component,這種微前端關係仍然成立

美團的的微前端方案裡,我們看看他們做微前端的訴求:

美團已經是一家擁有幾萬人規模的大型互聯網公司,提升整體效率至關重要,這需要很多內部和外部的管理系統來支撐。由於這些系統之間存在大量的連通和交互訴求,因此我們希望能夠按照用戶和使用場景將這些系統彙總成一個或者幾個綜合的系統。

因為美團的HR系統所涉及項目比較多,目前由三個團隊來負責。其中:OA團隊負責考勤、合同、流程等功能,HR團隊負責入職、轉正、調崗、離職等功能,上海團隊負責績效、招聘等功能

這種團隊和功能的劃分模式,使得每個系統都是相對獨立的,擁有獨立的域名、獨立的UI設計、獨立的技術棧。但是,這樣會帶來開發團隊之間職責劃分不清、用戶體驗效果差等問題

這裡我對他們要做微前端的動機感到有一些疑惑:

  • “系統彙總”的方式有很多,除了門戶以外,我們曾經嘗試過通過給每一個系統添加一個公共共享的導航欄組件來讓系統之間的導航和跳轉更方便,效果也不錯
  • “獨立的域名、獨立的UI設計、獨立的技術棧”——這不就是相互獨立的站點嗎?如果這麼多年用戶都能正常使用,為什麼現在要把它們聚合在一起?
  • “這樣會帶來開發團隊之間職責劃分不清、用戶體驗效果差”,我不認為微前端能夠解決團隊之間職責劃分的問題;用戶體驗效果不是更應該從交互體驗和統一設計規範入手嗎?

我不是針對美團,但就微前端而言,就事論事我認為這是一個好的反面例子,能夠讓我們從不同的角度進行反思。在後面的內容裡我也會再引用其中的內容。當然他們也不是這篇文章中唯一的反面教材。

我在閱讀 Martin Fowler 的 《Patterns Of Enterprise Application Atchitecture》時,最大的一點感觸是他從來不排斥任何的技術方案:如果你想做業務相關的數據存儲,你當然可以選擇 ORM 來實現 Domain Model 模式;你同樣可以選擇簡單至極的 Transaction Script 模式 (A Transaction Script organizes all this logic primarily as a single procedure):

However much of an object bigot you become, don’t rule out Transaction Script. There are a lot of simple problems out there, and a simple solution will get you up and running much faster.

很多人(曾經包括我自己在內)在技術選型方面喜歡追求一種“宏大敘事感”:如果技術不夠複雜,不夠新,開發週期不夠長,動員團隊不夠多,怎麼在公司內彰顯我的影響力?我們之所以敢這麼放肆是因為環境鼓勵我們這麼做,每個團隊都在這麼做。我們一直在被暗示,項目的風險和可維護性不重要,反正三年之後我也不一定在這個公司,三年之後我可能成了管理人員,三年之後接手維護系統的人不是我。無論如何三年之後項目一定會推倒重來。我們的簡歷上總是強調我們做過多少系統,而不是把它們做的多好

從職業素養的要求上說作為開發人員我們應該關心風險和可維護性。減少項目風險和增加可維護性的措施之一就是讓代碼變得簡單。微前端從本質上說只是給了我們一個解決選項而非標準答案。如果你有留意的微服務的發展趨勢的話,微服務生態已經非常的龐大,幾乎每一個環節都能找到對應的第三方組件來完成工作。微前端也一樣,如果你願意你可以找到無數種方案讓項目看上去高精尖,但是為什麼明明一個 React 就能解決的問題一定要上 React 全家桶才甘心。

準確且謹慎的使用微前端,這是我的建議

Dan Abramov 提出的另一個質疑是多種技術棧混合的產物其實是一種互不妥協的結果:

微前端說明書

首先我不認為應該禁止遊戲混用引擎,比如某個 3D 遊戲的某個回憶復古關卡需要橫版射擊的表現形式,那麼就應該使用橫版射擊引擎,引擎應該忠於遊戲的展現。其次,前端框架與遊戲引擎不同,遊戲引擎不僅決定了物理特效,還影響了畫面展現,但是前端框架只是決定了運行機制,真正的用戶體驗一致性取決於是否統一 UI 設計與用戶體驗

戰術篇

我無法告訴你某個方案是最完美的。評價是一件多維的事情,這其中甚至還與你的團隊規模有關,所以我只是把這些可能的方案的 pros 和 cons 一一列舉出來。我也不會陷入到具體的技術細節中去,例如如何在 CSS 中避免 class 汙染,像我之前說的,實現從來都不是問題,問題是我們為什麼要去實現。

Web App 隔離

讓我們從最簡單的 case 開始。

在美團的例子中,微前端是由三個團隊獨立開發的 Web App 組成,此時 App 是微前端架構裡的最小粒度。這樣的劃分和隔離是最安全的,因為 App 間幾乎沒有任何的從人到代碼的資源共享。

重複代碼

但這樣的獨立策略不免讓人擔心代碼的重複:假設團隊 A 使用 React 技術棧開發了一個 Dialog 組件,團隊 B 也使用了 React 技術棧也開發了一個 Dialog 組件,那麼貌似這那個 Dialog 能夠合併成一個 Dialog 來減少維護成本。

這的確符合常識,我們耳目濡染的接收了很多關於不要做重複代碼的薰陶:比如 DRY(Don’t Repeat Yourself) 原則,DIE 原則(Duplication is Evil).在絕大多數情況下它們都是正確,但在微前端中並非如此。“微 (Micro)” 這個詞並非僅僅是字面上“小”的意思,而是代表獨立和自治。以 Dialog 為例,不同的 App 隸屬於不同業務,不同的業務對 Dialog 功能有著不同的需求。每個小團隊對自己的業務才是最熟悉的,如果需要對 Dialog 進行變化的話他們能夠對自己維護的 Dialog 準確快速的做出決定。把不同團隊的 Dialog 合併成為一個之後,看似代碼量減少了,但是期間的溝通成本和維護成本反而增加了。本來是為了解耦架構的微前端因為組件共享又被耦合在了一起。

即使不站在微前端的角度上,我依然不推薦抽象共享組件。抽象最好應該是在項目穩定的後期,看到了確切的功能重疊部分,再考慮把它們共享出來。因為在需求快速變化的前期,不同業務的需求會導致共享組件變成並集而非交集的結果。

最後抽象並非是無敵的,前提是你要知道如何抽象,錯誤的抽象比重複代碼維護起來還要難受

編排層

隔離方案中另一個需要解決的問題是應用的啟動和切換,此時我們需要一個類似於 Orchestration Layer(編排層) 的東西。它負責協調不同的 App 之間的活動,比如:

  • 管理 App 的生命週期
  • 加載、卸載 App
  • 微前端路由管理
  • 提供公共功能

編排層不是什麼新鮮的東西,在 SOA 架構中就已經存在。你可以把編排層和 App 理解為 steam 平臺和平臺上游戲的關係,也可以把 BFF 當作針對接口的編排層。在美團的方案中,編排層就是他們口中的 Portal 項目。

但是我反感方案美團 Portal 方案的關鍵原因是,編排層對 App 代碼進行了入侵。比如:

為了不侵入“子項目”,我們採用構建過程中替換的方式來做,“Portal項目”把公共庫引入進來,重新定義,然後通過window.app.require的方式引用,在編譯“子項目”的時候,把引用公共庫的代碼從require(‘react’)全部替換為window.app.require(‘react’),這樣就可以將JS公共庫的版本都交給“Portal項目”來控制了

這段話自相矛盾:段落的開頭說“為了不入侵子項目”,結尾則說“這樣就可以將JS公共庫的版本都交給 Portal 項目來控制了”。這樣一來,微前端中最寶貴的獨立技術棧的優勢被削弱了,所有 App 的公共類庫都要交給 Portal 控制。

如果它們指的是 Java 的 Portal 概念的話,我覺得再這裡也不適用,因為 Portal 指的是動態碎片聚合成單個網頁:

A portlet is a Web-based component that will process requests and generate dynamic content.

在這裡我想特別的強調編排層的職責,編排層不是 manager,它類似 broker、coordinator 甚至 glue。編排層是為 App 服務,而不是 App 為編排層服務。你不會見到 BFF 對上游的後端接口提需求;你也不會見到 Application Layer 對 Domain Layer 指手畫腳。

關於編排層另一點我想強調的是,編排層不侷限於在 client 端實現,我們也可以擁有 server 端的編排層。例如當用戶從應用中登出之後,由後端返回一個包含需要登陸的頁面,而前端則不需要再關心權限控制。這其實是回到了傳統 MVC 的那一套。如果選擇 server 端的編排層,一方面我們可以考慮用上 server rendering;另一方面我們也需要擔心 App 間數據共享的問題

以組件為單位

以組件為單位聚合成微前端是目前你能看到的主流理想的實現方式。

  • 為什麼說是主流?因為你能搜索到的絕大部分關於微前端的實現案例,都是基於組件化的。
  • 為什麼說是理想?因為這些案例通常是來自某一位開發人員之手,而非是某個團隊實踐之後的結果。

如果你在 Google 上搜索 Micro Frontends, 排名靠前的是一個名叫 Micro Frontends 的開源項目。項目裡舉了一個例子,來描述用組件聚合微前端的一個場景,在這個挑選商品的頁面中,它需要調度三種框架來編寫組件來協同完成工作:

微前端說明書

我們就以這個 case 為例,看看以組件為單位的微前端需要解決什麼問題

Communication

通信是頭等大事。在上面的例子中,當用戶在產品列表中選擇不同類型的玩具時,需要通知購買按鈕的價格進行調整。然而項目作者在父子組件通信實現方案中選擇直接修改購買按鈕對應的 DOM:移除舊 DOM、插入新 DOM 或者修改 DOM 屬性。在這個開源項目中,作者認為 DOM 就是組件間相互通信的 API 。

我支持作者後半段敘述的使用 DOM Event 來進行子組件到父組件以及同輩組件之間的消息傳遞。但是直接修改 DOM 絕對是一個非常糟糕的設計。直接修改 DOM 好比我直接通過 IP 訪問網站,好比 React 父組件通過找到子組件的 DOM 來修改子組件。不僅耦合性強,以後每增加一處需要感知變化的組件時,都要在父組件中添加代碼。但是通過事件,我只需要添加消費方即可。

Synchronization

僅僅是消息機制往往是不夠的,有時候我們將數據狀態進行同步。假設現在需要支持用戶勾選多個商品並統一進行結算,且支持優惠滿減活動。此時購物按鈕組件需要存儲目前購物總金額,才能計算出優惠之後的金額。

此時我想到三個辦法:

  1. 保持原狀,用戶選擇商品時依然發佈事件。購物按鈕接收到事件後通過 Event Sourcing 計算當前總額
  2. 商品列表追蹤當前商品總額,並在用戶操作商品時通過事件同步給購物按鈕
  3. 用戶選擇商品時發佈事件,不過由全局存儲的模塊接收事件並計算總金額。購物按鈕從全局存儲模塊獲得當前總金額

方案一的缺陷在於,如果有多個組件同時需要知道當前總額時,多個組件需要重複相同的工作,一份相同含義和價值的數據會存儲被存儲多份

方案二的問題在於,商品列表在業務邏輯上來說是不需要知道商品總額的,模塊的職責劃分出現了錯誤

所以目前看來方案三才是最佳的選擇

Package Manage

通信和數據共享都無法迴避一件事情:契約。無論是組件間直接通信還是通過 event 進行通信,它們都需要和對方預定消息格式;需要共享數據的組件之間也需要約定數據的 schema。無論組件如何的迭代,契約始終要和其他組件保證一致。

因為組件之間獨立的緣故,不同的組件迭代節奏不盡相同,自然組件間就會出現版本差異。然而如何保證不同版本間的契約不會被破壞?文檔可以,契約測試也可以。然而更大的問題是,如何保證組件協作產生的功能不被破壞?獨立組件或許有測試能夠覆蓋到自己的功能,但這不意味著合併之後的功能依舊正常,於是在 App 中,我們似乎還需要端到端的測試來保證交付功能的正常

如果團隊如果真的獨立開發組件的話,我建議在組件的發佈階段加上 pipeline,持續集成以避免影響其他功能

Responsibility and Team Work

使用組件聚合最(令我)頭疼的問題之一,是如何為組件找到對應的團隊負責,以及如何在組件聚合的模式下劃分團隊。

團隊劃分通常有兩類劃分模式,這兩種模式的叫法有很多,我在這裡姑且稱之為 Component Team(以下簡稱 CT) 和 Feature Team(有以下簡稱 FT)

  • Component Team: 康威定律告訴我們組織的溝通方式會在系統設計上有所表達。如果你有四個小組開發編譯器,那麼你會得到一個四步編譯器。CT 模式即組織和架構一致。在這個模式下團隊的劃分是按照分層架構或者說垂直技術棧進行劃分的,例如前端、後端和運維。CT 模式的問題首先在於領域知識散落在不同的技術架構中,產生了耦合;其次在需要協同工作的情況下缺少 ownership,每個團隊只關心自己的KPI,缺少知識的共享和傳承

  • Feature Team: 這個模式也被成為逆康威模式(Inverse Conway Maneuver),團隊按照業務架構而非技術架構進行劃分,一個團隊負責單一業務上的功能,但是在技術上,它們可以需要同時修改端到端的代碼以及多個微服務,你可以理解為全棧。這個模式的問題是,在允許多個團隊修改同一個服務的情況,缺少服務 owner 容易導致服務代碼的質量下降

在敏捷開發和 DDD 的影響之下 FT 模式逐漸變得流行。我個人也推薦 FT 模式,因為我曾在某司深受 CT 模式其害,當組織越龐大,垂直的組織壁壘就越多,你能想象我在某司的時候運維部門的最大願望是希望我們不要上線嗎

然而在使用組件聚合的情況下,我們應該如何劃分組件和團隊?

首先我不贊成上面例子中如此細粒度的劃分技術棧的劃分組件和團隊。這樣的會導致每個如此之小的功能的修改都要涉及好幾個(未知)團隊的協作開發,CT 模式下的壁壘又重新顯現。我更加反對將 componets 在公司內部作為獨立的組件庫由獨立的團隊進行開發,這會導致業務團隊與組件團隊無法對齊。

那 FT 模式呢?當我在設想以 FT 模式進行組件劃分時,我又陷入了一種粒度的糾結當中。以上面選擇商品並且結算為例,該團隊負責的範圍就此為止了嗎?如果下方還有商品評論和商品推薦的相關內容,我是否應該繼續交給這個團隊繼續負責?

我認為 DDD 是可能是一個解藥。DDD 理論能夠幫助我們劃分出不同的領域模型,幫助我們界定上下文。比如商品的購買屬於核心域,但是商品的評論屬於支撐子域。這樣我們就有理由不將它們交給一個 FT 團隊負責。這樣前端團隊和後端也方便對齊。

注意在 DDD 的模式下請避免組件的跨域複用,這會導致上下文和領域的重疊。

另外如果以 DDD 劃分的話,說不定因為範圍夠大而導致組件聚合升級成了 App 聚合

以組件為聚合的解決方案

不少微前端解決方案基本都是以組件為劃分的,不過它們定義的組件和我們理解的組件並不相同。最終的解決思路又十分相似

IKEA

你沒看錯,宜家

Experiences Using Micro Frontends at IKEA 一文中,宜家架構師 Kotte 介紹它們採用了一種類似於 transclusion mechanism 的形式。客戶端 transclusion 的例子便是圖片標籤。標籤擁有 src 屬性用於指向一個 URL。瀏覽器會在渲染時將改標籤替換為一個真實的圖片。

在服務端他們的 Edge Side Includes(ESI) 便對應圖片標籤,不過指向的不是圖片而是 HTML。他們擁有頁面 (Page) 和碎片 (Fragment) 的概念,一個團隊同時需要負責碎片和頁面的開發,頁面通過 ESI 引用那些碎片。碎片的引用是跨越團隊邊界的。比如一個產品團隊擁有產品縮略圖的的碎片,其他的團隊就可以引用這個縮略圖碎片而不用自己再重寫相同的功能。

因為頁面由不同團隊的的碎片組成,可能使用的不同技術,為了能夠使它們組件時相互兼容,團隊採用了一種自包含(self-contained)技術,即碎片本身就包含了它自己需要的所有資源,比如 CSS 和 Javascript,能夠獨立運行,而不需要思考碎片的依賴。

OpenComponents

OpenComponents(以下簡稱 OC) 是一種端到端的解決方案。在關於 OC 的Architecture overview中,項目開宗明義的指出:

OpenComponents’ heart is a REST API. It is used for consuming and publishing components.

你可以把它理解為一個進階版的 npm 系統。除了是獨立的組件包之外,它還封裝了業務請求,甚至已經渲染完畢,加載即用,不需要再二次開發。如果你需要在頁面上引用使用一個縮略圖功能的 OC, 只需要在頁面引用

 <oc-component href="http://localhost/thumbnails">

目前解決微前端的另一個思路是將前端是站在消費者的角度考慮聚合:可能模塊 A 是由 React 編寫,模塊 B 是由 Vue 編寫,沒關係在服務端統一編譯成瀏覽器需要 html 與 es5 碎片返回,最終將它們組合再一起,對於編排層來說一視同仁。OC 是這個思路,宜家也是這個思路,

Project Mosaic

Mosaic 是整套的從微服務到微前端的解決方案。從它官網的圖例便可以理解它的架構:

微前端說明書

它也是通過組件化加碎片化的方式聚合前端

這三種方案都沒有明確說明如何解決我上面提出的各種問題。

結語

最後借用 Simon Brown 的一條 twitter 來結束這篇文章:

I’ll keep saying this … if people can’t build monoliths properly, microservices won’t help.

如果你連單體應用都寫不好,微前端也幫不上什麼忙


本文同時也發佈在我的知乎專欄,歡迎關注

相關文章

前端架構101(五):從Flux進化到ModelViewPresenter

前端架構101(四):MVC的不足與Flux的崛起

前端架構101(三):MVC啟示錄:模塊的職責,作用域和通信

前端架構101(二):MVC初探