Categories
程式開發

微前端在美團外賣的實踐


背景

微前端是一種利用微件拆分來達到工程拆分治理的方案,可以解決工程膨脹、開發維護困難等問題。隨著前端業務場景越來越複雜,微前端這個概念最近被提起得越來越多,業界也有很多團隊開始探索實踐並在業務中進行了落地。可以看到,很多團隊也遇到了各種各樣的問題,但各自也都有著不同的處理方案。誠然,任何技術的實現都要依託業務場景才會變得有意義,所以在闡述美團外賣廣告團隊的微前端實踐之前,我們先來簡單介紹一下外賣商家廣告端的業務形態。目前,我們開發和維護的系統主要包括三端:

  • PC系統:單門店投放系統PC端
  • H5系統:單門店投放系統H5端
  • KA系統:多門店投放系統PC端

微前端在美團外賣的實踐 33

如上圖所示,原始解決方案的三端由各自獨立開發和維護,各自包含所有的業務線,而我們的業務開發情況是:

  • PC端和H5端相同業務線的基本業務邏輯一致,UI差異大。
  • PC端和KA端相同業務線的部分業務邏輯一致,UI差異小。

在這種特殊的業務場景下,就會出現一個有關開發效率的抉擇問題。即我們希望能複用的部分只開發一次,而不是三次。那麼接下來,就有兩個問題擺在我們面前:

  • 如何進行物理層面的複用(不同端的代碼在不同地址的Git倉庫)。
  • 如何進行邏輯層面的複用(不同端的相同邏輯如何使用一份代碼進行抽象)。

我們這裡重點看一下物理層面的複用,即:如何在物理空間上使得各自獨立的三端系統(不同倉庫)引入我們的複用層?我們嘗試了NPM包、Git subtree等類“共享文件”的方式後發現,最有效率的複用方式是把三個系統放在一個倉庫裡,去消除物理空間上的隔離,而不是去連接不同的物理空間。當然,我們三端系統的技術棧是一致的,所以就進行瞭如下圖的改造:

微前端在美團外賣的實踐 34

可以看到,當我們把三端系統放在一個倉庫中時,通過common文件夾提供了物理層面可複用的土壤,不再需要“共享文件”式地進行頻繁地拉取操作,直接引用複用即可。不過,在帶來物理層面復用效率提升的同時,也加速了整個工程出現了爆炸式發展的問題,隨著產品線從最初的幾個發展到現在的幾十個之多,工程管理成本也在迅速增長。具體來說,包括如下四個方面:

  • 新業務線產品急速增加,同時為了保證三端系統復用效率的最大化,把文件放入同一倉庫管理,導致文件數量增長極快,管理及協同開發難度也在不斷加大。
  • 文件越來越多,文件結構越不受控制,業務開發尋址變得越來越困難。
  • 文件越來越多,開發、構建、部署速度變得越來越慢,開發體驗在持續下降。
  • 不同業務線間沒有物理隔離,出現了跨業務線互相引用混亂,例如A業務線出現了B業務線名字的組件。

如下圖所示,具體地說明了原有架構存在的問題。為了要解決這些問題,我們意識到需要拆分這些應用,即進行工程優化的常規手段進行“分治”。那麼要怎麼拆呢?自然而然地我們就想到了微前端的概念。也從這個概念出發,我們參考業界優秀方案,同時也深度結合了廣告端實際業務的開發情況,對現有工程進行了微前端的實踐與落地。

微前端在美團外賣的實踐 35

需求分析

結合現有工程的狀況,我們進行了深度的分析。不過,在進行微前端方案確定前,我們先確定了需求點及期望收益,如下表所示:

微前端在美團外賣的實踐 36

方案選擇

