微前端拆分實踐


微前端拆分實踐

這篇文章是我一次活動分享的講稿

最近項目上機緣巧合用微前端解決了一些團隊問題,借此機會跟大家分享一下。

微前端作為近兩年興起的一種解決方案,也不是什么新東西了,既然是解決方案,那么微前端幫我們解決了什么問題呢?這里我以我們項目組為例子講講:

我們為什么需要微前端?

我們的項目整體來看算得上一個比較大型的項目,整個項目規划完成后有 17 條業務線。但是在剛起項目的時候由於種種原因並沒有考慮周全,將項目當成一個普通的前端項目來解決,在第一期項目結束,第一條業務上線后,我們緊接着開始了第二和第三條業務線的開發,緊接着我們就遇到了一些問題:

代碼沖突

一期項目上線后交由維護團隊維護,交付團隊繼續后面項目的開發。由於所有代碼在同一個 repo 中作為一個大型單體被共同維護,兩個團隊的代碼修改常常有沖突,需要小心 merge。同時還需要理解對方的業務,看自己的業務會不會破壞對方的業務。

部署沖突

由於所有的基礎設施包括 CI/CD 等都是公用的,任何一個團隊想要部署自己的代碼,勢必會對另外一個團隊造成影響,不管是 feature toggle 還是 chunk base 的開發方式都將增大開發人員的心智負擔。

技術棧沖突

由於項目比較大,未來團隊的數量不確定,我們不能將技術棧限制死,否則就有可能有的團隊要使用自己完全不熟練的技術棧,更別說未來還有第三方團隊加入的可能性,我們不希望將整個項目綁定在某一個技術棧上。

基於這樣的背景,我們發現微前端這套解決方案很好地解決了我們的問題。說白了在我們的項目背景下,我們最希望得到的東西是 -- 團隊自治。

我們希望各個業務線的團隊能夠自由修改自己的代碼,不用擔心與別的團隊產生沖突。她們可以自由選擇自己熟悉的技術棧,不必有過多限制。同時任何團隊的部署都不會影響其他團隊,這也就意味着某一個團隊負責的部分如果掛掉了,網站上其他團隊維護的部分也是可用的。

最重要的,這樣的架構可以讓各個團隊聚焦在自己的技術和業務上,減少各個團隊不必要的無效溝通,提升各個團隊的開發效率。

拆分時機

對於微前端的拆分來說,這是一項工作量較大的技術改進,而且它不同於別的技術改進,它沒有模版,沒有辦法按部就班的從網上找個東西過來照抄,必須要結合自己的項目來進行。

另一方面,我們需要達成共識的是,在我們的日常開發中,大多數情況下項目上不可能給開發人員足夠的時間來做技術改進,這就意味着大多數技術改進需要同業務開發一同進行。那么找准一個改進的時機就很重要了。

那么這樣的時機通常是什么時候呢?

業務有較大的改變或演進

這種情況我想大多數同學都經歷過,在開發最初說的好好的需求,由於種種原因需要做一次大的改變。面對這種大的需求變更,通常我們的代碼也需要做對應的改變,而這種改變也需要重寫一些代碼,這個重寫的過程就是一個很好的進行拆分的好時機。

在這個期間我們有足夠的理由說服項目干系人給我們時間去重新組織項目代碼去更好地支持業務的發展。

業務穩定不再有大的改進

此時業務的發展趨於穩定,但目前的架構如果也的確給開發造成了阻礙。那么就可以在這個穩定架構上進行改進。當然此時的業務還在發展,我們可以采取兩種策略:

  • 一種是以拆分任務為高優先級,新的業務開發基於新的架構

  • 一種是先在舊的架構上持續開發,在拆分的過程中由負責拆分的同學將業務和技術一起遷移過去

拆分原則

我們在拆分微前端的時候一定是帶有某種目的的,有可能是想對技術棧進行漸進式升級,也有可能像我們一樣想提升各個獨立團隊的自治力,在不同的目的下我們可能會秉持不同的原則,這也是另一個為什么微前端的拆分沒辦法簡單抄作業的原因。

