最近我打算在我的面試題庫里加一道題,用於考驗候選人的應用的設計能力,這個問題也來源於最近經歷的真實的業務問題:
我目前所在的技術中臺部門為公司的業務部門開發了非常多的工具應用,比如資料分析、比如A/B測試、比如部署釋出平臺等等。它們都是基於 React 框架,然後輔佐以不同的狀態管理框架進行開發,比如 redux,mobx,又或者 dva。這些應用都是由不同的前端團隊維護開發,所以不難想象大家其實重疊的開發了相同的功能或者相同的元件,比如登陸驗證(都是基於公司級別的 OAuth ),比如錯誤處理,又比如傳送訊息給使用者等等。 因為分散在不同的域名但其實又聯絡緊密,不便於使用,所以我們打算把它們聚合在同一個門戶(站點)下,頂部顯示一條公共的導航欄,下方是當前具體的應用。通過導航欄的選單在當前頁面下切換不同的應用。讓使用者有一種使用單一應用的感覺。 如何來設計這個聚合方案?
如果大家聽說過“微服務(Microservices)”的概念的話就不難理解“微前端(Micro Frontends)”。和後端應用類似,當一個前端應用變得異常龐大以後,它會變得難以維護,同時也會變得不穩定。將大的應用拆分為小的應用能夠讓每個專業團隊專心負責自己的功能,更易於測試、部署以及釋出。
但是這樣的拆分有很多,最常見的,是將單個應用拆分為多個獨立的應用,通過導航欄和動態載入來實現無縫的切換,這樣的 app 甚至可以採用不同的技術棧進行構建:
單個頁面上的模組也可以拆分為不同的微前端由不同的團隊使用不同的技術棧進行開發:
這是兩個極端。其他場景包括但不限於
- 把第三方的SaaS應用進行整合,
- 把第三方私服應用進行整合(比如在公司內部部署的 gitlab)
- 在相同技術棧下處理以上各種情況等等
同理有時候我們也需要進行反向(後置)的操作,比如在開頭的例子中,在已有多個應用的情況下,需要將它們聚合為一個單應用。
可以想象正向的自頂向下的拆分遠比反向的聚合簡單,因為在開發之初你們就能預見功能,約定介面,統一技術棧,提取公共元件等等;而反向聚合下一切都是未知的,任何困難都有可能發生,只能見招拆招
說到底,微前端的聚合問題其實是非常複雜的,複雜之處在於細分的場景太多,沒法給出一個大一統的解決方案,所以在本文中要處理的問題也僅限於題目中所描述的那樣。但即便如此,本文中所描述的思路,考慮到的問題,或許你在未來處理相關問題是依然能夠拿來參考。
也許閱讀到這裡你可以暫停一會,拿起筆和紙用十到十五分鐘的時間來思考如何解決這個問題。在面試一個人的時候,他給出的結果並不重要,重要的是它解決問題的思路以及考慮問題是否周全。一個高手和菜鳥可能會給出同樣的答案,但高手是深思熟慮的結果,菜鳥則可能運氣好撿到了鑰匙。
在解決這個問題的時候,同事向我推薦了美團技術部落格的一篇關於微前端的文章《用微前端的方式搭建類單頁應用》。這篇文章為我們提供了一類解決問題的方案,這個方案剛好可以作為我們解決這類問題的一個線索。但是在方案中仍然有值得商榷的地方。文章接下來的內容,就是聊聊這些值得商榷的地方,重點談文章中的兩點:序號產生器制和名稱空間。
最後,指出這些不足並沒有貶低的意思,我也沒法給出一個明確的、更高明的解決辦法,而是旨在提供探討更多的可能性。在正式開始最好先閱讀完畢美團的這篇文章,並且與之前你個人的思考結果進行對比。也許你會迸發出一些和我們不一樣的想法
序號產生器制
美團的需求和我們的需求是一致的,用原文中的話說是
HR系統轉變成只有一個域名和一套展示風格的系統
在沒有更多額外資訊的情況下,我預設認為不同系統之間不需要通訊和互動,每個系統都是獨立的應用。但是仍然有邏輯交叉,比如使用者登陸機制、全域性異常處理等等。
美團的做法是新建一個公共級別的專案,叫做 Portal(“傳送門”?),所有的子專案將自己的資訊需要向這個 Portal 進行註冊,包括路由、包括名稱空間。之後所有的專案引入這個功能的元件。那麼在之後每個專案上線時,通過路由(導航欄?)動態的載入子專案資源(指令碼/樣式)來動態的載入子專案
通常在做反向聚合的工作時,除了要新建類似於上面的容器/入口級別的專案,還需要對原專案進行修改,對原專案修改是痛苦的,因為需要向前和向後的相容。如果是不同團隊維護的專案,你還需要推動“人”去做這方面的工作。所以最理想的狀態是“非侵入性”的改造,即原專案是無感知的。這似乎比較難,當然退而求其次,我們的目標是儘可能的把改動的代價降到最小。
在美團的方案中,他們希望把公共類庫交給 Portal 去維護和載入而又不侵入式的改造專案,於是決定在構建階段替換掉子專案的require('react')
方法,改為window.app.require('react')
,即 Portal 的私有 require
方法。這樣看似就能減少子專案的關心項,同時對公共類庫進行統一管理;
但是我們真的需要公共類庫管理嗎?
我們可以理解他們為什麼要做公類庫管理,一方面避免資源的重複載入,例如 react
, react-dom
, lodash
的類庫每個專案都要使用到,另一方面能保證所有專案用到的內部元件庫是釋出之後最新的
但在實際情況下,子專案是來自不同時期的不同團隊做的,所以哪怕是基本的類庫都會出現版本不統一的地方(你可能會有疑問:為什麼他們不及時更新呢?很多原因,比如功能夠用便不再開發了,比如使用到的其他類庫只相容到這個版本)
所以說即便你想進行對類庫的統一管理,務必還是要對其他的專案進行入侵式的修改,相容、測試的工作依然不能少。
那我們依然可以退一步,公用某些版本的元件。當子專案註冊時,需要向 Portal 註冊它依賴的類庫和對應的版本。這又會產生另一個問題,你如何在單頁面應用中管理多個版本的相同類庫?例如專案 A 依賴react@15
,專案 B 依賴react@16
,當使用者從 A 切換至 B 又切換至 A 時,react@15
需要重新載入嗎?很明顯不需要,應該把類庫快取起來。更好的方式是我們不應該依賴 HTTP 快取,而是應該把已經載入的類庫快取到記憶體中,那麼這你還需要解決記憶體中相同類庫名稱空間衝突問題。
所以綜上:
- 如果為了統一公共類庫,你需要侵入專案
- 如果堅持類庫重用,你需要更復雜的公共類庫管理
美團方案中另一個很有幸運的地方是,所有的子應用都是使用 Redux 框架編寫的(因為在向 Portal 註冊時還需要註冊 reducers?但是貌似沒有讀到在切換專案時解除安裝 reducers?),所以它們公用了一個 Redux 框架。還是那句老話,在現實情況裡沒有這麼好運,在做前端聚合時就需要考慮如何保證不同的資料流框架在動態載入之後順利執行,比如從 Redux 切換到 Mobx 怎麼辦?提前把所有可能使用到的框架都整合到 Portal 中去?版本管理怎麼做?
所作的一切看上去似乎都是為了動態載入而努力。但我們真的需要動態載入嗎?
多年前在愛奇藝工作的時候我負責播放頁的開發(就是播放視訊的那個頁面),其中有一塊邏輯是,噹噹前頁面的視訊播放完畢會後自動播放視訊列表的下一個視訊,或者說劇集的下一集。但方式不是跳轉到下一個頁面,而是非同步請求下一個視訊所有的資訊,包括作者、評論、熱度等在當前頁面更新,為的就是實現一種無縫切換的效果。別忘了還要修改 URL。
但為什麼一定要動態載入?效能更快?未必,如此多的請求如此多的DOM節點需要渲染(在那個年代沒有 React,沒有 Virtual DOM),未必會比由伺服器渲染的整張頁面來的快;體驗更好?我不認為單頁面的體驗會差(參考優酷);更可怕的事情是這會造成維護上的困難:想象一個剛入職的同學在修改一個動態載入的功能的時候,很可能就會遺忘某個邏輯需要更新。
我的個人意見是,如果你被聚合的專案之間是獨立的,不如就把它們打包為獨立的應用,然後在頁面間跳轉。並且也不需要再做公共類庫管理,直接打包進專案的最終指令碼里,重複也罷。
此時此刻你可能會詫異:看了半天的文章就給我一個這麼“樸素”的方案?!
年青人,我理解你,每個程式設計師都天生愛好“狂拽酷炫吊炸天”的技術,簡單來說越偏門越好、越複雜越好。但是公司級別實施的時候,你的上司,你的老闆,他們希望的是業務的穩定和成本。所以在選擇實施方案的時候,這兩點才是我們首先要考慮的,例如
- 方案一: 實現 3 分的功能,需要 5 分的成本,以及 3 分的風險
- 方案二: 實現 1 分的功能,需要 1 分的成本,以及 1 分的風險
那麼後者的收益其實更高。
如果以後可能需要這個功能,為什麼不現在就向那個方向看齊,設計並且實施呢?如果只是可能,而不是當前明確的需求的話,相當於你用當前專案的時間做了不需要的功能。這叫做過度設計
至少在這個場景下,我認為我們不需要更復雜的方案
在什麼場景下需要不得不考慮動態載入呢?比如說跨應用元件(頁面)級別的共享:假設目前內部有一個資料分析平臺,同時又擁有一個A/B測試平臺。在A/B測試平臺上我們同樣也需要觀察資料,比如不同實驗方案在不同指標下的效果。如果再編寫一個資料分析頁面有雷同的嫌疑,直接跳轉到資料分析平臺又頗為不便。於是最好的辦法是將資料分析平臺的元件或者頁面獨立打包出來,讓 A/B 平臺能夠動態的載入資料分析頁面並且瀏覽。
名稱空間
當子專案向 Portal 進行註冊時需要指定名稱空間,例如原文中的程式碼:
await window.app.init('namespace-kaoqin',reducers);
複製程式碼
注意這裡既使用到了全域性的變數 window
又手動指定了子專案的名稱空間
向推薦大家一本開源圖書:"Single page apps in depth",顧名思義這是一本談單應用開發的圖書,它誕生在 React 出現之前,在那個年代只有 MVC 框架 Backbone 和 Angular,以後有機會可以聊聊書中的內容。重點是,我同意書中的觀點(或者說它說服了我),名稱空間不是一個好的實踐。
在那個時代,模組化的主要方法是在全域性變數上申請獨一無二的名稱空間(比如window.MyApp
),然後把一切都掛載在這個名稱空間之下,這樣會帶來兩個問題:
- 在這個機制之下,你要麼把想公開的方法和變數在全域性暴露出來,要麼徹底把它隱藏起來。不存在你既想把它隱藏,又把它只暴露給一部分子系統進行使用的情況。如果你把它徹底暴露出來,你無法控制其他的程式碼訪問它甚至依賴它
- 既然模組在全域性變得都可以訪問,你很難保證它不會被弄“髒”:對它引用的模組很可能不小心的修改了它,導致其他依賴它的模組變得不可用。技術人員大部分都沒有耐性等待模組的釋出者修復bug或者新增功能,他們更寧願臨時 hack 一把,然後心裡想著等模組實現了這個功能我再還原回去。不過這個然後從來都不會發生
所以這也是為什麼 CommonJS 誕生的原因,為什麼我覺得 window.app
不是一個好的實踐
另一方面,人工手動的指定 namespace 也是一個很令人疑惑的地方。按照我的理解,它們在這裡想達到的效果只是給不同的專案以不同的作用域以便將它們隔離開,包括 reducer、包括 css。所以 namespace 是什麼並不重要。極端來說,哪怕直接生成一堆隨機字串也是OK(參考 css modules 的實踐)
即使是人工指定名稱空間,你有如何保證某一天它們不衝突呢? Who watches the watchman ?
總結
技術方案是每個公司每個團隊再特定業務場景下的產物,沒有優劣之分。如果美團的技術足夠統一、公共類庫版本維護的足夠勤快、開發足夠規範,這樣的方案沒有任何問題。我考慮的角度更多的是從我面對的業務邏輯出發,很顯然我是不幸的,因為需要處理的分支情況太多。
我相信本文中有很多我對原文理解不正確的地方,如果有本身在美團做這項業務的朋友,或者認識原文的作者,可以互相轉告,期待進一步的指正和交流
參考資料
這篇文章也釋出在我的知乎專欄,歡迎大家關注