經過以上的需求分析,我們調研了業界及公司周邊的微前端方案,並總結了以下幾種方案以及它們各自主要的特點:

  • NPM式:子工程以NPM包的形式發布源碼;打包構建發布還是由基座工程管理,打包時集成。
  • iframe式:子工程可以使用不同技術棧;子工程之間完全獨立,無任何依賴;基座工程和子工程需要建立通信機制;無單頁應用體驗;路由地址管理困難。
  • 通用中心路由基座式:子工程可以使用不同技術棧;子工程之間完全獨立,無任何依賴;統一由基座工程進行管理,按照DOM節點的註冊、掛載、卸載來完成。
  • 特定中心路由基座式:子業務線之間使用相同技術棧;基座工程和子工程可以單獨開發單獨部署;子工程有能力復用基座工程的公共基建。

通過對各個方案特點進行分析,我們將重點關注項進行了對比,如下表所示:

微前端在美團外賣的實踐 37

經過上面的調研對比之後,我們確定採用了特定中心路由基座式的開發方案,並命名為:基於React的中心路由基座式微前端。這種方案的優點包括以下幾個方面:

  • 保證技術棧統一在React。
  • 子工程之間開發互相獨立,互不影響。
  • 子工程可單獨打包、單獨部署上線。
  • 子工程有能力復用基座工程的公共基建。
  • 保持單頁應用的體驗,子工程之間切換不刷新。
  • 改造成本低,對現有工程侵入度較低,業務線遷移成本也較低。
  • 開發子工程和原有開發模式基本沒有不同,開發人員學習成本較低。

微前端實踐概覽

通過對方案的分析及技術方向上的梳理,我們確定了微前端的整體方案,如下圖所示:

微前端在美團外賣的實踐 38

可以看到,整個方案非常簡單明確,即按照業務線進行了路由級別的拆分。整個系統可分為兩個部分:

  • 基座工程:用於管理子工程的路由切換、註冊子工程的路由和全局Store層、提供全局庫和復用層。
  • 子工程:用於開發子業務線業務代碼,一個子工程對應一個子業務線,並且包含三端代碼和復用層代碼。

基座工程和子工程聯繫起來的橋樑則是子工程的入口文件地址和路由地址的映射信息。這些映射信息可以讓基座工程準確地發現子工程資源的路徑從而進行加載。

微前端架構下的業務變化

經過微前端實踐的改造,我們的業務在結構上發生瞭如下的變化:

微前端在美團外賣的實踐 39

如上圖所示,我們進行了微前端式的業務線拆分:

  • 原有的PC系統、H5系統、KA系統分別改造成了PC基座系統、H5基座系統和KA基座系統。
  • 原有的子業務線被拆分成了單獨的子倉庫,成為了業務線子工程(上圖中6個黑框豎列)。
  • 業務線子工程分別包含PC端、H5端、KA端以及該業務線復用層的代碼(上圖中3個純色背景橫列)。

新的拆分使得子工程能夠按照業務線進行劃分,獨立維護。在解決復用層的同時保證了子工程大小可控,即子工程只有單個業務線的代碼。而單個業務線的複雜度並不高,也降低了工程維護的複雜度。

採用微前端拆分的方案,使得我們的業務不僅在縱向上保有了復用的能力,更重要的是擁有了橫向擴展的能力,無論產品業務線如何膨脹,我們都可以更輕鬆地應對。那麼為了實現以上的能力,我們做了哪些工作呢?下文我們會詳細進行說明。

基於React技術棧的中心路由基座式微前端

微前端拆分的方案,我們命名為:基於React技術棧的中心路由基座式微前端。在具體實現上,我們會分為動態化方案路由配置信息設計子工程接口設計復用方案設計和流程方案設計等幾個模塊來逐一進行說明。

動態化方案

首先,我們需要路由的管理方案,使得子工程之間有能力互通切換。其次,我們需要Store層的方案,讓子工程有能力使用全局Store。並且,我們還需要CSS的加載方案,來加載子工程的樣式佈局。下面來詳細說明這三個方案。