就我們項目來說,我們追求各個團隊的最高自治力,那么我們就希望各個獨立 app 盡量減少彼此的通信和依賴,每個 app 能夠盡量獨立處理自己的業務。

在這樣的大前提下,我們可以按照業務為主模塊為輔的方式指導拆分,基於此,我們定義了一些拆分時候的原則:

  • 保證業務獨立,一條業務線應該由一個獨立的 app 來支撐,使得該業務團隊擁有這個 app 的完全控制權

  • 跨業務的頁面不應該各個業務各自持有,也應該拆分為一個獨立的 app

  • 通用方法庫和通用組件庫由大家共同維護以支撐各自的業務

拆分前的准備

前置概念

single-spa

Single-spa 是一個微前端框架,它不限制每一個 app 具體使用怎樣的技術棧,主要通過控制 route 的方式在頁面上渲染不同的 app。

在開始微前端的拆分前我們進行了一些調研后選擇了它作為我們微前端的框架,說是調研其實當時我們並沒有過多的了解每一個框架,比如國內比較有名的 qiankun。

這里其實有一個小插曲,我們第一個了解的框架就是 single-spa,當時有一個小需求 single-spa 實現不了,於是我按照官網的文檔去 slack 詢問,第二天一大早我就收到了回復,算上時差他們一看到我的問題就給了我答復,這個反饋速度加上對國內開源社區的不樂觀,我們直接就選擇了 single-spa。

In-broswer module vs build time module

在開始實踐前,我可能需要給大家介紹兩個概念以幫助大家更好地理解接下來的架構設計,第一個概念是 in-broswer module,或者叫做 es6 modules,與之對應的是現在用途最廣的 build time module,這兩個 module 有什么區別呢?我們先來看一個圖:

module-build-result

這個圖里兩個 js 文件互相引用后最后打包的結果就是 build time module。在寫代碼的時候雖然你覺得這兩個文件是分離的,但是其實在最終打包的時候這兩個文件里的內容會被合並,最終變成一個 js 文件,然后這個 js 文件被 html 文件引用。

in-broswer module 則不同,這種模塊是瀏覽器根據你提供的 url 從網絡中請求回來的,你的每一個 import 都代表了一次網絡請求,各個文件真的變成了獨立的模塊,通過網絡請求相互依賴。

但是這樣的模塊有一個缺點,就是它沒有辦法像我們日常開發一樣直接給一個名字就能直接引用到對應的模塊:

 
import singleSpa from "single-spa";
 
 
 
復制代碼
 

由於需要在網絡中定位到這個模塊在哪里病發送對應的請求,它需要一個完整的 url:

 
import singleSpa from "https://cdn.jsdelivr.net/npm/single-spa/esm/single-spa.min.js";
 
 
 
復制代碼
 

Import-map

這個特性使得大多數程序員都不喜歡它,畢竟大多數人都不想寫一串長長的 url 來引用一個模塊。為了解決這個問題,WICG 起草了一個新的瀏覽器規范,這個規范叫做 import map:

 
<script type="importmap"> {  "imports": {   "single-spa": "https://cdn.jsdelivr.net/npm/single-spa/esm/single-spa.min.js"  } }</script>
 
 
 
復制代碼
 

import map 是一段特殊的 js,它的 type 為 importmap,在這個 script 標簽里面的是一個 json object。這個 json object 的 key 就是某一個模塊的名字,而它對應的 value 就是這個模塊的 url 地址。

當然,既然 import-map 是一個 script 標簽,那么理所應當它也可以加上 src 屬性,成為一段外部 script:

 
<script type="importmap" src="https://some.url.to.your.importmap"></script>
 
 
 
復制代碼
 

