微前端實踐
在 toB 的前端開發工作中,我們往往就會遇到如下困境:
- 工程越來越大,打包越來越慢
- 團隊人員多,產品功能複雜,程式碼衝突頻繁、影響面大
- 內心想做 SaaS 產品,但客戶總是要做定製化
不同的團隊可能有不同的方法去解決這些問題。在前端開發日新月異、前端工程化蓬勃發展的今天,我想給大家介紹下另一種嘗試——微前端。
微前端是什麼
那什麼是微前端?微前端主要是借鑑後端微服務的概念。簡單地說,就是將一個巨無霸(Monolith)的前端工程拆分成一個一個的小工程。別小看這些小工程,它們也是“麻雀雖小,五臟俱全”,完全具備獨立的開發、執行能力。整個系統就將由這些小工程協同合作,實現所有頁面的展示與互動。
可以跟微服務這麼對比著去理解:
微服務 | 微前端 |
---|---|
一個微服務就是由一組介面構成,介面地址一般是 URL。當微服務收到一個介面的請求時,會進行路由找到相應的邏輯,輸出響應內容。 | 一個微前端則是由一組頁面構成,頁面地址也是 URL。當微前端收到一個頁面 URL 的請求時,會進行路由找到相應的元件,渲染頁面內容。 |
後端微服務會有一個閘道器,作為單一入口接收所有的客戶端介面請求,根據介面 URL 與服務的匹配關係,路由到對應的服務。 | 微前端則會有一個載入器,作為單一入口接收所有頁面 URL 的訪問,根據頁面 URL 與微前端的匹配關係,選擇載入對應的微前端,由該微前端進行進行路由響應 URL。 |
這裡要注意跟 iframe 實現頁面嵌入機制的區別。微前端沒有用到 iframe,它很純粹地利用 JavaScript、MVVM 等技術來實現頁面載入。後面我們將介紹相關的技術實現。
為什麼要用微前端
在介紹具體的改造方式之前,我想跟大家先說明下我們當時面臨的問題,以及改造後的對比,以便大家以此為對照,評判或決定使用。主要包括打包速度、頁面載入速度、多人多地協作、SaaS 產品定製化、產品拆分這幾個角度。
首先是打包速度。在 6 個月前,我們的 B 端工程那會兒還是一個 Monolith。當時已經有 20 多個依賴、60 多個公共元件、200 多個頁面,對接 700 多個介面。我們使用了 Webpack 2,並啟用 DLL Plugin、HappyPack 4。在我的個人主機上使用 4 執行緒編譯,大概要 5 分鐘。而如果不拆分,算下來現在我們已經有近 400 個頁面,對接1000 多個介面。
這個時間意味著什麼?它不僅會耽誤我們開發人員的時間,還會影響整個團隊的效率。上線時,在 Docker、CI 等環境下,耗時還會被延長。如果部署後出幾個 Bug,要線上立即修復,那就不知道要熬到幾點了。
在使用微前端改造後,目前我們已經有 26 個微前端工程,平均打包時間在 30-45 秒之間(注意,這裡還沒有應用 DLL + HappyPack)。
頁面載入速度其實影響到並不是很大,因為經過 CDN、gzip 後,資源的大小還能接受。這裡只是給大家看一些直觀的資料變化。6 個月前,打包生成的 app.js 有 5MB(gzip 後 1MB),vendor.js 有 2MB(gzip 後 700KB),app.css 有 1.5MB(gzip 後 250KB)。這樣首屏大概要傳輸 2MB 的內容。拆分後,目前首屏只需要傳輸 800KB 左右。
在協作上,我們在全國有三個地方的前端團隊,這麼多人在同一個工程裡開發,遭遇程式碼衝突的概率會很頻繁,而且衝突的影響面比較大。如果程式碼中出現問題,導致 CI 失敗,所有其他人的程式碼提交與更新也都會被阻塞。使用微前端後,這樣的風險就平攤到各個工程上去了。
再者就是定製化了。我們做的額是一款 toB 的產品,做成 SaaS 標準版產品大概是所有從業者的願望。但整體市場環境與產品功能所限,經常要面臨一些客戶要求做本地化與定製化的要求。本地化就會有程式碼安全方面的考量,最好是不給客戶原始碼,最差則是隻給客戶購買功能的原始碼。而定製化從易到難則可以分為獨立新模組、改造現有模組、替換現有模組。
通過微前端技術,我們可以很容易達到本地化程式碼安全的下限——只給客戶他所購買的模組的前端原始碼。定製化裡最簡單的獨立新模組也變得簡單:交付團隊增加一個新的微前端工程即可,不需要揉進現有研發工程中,不佔用研發團隊資源。而定製化中的改造現有模組也可以比較好地實現:比如說某個標準版的頁面中需要增加一個面板,則可以通過一個新的微前端工程,同樣響應該頁面的 URL(當然要控制好順序),在頁面的恰當位置插入一個新的 DOM 節點即可。
最後就是產品拆分方面的考量了。我們的產品比較大,有幾塊功能比較獨立、有特色。如果說將來需要獨立成一個子產品,有微前端拆分作為鋪墊,騰挪組合也會變得更加容易些。
其他目標
有了以上的一些原因與訴求,在決定進行微前端改造前,還需要設定一些額外的小目標:
- 不能對現有的前端開發方式帶來太大變化,至少要有平滑過渡的機制。
- 每個為前端工程都要求可以獨立執行,至少在本地開發時要能做到。
- 微前端在載入時,要實現預載入,並可以自由調整預載入順序,甚至是根據使用者的偏好來實現智慧化、個性化的載入順序。
如何改造現有工程
“Talk is cheap,show me the code“。下面就讓我們一起來看看具體的改造吧!我們的微前端工程可以劃分為 portal 工程、業務工程、common 工程這幾類。
portal 工程
portal,顧名思義,就是入口。這也就是上面所說的微前端載入器。當用戶開啟瀏覽器,首次進入我們的頁面時,不管是什麼 URL,首先載入的就是 portal。portal 裡會配置所有業務工程的地址、匹配哪些 URL、需要載入哪些資源。如:
// 業務工程的名稱 customer: { // URL 匹配模式 matchUrlHash: ['^/customer'], // 微前端地址 target: 'http://localhost:8101/mfe-customer/index.html', // 資源匹配模式 resourcePatterns: ['/app.*.css$', '/vendor.*.css$', '/manifest.*.js$', '/vendor.*.js$', '/app.*.js$'], } 複製程式碼
portal 會定時、非同步、併發地下載業務工程的資源,並將它們進行註冊,此時並不會載入這些業務工程。這裡之所以要業務工程的地址(target)、資源(resourcePatterns),是為了載入時確定地知道其所包含的 app.js、vendor.js、app.css 等資源的路徑。因為業務工程每次有變更,app.js 等資源路徑上都會帶有新的檔案內容雜湊值(Hash),導致路徑不可預測。而它的 index.html 的路徑是固定的。我們讀取該 HTML,解析其內容,通過正則就能匹配到 app.js 等資源的路徑。
portal 在執行時,會監聽 URL 變化。目前我們只支援 URL Hash(如 #/customer)。當 Hash 發生變更時,匹配到業務工程,然後執行解除安裝、載入的工作。這個機制主要是利用 single-spa
來實現,但原理就是這麼簡單。
import { registerApplication } from 'single-spa'; registerApplication('customer', // 下載微前端工程,獲取三個函式鉤子:bootstrap、mount、unmount () => { const html = fetch(mfeConfig.target); const {cssUrls, jsUrls} = match(html, mfeConfig.resourcePatterns); loadCss(cssUrls); loadJs(jsUrls); return windows['mfe:customer']; }, // 對當前瀏覽器 URL Hash 進行匹配,如果匹配(返回 true),則載入該微前端(呼叫 mount);否則解除安裝(呼叫 unmount) () => { return match(window.location.hash, mfeConfig.matchUrlHash); }, mfeConfig.customProps ); 複製程式碼
業務工程
業務工程就是普通的微前端工程,一般一個模組一個工程。業務工程要扮演兩個角色,一個是可獨立執行的前端工程,一個是受 portal 控制的執行時。前者主要用於我們本地開發,後者則是線上整合時使用。在獨立執行時,它跟原來的前端工程沒有什麼區別。以 Vue 工程為例,照樣使用 new Vue({el: '#app'})
來啟動、渲染頁面。
new Vue({ el: '#app', i18n, router, store, template: '<App/>', components: { App } }); 複製程式碼
而當受控執行時,則是利用 UMD 方式輸出幾個鉤子函式,包括初始化、載入、解除安裝。
if(!window.IS_IN_MFE){ // 獨立執行時 new Vue({...}) } else { // 受控執行時 module.exports = { bootstrap(){ // 註冊時執行 }, mount(customProps){ // 載入時執行 return Promise.resolve().then(()=>{ instance = new Vue({...}) }) }, unmount(){ // 解除安裝時執行 return Promise.resolve().then(()=>{ instance.$destroy() }) } } } 複製程式碼
線上環境的 Webpack 配置:
output: { libraryTarget: "umd", library: 'mfe:customer' } 複製程式碼
而區分是否受控,則可以通過判斷一個全域性變數來實現。如 window.IS_IN_MFE
,portal 工程在執行時會將其設定為 true
。
為了支援本地多個工程同時開發,我們需要為每個微前端工程指定一個確定的、獨佔的埠號。比如從 8100 開始,逐一遞增。同時,為了支援線上部署,我們還需要給每個微前端工程指定一個確定的、獨佔的基礎路徑(字首)。這樣相同域名下可以用不同路徑進行獨立訪問。路徑統一以 /mfe-
開頭,如 /mfe-customer
。這也就是上面 portal 裡業務工程的配置示例裡所展現的那樣。
特殊業務工程:mfe-navs
我們產品的頁面結構分為頂部欄、側邊欄、中間內容區三大塊。頂部欄和側邊欄在頁面跳轉過程中,基本上保持不變。所以我們也將它們剝離出來作為一個獨立的微前端業務工程,叫做 mfe-navs
。它會匹配所有的 URL,也就是說訪問任意 URL 時,都會載入它,而且還要保證先載入它。當它載入完畢後,會在頁面內提供一箇中間內容區的錨點 DOM( #app
:),供其他業務工程載入時掛載。
Common 工程
上面可以看到,每一個業務工程都是一個獨立的前端工程,所以裡面會有一些相同的依賴,如 Vue、moment、lodash 等。如果將這些內容都打包到各自的 vendor.js 裡,則勢必會導致程式碼冗餘太多,瀏覽器執行記憶體壓力增大。我們把這些公共依賴、公共元件、CSS、Fonts 等都放到一個工程裡,由該工程進行打包,將依賴、元件 export,並以 UMD 的方式注入到全域性。
main.js:
import Vue from 'vue'; // 公共依賴 import VueRouter from 'vue-router'; import VueI18n from 'vue-i18n'; import '@/css/icon-font/iconfont.css'; import ContentSelector from '@/components/ContentSelector'; // 公共元件 Vue.use(VueI18n); // 大家都要這麼做,我們就代勞吧! module.exports = { 'vue': Vue, 'vue-router': VueRouter, 'content-selector': ContentSelector, }; 複製程式碼
Webpack 配置:
output: { libraryTarget: "umd", library: 'mfe:common' } 複製程式碼
業務工程則通過 Webpack 外部依賴(external)的方式引入到工程中。這樣業務工程打包時就不會包含這些公共程式碼了。
var externalModules = ['vue', 'vue-router', 'content-selector']; module.exports = { // webpack 配置項 // ... externals: (context, request, callback)=>{ if(externalModules.includes(request)){ callback(null, 'root window["mfe:common"]["'+request+'"]') } else { callback(); } }, } 複製程式碼
結語
以上就是我們微前端改造與實踐方面的一些經驗。前路漫漫,這裡面還存在很多待完善的地方,如 History 模式支援、i18n 更好地整合、各個業務工程的載入順序優化及個性化等。除了這些純粹技術上的探索,在擁有微前端、微服務這些架構的基礎上,團隊也可以考慮進行垂直拆分:一個小組獨立負責一塊業務,它有自己的微前端工程和微服務工程。從技術管理到人員管理,將它們糅合在一起統一考慮,這也是我們軟體工程的探索方向。期待這些能夠對大家帶來一些思考和幫助!