動態路由

動態路由方案是想要進行路由級別的拆分,首先我們要確定用什麼來管理路由?很多實現方案傾向於使用特製路由來管理模塊。例如開源框架Single-Spa,實現了自己的一套路由監聽來切換子工程,並且需要子工程實現特定的註冊、掛載、卸載等接口來完成子工程和基座工程的動態對接,還需要特定的模塊管理系統,例如systemjs來輔助完成這一過程。毋庸置疑,這對我們原有工程的改造成本很大,還需要添加額外庫,進而造成包體積大小上的開銷。並且子工程的開發者需要熟悉這些特定的接口,學習成本也比較高。顯然,這對於我們的業務場景和需求來說很不划算。

那麼,我們選擇什麼來做路由管理呢?最終我們使用了React-Router,這樣能夠保持我們原來的技術棧不變,同時對於工程的侵入也是最低,幾乎可以忽略不計。此外,React-Router完全可以滿足我們的需求,而且自動會幫助我們管理頁面的加載與卸載,而不是每次切換路由都重新初始化整個子應用,所以在加載速度體驗上也是最優的,跟單頁應用的體驗一致。

在實現上也很簡單,如下圖所示:

微前端在美團外賣的實踐 40

上面這個流程圖,展示了我們在基座工程中切換到子工程路由時,加載子工程並進行展示的過程。這裡的重點步驟是加載子工程入口文件,並動態註冊子工程路由的過程。由於我們使用的是React-Router,顯然要使用其提供的動態能力來完成。這一過程也非常輕量,由於React-Router從版本4開始有了“破壞級”的升級,於是我們就調研了兩種方式進行動態加載路由(目前我們使用的是React-Router版本5),如下表所示:

微前端在美團外賣的實踐 41

React-Router版本3中,實現的基本代碼思路如下:

// react-router V3 用于接收子工程的路由
export default () => (
     {
            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 = ({ 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也是花費很小的改造成本就可以完成。

微前端在美團外賣的實踐 42

動態CSS

同樣的對應子工程的樣式佈局,我們也需要通過某種途徑加載到基座工程中來。這個很自然地用異步加載CSS文件通過style標籤注入來完成,不過這裡需要注意兩個問題:

一個問題是,加載子工程的JS入口文件和CSS文件可以同時發起請求,但是需要保證CSS文件加載完成後再進行JS入口文件的路由註冊。因為如果路由先註冊了頁面就會顯示出來,如果這時CSS文件還沒有加載完畢,就會出現頁面樣式閃動的問題。我們通過先加載CSS再加載JS的策略來避免這個問題的發生。

另一個問題是,怎麼保證子工程的CSS不會和其他子工程衝突。我們利用PostCSS插件在編譯子工程時,按照分配給子工程的唯一業務線標識,為每一組CSS規則生成了命名空間來解決這個問題。而子業務線開發者是沒有感知的,可以沒有“心智負擔”地書寫子工程的樣式。

路由配置信息方案

在動態加載方案確定之後,基座工程怎麼才能知道子工程的資源路徑,進而加載對應的JS和CSS資源呢?我們需要一組映射信息。如下圖所示,業務線唯一標識為Key,相應的靜態資源地址為Value。這樣的話,當基座工程切換到子工程時就可以拉取這個配置信息,在路由切換時準確地找到對應的子工程,進而進行後續的資源加載過程。這裡可能會遇到的一個問題,即如果JS和CSS過大,是否能進行拆分?

根據我們業務的實際情況,目前靜態資源的大小是可控的,無需註冊多個,單一入口地址完全能夠滿足我們的業務需求,並且由於我們的改造完全基於現有技術棧。如果業務很複雜,完全可以在子工程中通過webpack的動態import進行路由懶加載,也就是說,子工程完全可以按照路由再次切分成chunks來減少JS的包體積。至於CSS本身就很小,長期也不會有進行切分的需要。

微前端在美團外賣的實踐 43

子工程接口方案

子工程需要暴露它要註冊給基座工程的對象,來進行基座工程加載子工程的過程。在子工程入口文件中定義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 = () => (
    
        {
            routes.map(item => (
                
            ))
        }
        
    
);
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應用一樣進行開發。

微前端在美團外賣的實踐 44

熱更新

在開發過程中,我們希望我們的開發體驗和開發單頁應用的體驗一致,也要支持熱更新。由於我們的拆分,實際上有兩個服務,即基座和子工程,所以我們以上圖的方式完成了熱更新的支持:在子工程的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之後,把配置信息更新即可,因為配置信息中有子工程新的資源地址,這樣就達到了發布上線的目的。

微前端在美團外賣的實踐 45

整個部署過程我們是託管到Talos(美團內部自研的部署工具)上的,配置信息我們是託管到Portm(美團內部自研的文件存儲)上的(通過我們開發的Talos的插件UpdatePubInfo- To-Portm來更新我們的配置信息)。在靜態資源上傳到CDN之後,就可以更新配置信息,供主工程調用,也就完成了子工程上線的過程。利用美團現有服務,我們很迅速地完成了子工程單獨部署上線的整個流程。

微前端在美團外賣的實踐 46

回滾方案

在部署方案中,我們通過Talos進行部署,它本身就帶有回滾功能。得益於子工程的發布和普通工程的發布並沒什麼本質不同,都是將靜態資源放置到CDN上,通過靜態資源的的contenthash值來區分不同版本,所以回滾的時候,Talos取到上個版本(或者某個前版本)的靜態資源,再通過Portm更新我們的配置信息即可完成。整個過程和普通工程沒有區別,發版人員只需簡單地點下回滾按鈕即可。

微前端在美團外賣的實踐 47

監控方案

改變了原有的開發模式後,我們還對幾個關鍵節點進行了監控報警的埋點。利用美團CAT(已經在GitHub上開源)和天網(美團內部的監控系統),我們分別在子工程的配置信息、靜態資源加載等節點上進行了埋點上報,統計子工程加載成功率,及時發現可能出現的子工程切換問題。具體情況如下圖所示:

微前端在美團外賣的實踐 48

上方左圖是按照端維度進行統計的示例,上方右圖是PC端按照產品線統計加載成功數的示例。默認都是統計當天的數據,顯示‘-’的表明當前沒有數據。對資源加載的監控目前有三種類型:JSON、JS和CSS,資源加載失敗的統計也包含這三種類型。天網的監控按照分鐘級進行,每分鐘內如果有加載失敗就會發出報警,偶爾的報警可能是用戶網絡的問題,如果出現大批量的報警就要引起重視了。

總結

以上就是微前端在外賣商家廣告端的實踐過程。總的來說,我們完成了以下的目標:

  • 按照領域(業務線)拆分工程,工程的可維護性得到提高,相關領域進行了內聚,無關領域進行了解耦。
  • 子工程提供了PC、H5、KA三端的物理復用土壤,消除了工程膨脹問題,工程大小也變得可控。
  • 子工程打包速度從分鐘級降為秒級,提高了開發體驗,加快了上線的速度。
  • 子工程開發支持熱更新,開發體驗不降級。
  • 子工程能夠單獨開發、單獨部署、單獨上線,業務線間互不影響。
  • 整體工程改造成本低,插拔式開發,無侵入式代碼,在正常業務開發的同時短期內就可以完成上線。
  • 開發者學習成本低,完整地保留了單頁應用開發的開發體驗,開發者可快速上手。

作者介紹

張嘯、魏瀟、天堯,均為美團外賣前端團隊研發工程師。

本文轉載自公眾號美團技術團隊(ID:meituantech)。

原文鏈接

https://mp.weixin.qq.com/s/l17Uo6Q7up44uZI_VojFzw