在一些情況下,可能你的項目中引用了某一個包的不同版本,這時候可以用 import-map 的 scopes 功能來限制某一個文件的引用:

 
<script type="importmap"> {  "imports": {   "lodash": "https://unpkg.com/lodash@3"  },  "scopes": {   "/module-a/": {    "lodash": "https://unpkg.com/lodash@4"   }  } }</script>
 
 
 
復制代碼
 

這里的 scopes 代表了如果某一個 module 以 module-a 開頭那么里面如果有引用 lodash 的 import,這個 import 將會引用 v4 版本,其他的 import 則都是引用的 v3 版本。

於是根據這個 import-map,我們就能夠在代碼里像使用正常模塊那樣使用 in-broswer module 了:

 
import singleSpa from "single-spa";
 
 
 
復制代碼
 

Systemjs

然后接下來就是前端傳統節目,很顯然,這么新的規范大部分瀏覽器目前都是不支持的,更別提永遠也不可能支持的 IE 了,所以我們需要 polyfill - systemjs,它怎么工作的這里為了不扯遠就不再贅述了,感興趣的同學可以通過鏈接去 github 里面看文檔,總的來說這是一個專門為了 es-module 而生的 polyfill。

我們從一個簡單的 demo 來看它是怎么讓 import-map 工作的:

es6-module-syntax

這是一個很簡單的 demo,HTML 頁面中留有一段 template,然后導入一份 es-module,這份 module 也很簡單,只做了一件事就是導入 vue 然后把 template 里面的 name 換成我們想要的東西。

但是這里有一個細節,我們在導入 vue 的時候必須用一段 url 來導入,如果我們把這段 url 換成我們平時開發時的字符串會發生什么呢?

import-without-url

這里會發生這樣的錯誤是因為我們在 script 標簽上標記了這個 script 是一個 es-module,於是里面的 import 關鍵字是瀏覽器在運行時執行的,但是因為后面的字符串沒辦法告訴瀏覽器 Vue 這個資源到底在哪,瀏覽器當然也就找不到對應的資源,於是就報錯了。

如果我們想要將 url 替換為我們平時開發時候的字符串,就得依賴於 import-map,但是大部分瀏覽器現在都還不支持這一特性,於是我們需要引入 systemjs:

how-to-use-systemjs

由於我們使用了 systemjs,為了按照它的規矩來行事,我們需要在原本的規范上修改一些代碼:

  • 首先是我們需要在開始引入 systemjs

  • 然后將 import-map 的 type 從 importmap 改為 systemjs-importmap

  • 接着把 es-module 的 type 從 module 改為 systemjs-module

  • 最后是改動最大的地方,在 es-module 中我們不再使用 import 和 export 來導入導出模塊,轉而使用 systemjs 的語法,不過不用擔心, webpack 和 rollup 等打包工具現在都支持將代碼打包成 systemjs 風格,所以我們在寫代碼的時候還是可以按照正常規范來寫

架構設計

到這里我們的前置概念就介紹完了,可以准備開始正式的拆分工作了,不過在拆分開始前,我們需要提前設計好我們的基礎設施架構和代碼組織方式。

基礎設施架構

基於 single-spa 加上 import-map,我們最后計划好的基礎設施架構大概長這個樣子:

arch-of-micro-fe

  1. 首先我們前端的QQ賬號出售地圖所有靜態資源都會分別部署在 AWS 的 S3 服務中,其中唯一的一份 HTML 文件存放在 root 容器的 S3 中。

  2. 當用戶訪問我們的網站時,流量會從 client 端到達 root 容器的 AWS S3,這個時候用戶的瀏覽器會先加載根路徑下的 HTML 頁面,而 HTML 頁面的 head 標簽中有一份 import-map 的 script。

  3. 這時候 client 會再發送一次請求到我們的 import-map 所在的 S3 拿到 import-map。

  4. 然后我們在 body 標簽中用 systemjs 引入 root 容器,整個 APP 開始運轉,之后根據不同的路徑去不同的 S3 拿對應的靜態文件

部署策略

