微前端是一種利用微件拆分來達到工程拆分治理的方案,能夠解決工程膨脹、開發維護困難等問題。隨着前端業務場景愈來愈複雜,微前端這個概念最近被提起得愈來愈多,業界也有不少團隊開始探索實踐並在業務中進行了落地。能夠看到,不少團隊也遇到了各類各樣的問題,但各自也都有着不一樣的處理方案。誠然,任何技術的實現都要依託業務場景纔會變得有意義,因此在闡述美團外賣廣告團隊的微前端實踐以前,咱們先來簡單介紹一下外賣商家廣告端的業務形態。目前,咱們開發和維護的系統主要包括三端:前端
如上圖所示,原始解決方案的三端由各自獨立開發和維護,各自包含全部的業務線,而咱們的業務開發狀況是:react
在這種特殊的業務場景下,就會出現一個有關開發效率的抉擇問題。即咱們但願能複用的部分只開發一次,而不是三次。那麼接下來,就有兩個問題擺在咱們面前:webpack
咱們這裏重點看一下物理層面的複用,即:如何在物理空間上使得各自獨立的三端系統(不一樣倉庫)引入咱們的複用層?咱們嘗試了NPM包、Git subtree等類「共享文件」的方式後發現,最有效率的複用方式是把三個系統放在一個倉庫裏,去消除物理空間上的隔離,而不是去鏈接不一樣的物理空間。固然,咱們三端系統的技術棧是一致的,因此就進行了以下圖的改造:ios
能夠看到,當咱們把三端系統放在一個倉庫中時,經過common文件夾提供了物理層面可複用的土壤,再也不須要「共享文件」式地進行頻繁地拉取操做,直接引用複用便可。不過,在帶來物理層面複用效率提高的同時,也加速了整個工程出現了爆炸式發展的問題,隨着產品線從最初的幾個發展到如今的幾十個之多,工程管理成本也在迅速增加。具體來講,包括以下四個方面:web
以下圖所示,具體地說明了原有架構存在的問題。爲了要解決這些問題,咱們意識到須要拆分這些應用,即進行工程優化的常規手段進行「分治」。那麼要怎麼拆呢?天然而然地咱們就想到了微前端的概念。也從這個概念出發,咱們參考業界優秀方案,同時也深度結合了廣告端實際業務的開發狀況,對現有工程進行了微前端的實踐與落地。redux
結合現有工程的情況,咱們進行了深度的分析。不過,在進行微前端方案肯定前,咱們先肯定了需求點及指望收益,以下表所示:axios
需求點 | 收益與要求 |
---|---|
拆分解耦 | (1)按業務領域拆分紅不一樣的倉庫進行維護,不一樣業務線的開發者更加獨立,不一樣業務線之間互不影響。(2)物理層面拆分,加速尋址,新增功能修改Bug更加迅速。(3)邏輯層面拆分,杜絕引用混亂,不會出現A業務線引用B業務線組件的狀況。 |
加速體驗 | (1)開發環境急速啓動,提升開發體驗。(2)業務線按需打包,急速部署上線。 |
侵入性低 | 微前端方案改動原有代碼的侵入性降到最小,無需大規模改造,減小甚至消除迴歸測試的成本。 |
學習成本低 | 開發人員無需感知拆分的存在,保持單頁應用的開發體驗,不須要學習額外的規則。 |
統一技術棧 | 爲了統一共建與技術沉澱,團隊內工程已經統一到了React技術棧,禁止使用不一樣的技術棧進行開發。 |
通過以上的需求分析,咱們調研了業界及公司周邊的微前端方案,並總結了如下幾種方案以及它們各自主要的特色:緩存
經過對各個方案特色進行分析,咱們將重點關注項進行了對比,以下表所示:網絡
方案 | 技術棧是否能統一 | 單獨打包 | 單獨部署 | 打包部署速度 | 單頁應用體驗 | 子工程切換速度 | 工程間通訊難度 | 現有工程侵入性 | 學習成本 |
---|---|---|---|---|---|---|---|---|---|
NPM式 | 是(不強制) | 否 | 否 | 慢 | 是 | 快 | 正常 | 高 | 高 |
iframe式 | 是(不強制) | 是 | 是 | 正常 | 否 | 慢 | 高 | 高 | 低 |
通用中心路由基座式 | 是(不強制) | 是 | 是 | 正常 | 是 | 慢 | 高 | 高 | 高 |
特定中心路由基座式 | 是(強制) | 是 | 是 | 快 | 是 | 快 | 正常 | 低 | 低 |
通過上面的調研對比以後,咱們肯定採用了特定中心路由基座式的開發方案,並命名爲:基於React的中心路由基座式微前端。這種方案的優勢包括如下幾個方面:react-router
經過對方案的分析及技術方向上的梳理,咱們肯定了微前端的總體方案,以下圖所示:
能夠看到,整個方案很是簡單明確,即按照業務線進行了路由級別的拆分。整個系統可分爲兩個部分:
基座工程和子工程聯繫起來的橋樑則是子工程的入口文件地址和路由地址的映射信息。這些映射信息可讓基座工程準確地發現子工程資源的路徑從而進行加載。
通過微前端實踐的改造,咱們的業務在結構上發生了以下的變化:
如上圖所示,咱們進行了微前端式的業務線拆分:
新的拆分使得子工程可以按照業務線進行劃分,獨立維護。在解決複用層的同時保證了子工程大小可控,即子工程只有單個業務線的代碼。而單個業務線的複雜度並不高,也下降了工程維護的複雜度。
採用微前端拆分的方案,使得咱們的業務不只在縱向上保有了複用的能力,更重要的是擁有了橫向擴展的能力,不管產品業務線如何膨脹,咱們均可以更輕鬆地應對。那麼爲了實現以上的能力,咱們作了哪些工做呢?下文咱們會詳細進行說明。
微前端拆分的方案,咱們命名爲:基於React技術棧的中心路由基座式微前端。在具體實現上,咱們會分爲動態化方案、路由配置信息設計、子工程接口設計、複用方案設計和流程方案設計等幾個模塊來逐一進行說明。
首先,咱們須要路由的管理方案,使得子工程之間有能力互通切換。其次,咱們須要Store層的方案,讓子工程有能力使用全局Store。而且,咱們還須要CSS的加載方案,來加載子工程的樣式佈局。下面來詳細說明這三個方案。
動態路由
動態路由方案是想要進行路由級別的拆分,首先咱們要肯定用什麼來管理路由?不少實現方案傾向於使用特製路由來管理模塊。例如開源框架Single-Spa,實現了本身的一套路由監聽來切換子工程,而且須要子工程實現特定的註冊、掛載、卸載等接口來完成子工程和基座工程的動態對接,還須要特定的模塊管理系統,例如systemjs來輔助完成這一過程。毋庸置疑,這對咱們原有工程的改形成本很大,還須要添加額外庫,進而形成包體積大小上的開銷。而且子工程的開發者須要熟悉這些特定的接口,學習成本也比較高。顯然,這對於咱們的業務場景和需求來講很不划算。
那麼,咱們選擇什麼來作路由管理呢?最終咱們使用了React-Router,這樣可以保持咱們原來的技術棧不變,同時對於工程的侵入也是最低,幾乎能夠忽略不計。此外,React-Router能徹底能夠知足咱們的需求,並且自動會幫助咱們管理頁面的加載與卸載,而不是每次切換路由都從新初始化整個子應用,因此在加載速度體驗上也是最優的,跟單頁應用體驗一致。
實現上也很簡單,以下圖:
上面這個流程圖,展現了咱們在基座工程中切換到子工程路由時,加載子工程並進行展現的過程。這裏的重點步驟是加載子工程入口文件,並動態註冊子工程路由的過程。因爲咱們使用的是React-Router,顯然要使用其提供的動態能力來完成。這一過程也很是輕量,因爲React-Router從版本4開始有了「破壞級」的升級,因而咱們就調研了兩種方式進行動態加載路由(目前咱們使用的是React-Router版本5),以下表所示:
React-Router 版本 | 動態加載方式 |
---|---|
3 | 利用Route的getChildRoutes的API異步加載路由。 |
4及以上 | 版本4及以上,React-Router在實現思路上有了很是大的變更,即再也不以提早註冊路由的集中式路由爲設計理念,轉變成路由即組件的思路。對於動態加載路由來講,就是動態加載組件,使得咱們的動態加載更加容易實現,無須依賴任何API,只需寫一個異步組件便可。 |
React-Router版本3中,實現的基本代碼思路以下:
// react-router V3 用於接收子工程的路由
export default () => (
<Route path="/subapp" getChildRoutes={(location: any, cb: any) => { const { pathname } = location.location; // 取路徑中標識子工程前綴的部分, 例如 '/subapp/xxx/index' 其中xxx即路由惟一前綴 const id = pathname.split('/')[2]; const subappModule = (subAppMapInfo as any)[id]; if (subappModule) { if (subappRoutes[id]) { // 若是已經加載過該子工程的模塊,則再也不加載,直接取緩存的routes cb(null, [subappRoutes[id]]); return; } // 若是能匹配上前綴則加載相應子工程模塊 currentPrefix = id; loadAsyncSubapp(subappModule.js) .then(() => { // 加載子工程完成 cb(null, [subappRoutes[id]]); }) .catch(() => { // 若是加載失敗 console.log('loading failed'); }); } else { // 能夠重定向到首頁去 goBackToIndex(); } }} /> ); 複製代碼
而在React-Router版本4中,實現的基本代碼思路以下:
export const AyncComponent: React.FC<{ hotReload?: number; } & RouteComponentProps> = ({ location, hotReload }) => {
// 子工程資源是否加載完成
const [ayncLoaded, setAyncLoaded] = useState(false);
// 子工程url配置信息是否加載完成
const [subAppMapInfoLoaded, setSubAppMapInfoLoaded] = useState(false);
const [ayncComponent, setAyncComponent] = useState(null);
const { pathname } = location;
// 取路徑中標識子工程前綴的部分, 例如 '/subapp/xxx/index' 其中xxx即路由惟一前綴
const id = pathname.split('/')[2];
useEffect(() => {
// 若是沒有子工程配置信息, 則請求
if (!subAppMapInfoLoaded) {
fetchSubappUrlPath(id).then((data) => {
subAppMapInfo = data;
setSubAppMapInfoLoaded(true);
}).catch((url: any) => {
// 失敗處理
goBackToIndex();
});
return;
}
const subappModule = (subAppMapInfo as any)[id];
if (subappModule) {
if (subappRoutes[id]) {
// 若是已經加載過該子工程的模塊,則再也不加載,直接取緩存的routes
setAyncLoaded(true);
setAyncComponent(subappRoutes[id]);
return;
}
// 若是能匹配上前綴則加載相應子工程模塊
// 若是請求成功,則觸發JSONP鉤子window.wmadSubapp
currentPrefix = id;
setAyncLoaded(false);
const jsUrl = subappModule.js;
loadAsyncSubapp(jsUrl)
.then(() => {
// 加載子工程完成
setAyncComponent(subappRoutes[id]);
setAyncLoaded(true);
})
.catch((urlList) => {
// 若是加載失敗
setAyncLoaded(false);
console.log('loading failed...');
});
} else {
// 能夠重定向到首頁去
goBackToIndex();
}
}, [id, subAppMapInfoLoaded, hotReload]);
return ayncLoaded ? ayncComponent : null;
};
複製代碼
能夠看到,這種方式實現起來很是簡單,不須要額外依賴,同時知足了咱們「拆分」的訴求。
動態Store
對於Store層,咱們原工程使用的是Redux,子工程經過路由動態註冊進來自然就能夠訪問到全局Store,因此對於Store的訪問可以自動支持。那麼,若是子工程想要註冊本身的全局Store該怎麼辦呢?並且咱們還用了redux-saga來做爲異步處理方案。redux-saga如何動態註冊呢?仍是利用它們各自的API就能夠達到咱們的目的?從下圖中能夠看到,支持動態Store也是花費很小的改形成本就能夠完成。
動態CSS
一樣的對應子工程的樣式佈局,咱們也須要經過某種途徑加載到基座工程中來。這個很天然地用異步加載CSS文件經過style標籤注入來完成,不過這裏須要注意兩個問題:
一個問題是,加載子工程的JS入口文件和CSS文件能夠同時發起請求,可是須要保證CSS文件加載完成後再進行JS入口文件的路由註冊。由於若是路由先註冊了頁面就會顯示出來,若是這時CSS文件尚未加載完畢,就會出現頁面樣式閃動的問題。咱們經過先加載CSS再加載JS的策略來避免這個問題的發生。
另外一個問題是,怎麼保證子工程的CSS不會和其餘子工程衝突。咱們利用PostCSS插件在編譯子工程時,按照分配給子工程的惟一業務線標識,爲每一組CSS規則生成了命名空間來解決這個問題。而子業務線開發者是沒有感知的,能夠沒有「心智負擔」地書寫子工程的樣式。
在動態加載方案肯定以後,基座工程怎麼才能知道子工程的資源路徑,進而加載對應的JS和CSS資源呢?咱們須要一組映射信息。以下圖所示,業務線惟一標識爲Key,相應的靜態資源地址爲Value。這樣的話,當基座工程切換到子工程時就能夠拉取這個配置信息,在路由切換時準確地找到對應的子工程,進而進行後續的資源加載過程。這裏可能會遇到的一個問題,即若是JS和CSS過大,是否能進行拆分?
根據咱們業務的實際狀況,目前靜態資源的大小是可控的,無需註冊多個,單一入口地址徹底可以知足咱們的業務需求,而且因爲咱們的改造徹底基於現有技術棧。若是業務很複雜,徹底能夠在子工程中經過webpack的動態import進行路由懶加載,也就是說,子工程徹底能夠按照路由再次切分紅chunks來減小JS的包體積。至於CSS自己就很小,長期也不會有進行切分的須要。
子工程須要暴露它要註冊給基座工程的對象,來進行基座工程加載子工程的過程。在子工程入口文件中定義registerApp來傳遞註冊的對象,主要代碼以下:
import reducers from 'common/store/labor/reducer';
import sagas from 'common/store/labor/saga';
import routes from './routes/index';
function registerApp(dep: any = {}): any {
return {
routes, // 子工程路由組件
reducers, // 子工程Redux的reducer
sagas, // 子工程的Redux反作用處理saga
};
}
export default registerApp
複製代碼
咱們這裏暴露了子工程的三個對象:這裏最重要的就是routes路由組件,就是在寫React-Router(版本4及以上)的路由。子工程開發者只須要配置routes對象便可,沒有任何學習成本,其代碼以下:
/** * 子工程路由註冊說明 * 如註冊的路由以下: * path: 'index' * 路由前綴會被追加上,路由前綴規則見變量urlPrefix * 在主工程的訪問路勁爲:/subapp/${工程註冊名稱}/index */
const urlPrefix = `/subapp/${microConfig.name}/`;
const routes = [
{
path: 'index',
component: IndexPage,
},
];
const AppRoutes = () => (
<Switch>
{
routes.map(item => (
<Route
key={item.path}
exact
path={`${urlPrefix}${item.path}`}
component={item.component}
/>
))
}
<Redirect to="/" />
</Switch>
);
export default AppRoutes;
複製代碼
除了上方的routes對象,還剩下兩個接口對象是:reducers和sagas,用於動態註冊全局Store相關的數據和反作用處理。這兩個接口咱們在子工程中暫時沒有開放,由於按照業務線拆分事後,因爲業務線間獨立性很強,全局Store的意義就不大了。咱們但願子工程能夠自行處理本身的Store,即每一個業務線維護本身的Store,這裏就再也不展開進行說明了。
基座工程除了路由管理以外,還做爲共享層共享全局的基建,例如框架基本庫、業務組件等。這樣作的目的是,子業務線間若是有相同的依賴,切換的時候就不會出現重複加載的問題。例以下面的代碼,咱們把React相關庫都以全局的方式導出,而子工程加載的時候就會以external的形式加載這些庫,這樣子工程的開發者不須要額外的第三方模塊加載器,直接引用便可,和平時開發React應用一致,沒有任何學習成本。而和各個業務都相關的公用組件等,咱們會放到wmadMicro的全局命名空間下進行管理。主要代碼以下:
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import * as ReactRouterDOM from 'react-router-dom';
import * as Axios from 'axios';
import * as History from 'history';
import * as ReactRedux from 'react-redux';
import * as Immutable from 'immutable';
import * as ReduxSagaEffects from 'redux-saga/effects';
import Echarts from 'echarts';
import ReactSlick from 'react-slick';
function registerGlobal(root: any, deps: any) {
Object.keys(deps).forEach((key) => {
root[key] = deps[key];
});
}
registerGlobal(window, {
// 在這裏註冊暴露給子工程的全局變量
React,
ReactDOM,
ReactRouterDOM,
Axios,
History,
ReactRedux,
Immutable,
ReduxSagaEffects,
Echarts,
ReactSlick,
});
export default registerGlobal;
複製代碼
在肯定了程序拆分運行的總體銜接以後,咱們還要肯定開發方案、部署方案以及回滾方案。咱們如何開始開發一個子工程?以及咱們如何部署咱們的子工程?
開發流程
有兩種開發方案能夠知足獨立開發的目的:第一種是提供一個基座工程的Dev環境,子工程在本地啓動後在Dev環境進行開發,這種開發方式要求有一套基座工程的更新機制,例如基座工程更新後要同步部署到Dev環境。第二種是子工程開發者拉取基座工程到本地並啓動本地開發環境,而後拉取子工程到本地,再啓動子工程本地開發環境進行開發,這種開發方式是目前咱們使用的方式。以下圖所示,咱們提供了子工程腳手架來快速建立子工程,開發者無需作任何配置和額外學習成本,就能夠像開發React應用同樣進行開發。
熱更新
在開發過程當中,咱們但願咱們的開發體驗和開發單頁應用的體驗一致,也要支持熱更新。因爲咱們的拆分,實際上有兩個服務,即基座和子工程,因此咱們以上圖的方式完成了熱更新的支持:在子工程的module.hot中經過再次觸發基座工程中的JSONP鉤子來通知基座工程,來再次觸發renderApp達到子工程更新代碼則頁面熱刷新的目的。主要代碼以下:
// 在子工程入口文件
import routes from './routes/index';
function registerApp(dep: any = {}): any {
return {
routes,
};
}
if ((module as any).hot) {
(module as any).hot.accept('./routes/index', (): any => {
window.wmadSubapp(registerApp, true); // 支持子工程熱加載的信息傳遞
});
}
export default registerApp
複製代碼
Mock數據
子工程目前Mock數據的方式有三種:一是在基座本地Mock,這種Mock方式自然支持,由於基座工程基於外賣工程化Nine腳手架進行開發,自己支持本地Mock。二是支持子工程本地Mock。三是使用公共Mock服務YAPI。目前子工程開發的Mock功能結合第一種方式和第三種方式進行。
最後是部署方案,咱們達成了獨立部署上線的目的,即子工程發佈不須要基座工程的參與。以前全部子業務線都在一個工程中,打包速度隨着業務線的膨脹愈來愈慢,而以下的方案使得子工程的開發和部署徹底獨立,單個業務線的打包速度會很是快,從以前的分鐘級別降到了秒級別。以下圖所示,能夠看到,子工程部署只須要把子工程打包,並在上傳CDN以後,把配置信息更新便可,由於配置信息中有子工程新的資源地址,這樣就達到了發佈上線的目的。
整個部署過程咱們是託管到Talos(美團內部自研的部署工具)上的,配置信息咱們是託管到Portm(美團內部自研的文件存儲)上的(經過咱們開發的Talos的插件UpdatePubInfo-To-Portm來更新咱們的配置信息)。在靜態資源上傳到CDN以後,就能夠更新配置信息,供主工程調用,也就完成了子工程上線的過程。利用美團現有服務,咱們很迅速地完成了子工程單獨部署上線的整個流程。
在部署方案中,咱們經過Talos進行部署,它自己就帶有回滾功能。得益於子工程的發佈和普通工程的發佈並沒什麼本質不一樣,都是將靜態資源放置到CDN上,經過靜態資源的的contenthash值來區分不一樣版本,因此回滾的時候,Talos取到上個版本(或者某個前版本)的靜態資源,再經過Portm更新咱們的配置信息便可完成。整個過程和普通工程沒有區別,發版人員只需簡單地點下回滾按鈕便可。
改變了原有的開發模式後,咱們還對幾個關鍵節點進行了監控報警的埋點。利用美團CAT(已經在GitHub上開源)和天網(美團內部的監控系統),咱們分別在子工程的配置信息、靜態資源加載等節點上進行了埋點上報,統計子工程加載成功率,及時發現可能出現的子工程切換問題。具體狀況以下圖所示:
上方左圖是按照端維度進行統計的示例,上方右圖是PC端按照產品線統計加載成功數的示例。默認都是統計當天的數據,顯示‘-’的代表當前沒有數據。對資源加載的監控目前有三種類型:JSON、JS和CSS,資源加載失敗的統計也包含這三種類型。天網的監控按照分鐘級進行,每分鐘內若是有加載失敗就會發出報警,偶爾的報警多是用戶網絡的問題,若是出現大批量的報警就要引發重視了。
以上就是微前端在外賣商家廣告端的實踐過程。總的來講,咱們完成了如下的目標:
張嘯、魏瀟、天堯,均爲美團外賣前端團隊研發工程師。
美團外賣廣告前端團隊誠招高級前端開發、前端開發專家。咱們爲商家提供變現服務平臺,爲用戶提供優質廣告體驗,是外賣商業變現中的重要環節。歡迎各位小夥伴的加入,共同打造極致廣告產品。感興趣的同窗可投遞簡歷至:tech@meituan.com(郵件標題註明:美團外賣廣告前端團隊)