為了能夠達到各個團隊獨立自治的目的,部署是必不可缺的一環,我們的最終目的是不同的團隊部署不會影響其他團隊的業務。一個團隊的線上代碼出了問題,其他團隊的業務仍可正常運行,對於一個 to B 的項目來說,這樣的規划是有意義的。

delpoy-plan

基於這個目的,每一個團隊自己維護自己的 app 的 CI/CD pipeline。需要特別注意的是,在每一次部署后需要更新 import-map 自己團隊對應的 app 地址,這樣還可以達到版本管理的目的。只要 S3 中一直存放着某一個版本的靜態資源,僅僅更新 import-map 的對應地址即可達到快速部署和回滾的目的。

pipeline-stage

本地開發策略

在本地開發時有兩種策略,一種是直接在本地啟動一個 root 容器,然后將本地的 APP 注冊到 root 容器中。

但是這樣的開發方式需要解決依賴問題,比如 APP 依賴的通用方法庫、通用組件庫。解決這些依賴問題也有兩個辦法,一個是直接將對應的依賴打包,在本地進行配置,本地開發時直接引用打包好的依賴;第二個方式是將這些依賴作為一個共享 APP 直接在本地作為一個類似於 server 一樣運行,然后通過 import-map 來共享,在開發時直接引用導出的方法和組件,而 single-spa 也提供了這樣的方式,感興趣的讀者可以通過這個鏈接詳細了解。

第二種方式則要簡單許多,並且開發體驗也會好很多。通常我們都有開發環境。我們可以直接在線上開發環境的 import-map 開一個口,利用 import-map-overrides 這個工具把線上的 import-map 對應的那個 APP 地址覆蓋成本地地址。這時線上通過 import-map 去尋找這個 APP 的時候就會直接請求你的本地某個地址,然后線上運行的代碼其實就已經是你本地的代碼了,可以無縫與各種依賴開發。

你可能會覺得有安全問題,但其實這個工具可以做一些配置,比如只在本地和某一個域名下才打開這個口子,在別的地方都不開放這個后門。

實際拆分

problem

講了這么多,終於開始上手了,但是這個世界上有一句名話叫做理想很豐滿,現實很骨感。當你興致勃勃准備好了一切計划,現實一般都不會讓你如願。我們這些看起來都還不錯的計划有一部分被金主爸爸暫時擱置了,有一部分由於設計不妥開發體驗不佳也被改造了。

太貴了

成本永遠是和金主爸爸談判繞不開的話題,我們新的架構設計在單體前端的基礎上增加了許多東西:

  • 多 repo(當然這個不算錢,也就沒啥阻礙,但是最終也沒有用多 repo 的方案,這個后面再聊

  • 多 pipeline

  • 多部署資源(每一個 APP 使用單獨的 S3

  • 多出來的 import map service

以前 10 塊錢就能干完的活,你這么一搞我得出 100 塊了吧,你這么玩我的錢包很難辦啊

金主爸爸如是說。這種情況下我們就需要和金主爸爸談判,為什么這些東西是必要的,為什么我們需要加這么多資源。但項目的問題在於,我們沒時間談判了,所以決定采取“架構降級”:

  • 先暫時用一條 pipeline 來 build 我們的 app,在下一期項目有足夠證據的時候切分 pipeline 這一決定在后來驗證是完全錯誤的,設想一下一個內存只有 1G 的 agent,需要 build 一個有 5 個 APP 的前端項目同時由於金主爸爸的錢包問題,我們項目只有一個 agent,請想象一下我們的日常開發 hhhhh

  • 先暫時將所有 app 部署到同一個地方,以文件夾分隔,如果一段時間后發現能滿足需求,就先保持原狀

  • 每次 build 生成一份 import map,不單獨維護 import map 資源,當團隊相互影響時再尋求拆分時機

repo 拆分問題

我們一開始的設想是一個單獨的 APP 拆分為一個單獨的 repo,真正上手的時候仔細一想,有必要嗎?

這讓我回想起了一期項目時后端的微服務 repo,由於是一期項目,不同微服務之間的調用需要 setup,所以大多數時候本地都打開了三個以上的 Intellij,加上亂七八糟的其他應用,不得不說對 16G 內存的 Macbook 是一個考驗。

回到前端這邊,極有可能我們在日常的開發過程中會頻繁抽取/更改公用代碼庫,也就意味着我們需要頻繁提交更改,更新版本,然后才能使用,想想都不想做了。

再者,目前兩個團隊的體量其實還不必如此細致的拆分

有必要嗎 - 繁瑣的開發流程 - 多個本地 idea

公用代碼難以維護 - 不同 repo 不同更改 - ts 類型引用問題

跨業務頁面拆分問題

最初的設想是一條業務線是一個單獨的 APP,一些跨業務的頁面(也就是每一個業務都會有的頁面,比如 User Account Management)也會被單獨抽取一個 APP。

我們也真的這么做了,然后小伙伴們就戴上了痛苦面具:

  • “BA 說這個頁面是統一的,這個業務的改動,那個業務也要改。” “抽!”

  • “BA 說這個新的頁面要獨立,所有新功能要在所有業務中生效。” “抽!”

  • ......

“這個公共頁面的邏輯跟那邊的邏輯是一樣的,我們是 copy 一份?” “......”

這樣的策略導致我們的項目中存在大量 APP,而這些 APP 仔細一想好像沒必要啊。增加 build 成本的同時也增加了我們自己的開發和維護成本,這拆的本末倒置了,於是我們做了一個改進 - 將所有公共頁面塞進了一個 APP 中。

這個方案咋一聽怪怪的,但是真的這么做了以后發現真香。所有的改動都會在所有的業務生效,不同的業務用不同的權限限制,大家維護同意份代碼。等一下,你剛剛不是說不想大家維護同一份代碼怕沖突嗎?

這里的情況恰恰相反,所有的改動和需求都需要在所有地方生效,這樣的方式我們就不用維護多份代碼,而且也不會造成沖突 - 因為需求方的需求是單向的,如果有沖突,那就是需求沖突了,需要金主爸爸自己內部去掰頭了。

可能有的小伙伴會說,怎么不試試后端拆分方式,使用 DDD 來指導拆分呢?巧了么不是,一開始我們就是按照后端 DDD 的方式來指導拆分的,然后就發生了這些問題,至少在我們的實踐過程中,微服務的拆分方式不能照搬到前端來。

CSS 沖突問題

這是我們遇到的另一個比較嚴重的問題。我們在項目中使用了 Material UI,其中的 CSS 使用的是 CSS-in-JS 的方式,又因為有一套自己的 class name 生成規則,在沒有控制好 scope 的情況下,多個 APP 的樣式名沖突了,導致了嚴重的互相影響。

這雖然不是 single-spa 的問題,但是 single-spa 也提供了一些解決方案,包括 JS lib 和 CSS 的隔離問題,這些方案可以輕易地在官網或者 github issue 里面搜索到,這里就不過多解釋了。解決的關鍵在於使用不同的 JS 或者 CSS 方案要做好相應的隔離。

寫在最后

以上大概就是我們在拆分微前端過程中遇到的還記得住的事情了,從這次拆分中給我最大的益處其實不是技術上的提升,而是讓我明白了做項目的兩個關鍵點:

  • 所有事情不會原封不動按照你的計划執行,越大的事情越是這樣,及時考慮突發事件,靈活應變,不要拘泥於設計,基於現實改變計划才是可行之策。

  • 架構的演進應該逐步推進,穩步前行,沒有必要在一次架構演進中考慮好未來的所有情況,先不說你能不能考慮周全,誰又能說未來的情況不會發生改變呢,不要以現在的情況去揣度未來的情景,過好當下,靈活設計,提前預防未來可能發生的狀況,准備好 plan B 即可。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM